From f1d3c93469642ae8c877a4e3a4a69417e590961d Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Wed, 7 Oct 2026 13:03:58 +0200 Subject: [PATCH] ci(release): release from the tag that bun run release pushes (#1572) The v0.5.15 release run failed to sign the Windows DLLs. It ran on the push of the release commit to main, and Azure, which signs them, trusts only runs on a tag (subject repo:anomalyco/opentui:ref:refs/heads/main had no match). `bun run release` now tags the release commit and pushes it together with the tag, and release.yml releases again on the push of a vX.Y.Z tag, so signing runs on the tag as it did before. A push to main that raises the version still releases when the version has no tag yet, as when a release pull request is merged, but that needs Azure to trust main first. --- .github/workflows/release.yml | 46 ++++++++++++++++++++++++----------- scripts/release.ts | 38 +++++++++++++++++++---------- 2 files changed, 57 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fb110cc..a1f7598f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,21 @@ name: Release -# A push to main, or to a maintenance branch of an older line such as 0.5.x, releases when it raises the -# version in packages/core/package.json: a "Release vX.Y.Z" commit, pushed by `bun run release` or -# merged from the pull request that the release button opens. A vX.Y.Z-dry.N tag is a dry run. A tag -# that contains "snapshot" publishes a snapshot. +# A push of a vX.Y.Z tag releases that version: `bun run release` pushes the tag with its "Release +# vX.Y.Z" commit. The Windows signing in Azure trusts only runs on a tag. +# +# A push to main, or to a maintenance branch of an older line such as 0.5.x, that raises the version +# in packages/core/package.json releases it when the version has no tag yet: a release commit merged +# from the pull request that the release button opens. That run tags the commit itself, and needs Azure +# to trust main and the maintenance branches. +# +# A vX.Y.Z-dry.N tag is a dry run. A tag that contains "snapshot" publishes a snapshot. on: push: branches: [main, "[0-9]+.[0-9]+.x"] # Path filters do not apply to tag pushes. paths: [packages/core/package.json] tags: - - "v*-dry.*" + - "v*" - "*snapshot*" permissions: @@ -74,12 +79,18 @@ jobs: run: | set -euo pipefail if [[ "$GITHUB_REF" == refs/tags/* ]]; then - # Only vX.Y.Z-dry.N tags reach this job. TAG=${GITHUB_REF#refs/tags/v} - IS_DRY_RUN=true - VERSION=$(echo "$TAG" | sed -E 's/-dry\.[0-9]+$//') - RELEASE_TITLE="Release v$TAG (DRY RUN)" - echo "This is a DRY RUN release" + if [[ "$TAG" =~ -dry\. ]]; then + IS_DRY_RUN=true + VERSION=$(echo "$TAG" | sed -E 's/-dry\.[0-9]+$//') + RELEASE_TITLE="Release v$TAG (DRY RUN)" + echo "This is a DRY RUN release" + else + IS_DRY_RUN=false + VERSION="$TAG" + RELEASE_TITLE="Release v$TAG" + echo "This is a PRODUCTION release" + fi else # A version that does not go up, such as a reverted release commit, is not a release. A # failure of the comparison fails the job. @@ -97,6 +108,13 @@ jobs: echo "::error::$CURRENT is not a version of $GITHUB_REF_NAME" exit 1 fi + # `bun run release` pushes the tag with the commit, and the run of the tag push releases it. + # This also skips a rerun of all jobs after the tag step below; rerun the failed jobs instead. + if git ls-remote --exit-code --tags origin "refs/tags/v$CURRENT" > /dev/null; then + echo "v$CURRENT is already tagged, so the run of its tag push releases it" + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi TAG="$CURRENT" IS_DRY_RUN=false VERSION="$TAG" @@ -115,11 +133,11 @@ jobs: echo "Is Dry Run: $IS_DRY_RUN" echo "Release Title: $RELEASE_TITLE" - # 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. + # A release from a branch push tags its commit before the build, so it stops early when the tag + # cannot be created. Needs the "Main tags" ruleset to let GITHUB_TOKEN create v* tags. A tag pushed + # with GITHUB_TOKEN starts no workflow. - name: Tag the release commit - if: steps.extract.outputs.isDryRun == 'false' + if: github.ref_type == 'branch' && steps.extract.outputs.isDryRun == 'false' env: TAG: v${{ steps.extract.outputs.fullTag }} run: | diff --git a/scripts/release.ts b/scripts/release.ts index fa487131..07b1cdd0 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -15,9 +15,10 @@ import { compareVersions, registryIntegrity } from "./npm-publish" // bun run release [--pr | --dry-run] [--no-watch] // // 1. Checks that the branch has no uncommitted changes and matches its origin branch. -// 2. Runs prepare-release, commits "Release vX.Y.Z", and pushes the commit to the branch. That needs -// the right to bypass the branch rules. A maintenance branch takes only versions of its line. -// 3. Follows the release.yml run of the commit. Its publish job ends when npm serves every package, +// 2. Runs prepare-release, commits "Release vX.Y.Z", tags it vX.Y.Z, and pushes the commit to the +// branch together with the tag. That needs the right to bypass the branch and tag rules. A +// maintenance branch takes only versions of its line. +// 3. Follows the release.yml run of the tag push. Its publish job ends when npm serves every package, // and this script reports that time. // // --pr pushes the commit to a release/vX.Y.Z branch and opens a pull request instead. Merging it @@ -40,7 +41,8 @@ interface Release { // The branch that releases it: main or a maintenance branch. branch: string sha: string - // The ref that was pushed, without refs/heads/ or refs/tags/. + // The tag whose push starts the release run, or the pull request branch. Without refs/tags/ or + // refs/heads/. ref: string pushedAt: number } @@ -215,12 +217,14 @@ function remoteTagCommit(tag: string): string | undefined { return peeled?.split(/\s+/)[0] || undefined } -function push(ref: string, sha: string): void { +// Pushes the refs together. The last one is the ref whose push starts the release run. +function push(refspecs: readonly string[], sha: string): void { try { - run("git", ["push", "origin", ref], { inherit: true }) + run("git", ["push", "--atomic", "origin", ...refspecs], { inherit: true }) } catch (error) { // The push can reach origin and still fail here, for example when the connection drops. - const target = ref.slice(ref.indexOf(":") + 1) + const last = refspecs.at(-1)! + const target = last.slice(last.indexOf(":") + 1) const landed = target.startsWith("refs/tags/") ? remoteTagCommit(target.slice("refs/tags/".length)) : remoteSha(target) @@ -283,16 +287,23 @@ async function pushRelease(options: Options, branch: string, base: string): Prom console.log(`Prepared ${previous} -> ${version}. Committing...`) run("git", ["commit", "--quiet", "--all", "--message", `Release v${version}`], { inherit: true }) - if (options.mode === "dry-run") { - const name = nextDryRunTag(version) + if (options.mode !== "pr") { + const name = options.mode === "dry-run" ? nextDryRunTag(version) : `v${version}` run("git", ["tag", "--annotate", name, "--message", `Release ${name}`], { inherit: true }) tag = name } const sha = git("rev-parse", "HEAD") stopIfInterrupted() - const ref = tag ?? prBranch ?? branch - push(tag ? `refs/tags/${tag}` : `HEAD:refs/heads/${ref}`, sha) + // A release pushes the commit with its tag. The run of the tag push releases it, with the tag as + // its ref, which is the ref that the Windows signing in Azure trusts. + const ref = tag ?? prBranch! + const refspecs = { + push: [`HEAD:refs/heads/${branch}`, `refs/tags/${ref}`], + "dry-run": [`refs/tags/${ref}`], + pr: [`HEAD:refs/heads/${ref}`], + }[options.mode] + push(refspecs, sha) const release = { version, branch, sha, ref, pushedAt: Date.now() } if (options.mode !== "push") git("switch", "--quiet", branch) if (prBranch) git("branch", "--quiet", "--delete", "--force", prBranch) @@ -382,14 +393,15 @@ async function main(): Promise { console.log(`Releasing ${repo} ${branch} at ${short(base)}: ${options.target}, ${options.mode}`) const release = await pushRelease(options, branch, base) - console.log(`Pushed Release v${release.version} (${short(release.sha)}) to ${release.ref}`) + const pushedTo = options.mode === "push" ? `${release.branch} and ${release.ref}` : release.ref + console.log(`Pushed Release v${release.version} (${short(release.sha)}) to ${pushedTo}`) if (options.mode === "pr") { const url = openPullRequest(repo, release) report([`Opened ${url}`, `- Merge it to release v${release.version}.`]) return } - const tag = options.mode === "dry-run" ? release.ref : `v${release.version}` + const tag = release.ref const releaseRun = await findReleaseRun(repo, release) console.log(`Release run: ${releaseRun.html_url}`) if (!options.watch) {