ci(release): fix gaps found in review

- Pushes to main get their own CI concurrency group. In the shared
  group, a push cancelled the waiting runs of the previous commit, so
  that commit got no CI (cbec2687c), and a release built on it failed
  its check wait.
- release.yml releases only when the version goes up. A reverted
  release commit started a release that failed at the tag step.
- The tag step keeps a tag that already points at the release commit,
  annotated or not, and fails with a clear error for any other commit.
- release.ts deletes a branch or tag on failure only if it created it,
  and retries a failed read of the required checks.
- prepare-release prints the new release steps. Comments now say that
  a dry run creates a GitHub prerelease, that the tag step needs the
  "Main tags" ruleset to allow tag creation, and what signing rezips.
- The npm package extraction loops over packages, and build-examples
  requires the artifact name its only caller passes.
This commit is contained in:
Simon Klee committed 2026-10-07 11:19:31 +02:00
1 parent 998c6d6127
commit c2d3bf89c2
12 files changed
+106 -124

No files matched your search

+11 -56
View File
@@ -28,72 +28,27 @@ runs:
echo "Extracting npm packages..."
unzip -q npm-packages.zip
# Copy core dist
if [ -d "npm-packages/core-dist" ]; then
cp -r npm-packages/core-dist packages/core/dist
echo "Copied core dist"
fi
for package in core three qrcode react solid keymap ssh; do
if [ -d "npm-packages/$package-dist" ]; then
cp -r "npm-packages/$package-dist" "packages/$package/dist"
echo "Copied $package dist"
fi
done
if [ -d "npm-packages/three-dist" ]; then
cp -r npm-packages/three-dist packages/three/dist
echo "Copied three dist"
fi
if [ -d "npm-packages/qrcode-dist" ]; then
cp -r npm-packages/qrcode-dist packages/qrcode/dist
echo "Copied qrcode dist"
fi
# Copy native packages to core's node_modules
# Replace Bun-managed symlinks with the packaged native directories.
if [ -d "npm-packages/core-native-packages" ]; then
mkdir -p packages/core/node_modules/@opentui
# Replace Bun-managed symlinks with the packaged native directories.
rm -rf packages/core/node_modules/@opentui/core-darwin-x64
rm -rf packages/core/node_modules/@opentui/core-darwin-arm64
rm -rf packages/core/node_modules/@opentui/core-linux-x64
rm -rf packages/core/node_modules/@opentui/core-linux-arm64
rm -rf packages/core/node_modules/@opentui/core-linux-x64-musl
rm -rf packages/core/node_modules/@opentui/core-linux-arm64-musl
rm -rf packages/core/node_modules/@opentui/core-win32-x64
rm -rf packages/core/node_modules/@opentui/core-win32-arm64
rm -rf packages/core/node_modules/@opentui/core-*
cp -r npm-packages/core-native-packages/* packages/core/node_modules/@opentui/
echo "Copied native packages"
fi
# Copy other package dists
if [ -d "npm-packages/react-dist" ]; then
cp -r npm-packages/react-dist packages/react/dist
echo "Copied react dist"
fi
if [ -d "npm-packages/solid-dist" ]; then
cp -r npm-packages/solid-dist packages/solid/dist
echo "Copied solid dist"
fi
if [ -d "npm-packages/keymap-dist" ]; then
cp -r npm-packages/keymap-dist packages/keymap/dist
echo "Copied keymap dist"
fi
if [ -d "npm-packages/ssh-dist" ]; then
cp -r npm-packages/ssh-dist packages/ssh/dist
echo "Copied ssh dist"
fi
echo "Package extraction complete"
- name: Run packed Node smoke tests
if: inputs.smoke-test == 'true'
shell: bash
run: |
cd packages/core
bun run test:dist --skip-build
cd ../solid
bun run test:dist --skip-build
cd ../keymap
bun run test:dist --skip-build
cd ../ssh
bun run test:dist --skip-build
for package in core solid keymap ssh; do
(cd "packages/$package" && bun run test:dist --skip-build)
done
+3 -1
View File
@@ -10,7 +10,9 @@ permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# Each push to main gets its own group. In a shared group, the next push cancels a waiting run, and
# that commit gets no CI.
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
+3 -4
View File
@@ -8,10 +8,9 @@ on:
required: true
type: string
npmPackagesArtifact:
description: "Name of the npm packages artifact. Defaults to npm-packages-<version>."
required: false
description: "Name of the npm packages artifact"
required: true
type: string
default: ""
permissions:
contents: read
@@ -41,7 +40,7 @@ jobs:
- name: Download npm packages artifact
uses: actions/download-artifact@v8
with:
name: ${{ inputs.npmPackagesArtifact || format('npm-packages-{0}', inputs.version) }}
name: ${{ inputs.npmPackagesArtifact }}
- name: Extract and install npm packages
run: |
+3 -1
View File
@@ -10,7 +10,9 @@ permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# Each push to main gets its own group. In a shared group, the next push cancels a waiting run, and
# that commit gets no CI.
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
+3 -1
View File
@@ -10,7 +10,9 @@ permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# Each push to main gets its own group. In a shared group, the next push cancels a waiting run, and
# that commit gets no CI.
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
+3 -1
View File
@@ -10,7 +10,9 @@ permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# Each push to main gets its own group. In a shared group, the next push cancels a waiting run, and
# that commit gets no CI.
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
+3 -1
View File
@@ -10,7 +10,9 @@ permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# Each push to main gets its own group. In a shared group, the next push cancels a waiting run, and
# that commit gets no CI.
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
+3 -1
View File
@@ -10,7 +10,9 @@ permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# Each push to main gets its own group. In a shared group, the next push cancels a waiting run, and
# that commit gets no CI.
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
+26 -16
View File
@@ -40,6 +40,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.1
- name: Extract version and dry-run flag
id: extract
env:
@@ -57,15 +62,16 @@ jobs:
TAG=$(jq -r .version packages/core/package.json)
git fetch --quiet --no-tags --depth=1 origin "$BEFORE"
PREVIOUS=$(git show "$BEFORE:packages/core/package.json" | jq -r .version)
if [ "$TAG" = "$PREVIOUS" ]; then
echo "The version is still $TAG, so this push is not a release"
# A version that does not go up, such as a reverted release commit, is not a release.
if ! TAG="$TAG" PREVIOUS="$PREVIOUS" bun -e 'import { compareVersions } from "./scripts/npm-publish.ts"; process.exit(compareVersions(process.env.TAG ?? "", process.env.PREVIOUS ?? "") > 0 ? 0 : 1)'; then
echo "The version went from $PREVIOUS to $TAG, so this push is not a release"
echo "release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
IS_DRY_RUN=false
VERSION="$TAG"
RELEASE_TITLE="Release v$TAG"
echo "The version changed from $PREVIOUS to $TAG. This is a PRODUCTION release"
echo "The version went from $PREVIOUS to $TAG. This is a PRODUCTION release"
fi
echo "release=true" >> "$GITHUB_OUTPUT"
@@ -79,27 +85,31 @@ jobs:
echo "Is Dry Run: $IS_DRY_RUN"
echo "Release Title: $RELEASE_TITLE"
- name: Setup Bun
if: steps.extract.outputs.release == 'true' && steps.extract.outputs.isDryRun == 'false'
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.1
# main can move between opening a release pull request and merging it.
- name: Wait for the checks of the previous commit
if: steps.extract.outputs.release == 'true' && steps.extract.outputs.isDryRun == 'false'
if: steps.extract.outputs.isDryRun == 'false'
env:
BEFORE: ${{ github.event.before }}
GH_TOKEN: ${{ github.token }}
run: bun scripts/release.ts wait-checks "$BEFORE"
# Before the build, so a release stops early when the tag cannot be created. A rerun finds the
# tag at this commit and continues. A tag pushed with GITHUB_TOKEN starts no workflow.
# Before the build, so a release stops early when the tag cannot be created. Needs the "Main tags"
# ruleset to allow creating v* tags. A tag already at this commit, from a rerun or pushed by hand,
# is kept. A tag pushed with GITHUB_TOKEN starts no workflow.
- name: Tag the release commit
if: steps.extract.outputs.release == 'true' && steps.extract.outputs.isDryRun == 'false'
if: steps.extract.outputs.isDryRun == 'false'
env:
TAG: v${{ steps.extract.outputs.fullTag }}
run: git push origin "$GITHUB_SHA:refs/tags/$TAG"
run: |
set -euo pipefail
# The commit of the tag: an annotated tag lists it as the peeled ^{} line, which sorts last.
existing=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}" | sort -k2 | tail -n 1 | cut -f1)
if [ -z "$existing" ]; then
git push origin "$GITHUB_SHA:refs/tags/$TAG"
elif [ "$existing" != "$GITHUB_SHA" ]; then
echo "::error::$TAG already exists at $existing, not at $GITHUB_SHA"
exit 1
fi
# Version validation check
validate-version:
@@ -169,8 +179,8 @@ jobs:
version: ${{ needs.prepare.outputs.fullTag }}
npmPackagesArtifact: ${{ needs.sign-windows-artifacts.outputs.npmPackagesArtifact }}
# Tests the packages that npm-publish publishes. They do not load the Windows DLLs, the only files
# that signing changes, so they run while signing runs.
# Tests the packages before signing, so the tests run while signing runs. Signing replaces only the
# Windows DLLs, which the tests do not load, but it unzips and rezips every package on Windows.
smoke-test:
name: Smoke Test Packages
if: ${{ needs.prepare.outputs.release == 'true' }}
+28
View File
@@ -70,6 +70,34 @@ export function publishDirs(names: readonly string[] = []): string[] {
)
}
// Semver precedence: negative when left is older than right.
export function compareVersions(left: string, right: string): number {
const [leftCore = "", leftPre] = left.split(/-(.*)/)
const [rightCore = "", rightPre] = right.split(/-(.*)/)
const leftParts = leftCore.split(".").map(Number)
const rightParts = rightCore.split(".").map(Number)
for (let index = 0; index < 3; index++) {
const difference = leftParts[index]! - rightParts[index]!
if (difference !== 0) return difference
}
if (leftPre === undefined || rightPre === undefined)
return (leftPre === undefined ? 1 : 0) - (rightPre === undefined ? 1 : 0)
const leftIds = leftPre.split(".")
const rightIds = rightPre.split(".")
for (let index = 0; index < Math.max(leftIds.length, rightIds.length); index++) {
const leftId = leftIds[index]
const rightId = rightIds[index]
if (leftId === undefined || rightId === undefined) return leftId === undefined ? -1 : 1
if (leftId === rightId) continue
const leftNumeric = /^\d+$/.test(leftId)
const rightNumeric = /^\d+$/.test(rightId)
if (leftNumeric && rightNumeric) return Number(leftId) - Number(rightId)
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
return leftId < rightId ? -1 : 1
}
return 0
}
function isSnapshotVersion(version: string): boolean {
return version.includes("-snapshot") || /^0\.0\.0-\d{8}-[a-f0-9]{8}$/.test(version)
}
+5 -5
View File
@@ -98,11 +98,11 @@ ${lockstepPackages.map((pkg) => `- ${pkg.name}`).join("\n")}
Next steps:
1. Review the changes: git diff
2. Build the packages: bun run build
3. Commit the changes: git add -A && git commit -m "Release v${version}"
4. Push the commit: git push
5. Tag the release after the commit: git tag v${version} -m "Release v${version}"
6. Push the tag to trigger the release workflow: git push origin v${version}
2. Commit the changes: git add -A && git commit -m "Release v${version}"
3. Get the commit onto main, pushed or through a pull request. release.yml releases each commit on
main that changes the version, and creates the v${version} tag.
\`bun run release\` does all of this and follows the release.
`)
function getRequestedReleaseType(args: string[], explicitVersion?: string): ReleaseType | null {
+15 -37
View File
@@ -5,7 +5,7 @@ import process from "node:process"
import { setTimeout as sleep } from "node:timers/promises"
import { fileURLToPath } from "node:url"
import { registryIntegrity } from "./npm-publish"
import { compareVersions, registryIntegrity } from "./npm-publish"
// Releases main. release.yml publishes each commit on main that changes the version, so a release is
// a "Release vX.Y.Z" commit on main, pushed directly or merged from a pull request.
@@ -23,8 +23,9 @@ import { registryIntegrity } from "./npm-publish"
// --pr pushes the commit to a release/vX.Y.Z branch and opens a pull request instead. Merging it
// releases. The "Prepare Release" workflow, the release button, runs this mode.
//
// --dry-run tags the commit vX.Y.Z-dry.N and pushes only the tag. main does not change, and the
// release run builds and packs every package but publishes nothing. --no-watch stops after the push.
// --dry-run tags the commit vX.Y.Z-dry.N and pushes only the tag. main does not change. The release
// run builds and packs every package but publishes nothing to npm; it creates a GitHub prerelease.
// --no-watch stops after the push.
//
// bun scripts/release.ts wait-checks <sha>
//
@@ -253,9 +254,10 @@ function pendingChecks(repo: string, sha: string, required: readonly RequiredChe
}
async function waitForChecks(repo: string, sha: string): Promise<void> {
const required = requiredChecks(repo)
let required: RequiredCheck[] | undefined
let reported = ""
await poll(`the checks of ${short(sha)}`, CHECKS_TIMEOUT_MS, () => {
required ??= requiredChecks(repo)
const pending = pendingChecks(repo, sha, required)
if (pending.length === 0) return true
const summary = pending.join(", ")
@@ -271,34 +273,6 @@ function coreVersion(): string {
.version
}
// Semver precedence: negative when left is older than right.
function compareVersions(left: string, right: string): number {
const [leftCore = "", leftPre] = left.split(/-(.*)/)
const [rightCore = "", rightPre] = right.split(/-(.*)/)
const leftParts = leftCore.split(".").map(Number)
const rightParts = rightCore.split(".").map(Number)
for (let index = 0; index < 3; index++) {
const difference = leftParts[index]! - rightParts[index]!
if (difference !== 0) return difference
}
if (leftPre === undefined || rightPre === undefined)
return (leftPre === undefined ? 1 : 0) - (rightPre === undefined ? 1 : 0)
const leftIds = leftPre.split(".")
const rightIds = rightPre.split(".")
for (let index = 0; index < Math.max(leftIds.length, rightIds.length); index++) {
const leftId = leftIds[index]
const rightId = rightIds[index]
if (leftId === undefined || rightId === undefined) return leftId === undefined ? -1 : 1
if (leftId === rightId) continue
const leftNumeric = /^\d+$/.test(leftId)
const rightNumeric = /^\d+$/.test(rightId)
if (leftNumeric && rightNumeric) return Number(leftId) - Number(rightId)
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
return leftId < rightId ? -1 : 1
}
return 0
}
function nextDryRunTag(version: string): string {
const prefix = `v${version}-dry.`
const numbers = git("ls-remote", "--tags", "--refs", "origin", `refs/tags/${prefix}*`)
@@ -367,18 +341,22 @@ async function pushRelease(options: Options, base: string): Promise<Release> {
if ((await registryIntegrity("@opentui/core", version)) !== undefined) {
throw new ReleaseError(`@opentui/core@${version} is already on npm`)
}
// The branch and the tag are recorded only once created, so that a failure never deletes one that
// existed before.
if (options.mode === "pr") {
branch = `release/v${version}`
if (remoteSha(`refs/heads/${branch}`)) throw new ReleaseError(`Branch ${branch} already exists on origin`)
git("switch", "--quiet", "--create", branch)
const name = `release/v${version}`
if (remoteSha(`refs/heads/${name}`)) throw new ReleaseError(`Branch ${name} already exists on origin`)
git("switch", "--quiet", "--create", name)
branch = name
}
stopIfInterrupted()
console.log(`Prepared ${previous} -> ${version}. Committing...`)
run("git", ["commit", "--quiet", "--all", "--message", `Release v${version}`], { inherit: true })
if (options.mode === "dry-run") {
tag = nextDryRunTag(version)
run("git", ["tag", "--annotate", tag, "--message", `Release ${tag}`], { inherit: true })
const name = nextDryRunTag(version)
run("git", ["tag", "--annotate", name, "--message", `Release ${name}`], { inherit: true })
tag = name
}
const sha = git("rev-parse", "HEAD")
stopIfInterrupted()