From 76cb5e5561b1533b35929d58339f65f70981d54f Mon Sep 17 00:00:00 2001 From: Cory Johns Date: Mon, 19 Oct 2026 17:27:13 +0000 Subject: [PATCH 1/9] chore: share the release workflow and cut releases here (ENG-9233) [ENG-9233](https://stacklet.atlassian.net/browse/ENG-9233) What ---- - Adds `cut-release.yml`, a `workflow_call` workflow that works out the next version from the tag history and publishes a GitHub release for it. It is the release workflow from `terraform-azure-stacklet-cost-setup`, lifted so the other five module repositories call it instead of copying it. - Adds `release.yml`, the dispatch caller, so this repository cuts its own releases down the same path the module repositories will. - Documents `cut-release` in the README beside `run-script`, with the caller block to copy. Why ---- Five Terraform module repositories have no release workflow, and the sixth already had this one. Copying it five more times leaves six sets of the same guards to keep in step. The guards are the part worth sharing, because a published release is immutable and a slip in one cannot be taken back. The lift departs from the original in three places. 1. The `bump` input is a string rather than a choice, because `workflow_call` has no choice type. A new step rejects anything but patch, minor, or major. Without it the tag action reads an unrecognised `force_bump` as no bump, falls back to the commit messages, and publishes a version nobody asked for. 2. The branch guard compares against the calling repository's default branch rather than the literal `main`, so it holds for a caller whose default branch has another name. 3. The version job gains the Harden Runner step `run-script` already uses. That job fetches and runs a third-party action, while the publish job runs nothing but `gh`. The caller keeps the `workflow_dispatch` trigger and the `concurrency` group. Both matter only on the workflow a person dispatches, and a called workflow cannot serialise its caller. Testing ---- - [x] `just lint` passes. - [x] `uvx zizmor@1.23.1 -v --gh-token=... .github/workflows/` reports no findings, with the five online audits scheduled rather than skipped. - [x] Each action pin resolves to a commit via `gh api repos///commits/`. - [ ] After merge, dispatch Release on main with dry run checked, and confirm the summary reports v0.0.0 as previous and v1.0.0 as new. - [ ] Dispatch Release with a major bump and confirm v1.0.0 is published against the dispatched commit. Docs ---- The README gains a `cut-release` section under Reusable workflows. The pinning note at the top no longer says this repository publishes no release tags, because after this it does. Co-Authored-By: Claude Opus 5 --- .github/workflows/cut-release.yml | 205 ++++++++++++++++++++++++++++++ .github/workflows/release.yml | 44 +++++++ README.md | 63 ++++++++- 3 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/cut-release.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml new file mode 100644 index 0000000..34b7a1f --- /dev/null +++ b/.github/workflows/cut-release.yml @@ -0,0 +1,205 @@ +# Cuts a tag and a GitHub release for the repository that calls this workflow. +# The caller owns the `workflow_dispatch` trigger and the `concurrency` group, +# which only mean anything on the workflow a person dispatches. The README +# carries the caller block to copy. +name: "Cut release" + +on: + workflow_call: + inputs: + bump: + # `workflow_call` has no choice type, so only the caller's choice list + # narrows this. The version job checks the value before acting on it. + description: "Which part of the version to raise. One of patch, minor, or major." + required: true + type: string + dry_run: + description: "Work out the next version and stop without publishing" + required: false + type: boolean + default: false + secrets: + STEP_SECURITY_API_KEY: + required: false + +permissions: {} + +jobs: + # Split from `publish` so the third-party action never runs under a token + # that can write refs. This job reads; only the job below can publish. + version: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + new_tag: ${{ steps.version.outputs.new_tag }} + changelog: ${{ steps.version.outputs.changelog }} + env: + HARDEN_RUNNER: ${{ secrets.STEP_SECURITY_API_KEY != '' }} # zizmor: ignore[secrets-outside-env] + steps: + # First, ahead of the guards below, because egress policy only covers + # what runs after it. This job fetches and runs a third-party action. + # The publish job holds the write token but runs nothing but `gh`. + - name: Harden Runner + if: env.HARDEN_RUNNER == 'true' + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + use-policy-store: true + api-key: ${{ secrets.STEP_SECURITY_API_KEY }} # zizmor: ignore[secrets-outside-env] + + # The tag action reads an unrecognised `force_bump` as no bump at all and + # falls back to the commit messages, so without this a typo publishes a + # version nobody asked for. + - name: Check the requested bump + if: ${{ !contains(fromJSON('["patch", "minor", "major"]'), inputs.bump) }} + env: + BUMP: ${{ inputs.bump }} + run: | + echo "The bump has to be patch, minor, or major. This run asked for" >&2 + echo "'${BUMP}'." >&2 + exit 1 + + # workflow_dispatch takes a ref, so the caller can start against any + # branch. A release cut from a topic branch names code that never merged, + # and immutability makes that permanent. The branch comes from the + # calling repository, so this holds for a caller whose default branch has + # another name. + - name: Refuse to release from anywhere but the default branch + if: ${{ github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }} + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + echo "Releases are cut from ${DEFAULT_BRANCH}. This run targeted ${GITHUB_REF}." >&2 + exit 1 + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The next version comes from the tag history, and the action walks + # the local checkout when the compare API is unavailable. + fetch-depth: 0 + persist-credentials: false + + # Works out the version and stops. `push: false` leaves every ref write to + # the publish job, so one step owns the tag. Splitting them lets a failure + # halfway through leave a bare tag with no release, which is mutable and + # in the way of the retry. + - name: Work out the next version + id: version + uses: mathieudutour/github-tag-action@af99e60ce8132224b8e6ebab5023449fe256ed46 # v7 + with: + github_token: ${{ github.token }} + force_bump: ${{ inputs.bump }} + push: false + + - name: Report the version + env: + PREVIOUS_TAG: ${{ steps.version.outputs.previous_tag }} + NEW_TAG: ${{ steps.version.outputs.new_tag }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + { + echo "| | |" + echo "|---|---|" + echo "| Previous | ${PREVIOUS_TAG} |" + echo "| New | ${NEW_TAG} |" + echo "| Dry run | ${DRY_RUN} |" + } >> "${GITHUB_STEP_SUMMARY}" + + # The tag action reports v0.0.0 as the previous tag when no tag exists, so + # this fires only on the first release. A patch bump from that baseline + # lands v0.0.1 permanently, so the input description is not enough. + - name: Require the first release to be major + if: ${{ steps.version.outputs.previous_tag == 'v0.0.0' && inputs.bump != 'major' }} + env: + BUMP: ${{ inputs.bump }} + run: | + echo "This repository has no releases yet, so the first one has to be a" >&2 + echo "major bump to land v1.0.0. This run asked for ${BUMP}." >&2 + exit 1 + + publish: + needs: version + # `success()` is implicit on a job `if:` that names no status function, and + # it keeps the version job's three guards binding. Spelling it out matters + # because adding `always()` or a `failure()` branch later detaches them + # silently, and that fails open: `new_tag` is already set by the time the + # first-release guard runs. + # + # `inputs.dry_run`, not `github.event.inputs.dry_run`. The former keeps a + # boolean a boolean. The latter yields the string "false", which is truthy, + # so the negation is always false and this job never runs. + if: ${{ success() && !inputs.dry_run }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + # Re-running stays on offer for 30 days, and the two modes go wrong in + # opposite directions. "Re-run all jobs" re-runs the version job, and + # because `force_bump` overrides the no-new-commits early return it works + # out the version after this one and publishes that too. "Re-run failed + # jobs" replays the succeeded version job's outputs and republishes the + # same tag. run_attempt catches both. Retry by dispatching again, which + # reads the same latest tag and so arrives at the same number. + - name: Refuse a re-run + if: ${{ github.run_attempt != 1 }} + run: | + echo "Attempt ${GITHUB_RUN_ATTEMPT} of this run. Re-running would publish the" >&2 + echo "version after the one this run was for. Dispatch the workflow again." >&2 + exit 1 + + # No checkout: the release is cut against a commit SHA and carries no + # assets. That also keeps this job, the one holding `contents: write`, + # free of third-party actions, which is why it has no Harden Runner step. + # + # The checkout was doing a second job though, which is why --repo appears + # below. `gh` resolves the repository from --repo, then GH_REPO, then the + # git remotes of the working directory. It never reads GITHUB_REPOSITORY, + # so without one of those it exits 1 with "not a git repository". `gh api` + # is unaffected because its path is literal. + # + # Publishing the release locks the tag, so the tag is created here rather + # than by the version job. + # + # zizmor rejects a release action here as work the runner already does, + # which is why this is the CLI rather than ncipollo/release-action. + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.version.outputs.new_tag }} + COMMIT: ${{ github.sha }} + NOTES: ${{ needs.version.outputs.changelog }} + run: | + set -euo pipefail + if [ -z "${TAG}" ]; then + echo "The version job worked out no tag, so there is nothing to publish." >&2 + echo "That should not happen on the default branch with an explicit bump." >&2 + exit 1 + fi + # The version job derives the number by incrementing the highest tag, + # so the name was free when it ran. A tag pushed from anywhere else + # since then still collides, and `gh release create` answers a + # collision by releasing the existing tag and ignoring --target, which + # publishes an immutable release for a commit nobody chose. This + # narrows the window rather than closing it. + # + # `git/matching-refs` answers "no such tag" with 200 and an empty + # array, so a non-zero exit means the check itself failed rather than + # the name being free. `git/ref/tags/...` answers 404, which shares an + # exit status with a 401, a 5xx, or a dropped connection, and would + # leave the guard passing whenever the API misbehaved. Keeping the + # call out of an `if` matters for the same reason: errexit is + # suppressed for a condition. matching-refs is a prefix match, hence + # the exact-ref filter. + matches=$(gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${TAG}" \ + --jq '[.[] | select(.ref == "refs/tags/" + env.TAG)] | length') + if [ "${matches}" != "0" ]; then + echo "Tag ${TAG} already exists, so this run would publish a release over" >&2 + echo "a tag it did not create. Refusing." >&2 + exit 1 + fi + gh release create "${TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --target "${COMMIT}" \ + --title "${TAG}" \ + --notes "${NOTES}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..17e69c0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +on: + workflow_dispatch: + inputs: + bump: + description: Which part of the version to raise. The first release has to be major, because the count starts from 0.0.0. + required: true + type: choice + default: patch + options: + - patch + - minor + - major + dry_run: + description: Work out the next version and stop without publishing + required: false + type: boolean + default: false + +# Two runs would otherwise read the same latest tag and claim the same version. +# The second fails at the release step rather than corrupting anything, but +# serialising stops it happening at all. Only one run queues behind the active +# one, so a third dispatch drops the second. +# +# This belongs to the dispatched workflow rather than the called one, so every +# caller carries it. Copy this file rather than writing one. +concurrency: release + +permissions: {} + +jobs: + release: + uses: ./.github/workflows/cut-release.yml + # The ceiling for the called workflow, not what each job gets. It narrows + # this to read for the job running the third-party version action, and only + # its publish job keeps write. + permissions: + contents: write + secrets: + STEP_SECURITY_API_KEY: ${{ secrets.STEP_SECURITY_API_KEY }} + with: + bump: ${{ inputs.bump }} + dry_run: ${{ inputs.dry_run }} diff --git a/README.md b/README.md index e241929..62b6373 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,15 @@ Reusable GitHub Actions and workflows shared across Stacklet repositories. -Each tool lives in its own top-level directory with an `action.yml` and a `README.md`. Reference one by path: +Each tool lives in its own top-level directory with an `action.yml` and a `README.md`. +Reference one by path: ```yaml - uses: stacklet/github-tooling/@ ``` -Pin by commit SHA. This repository publishes no release tags, so a SHA is the only reference that cannot move. +Pin by commit SHA. This repository cuts releases, but it cuts them by hand and most +commits never get one, so a SHA is what names the commit you meant. ## Tools @@ -20,7 +22,9 @@ Pin by commit SHA. This repository publishes no release tags, so a SHA is the on 1. Create a directory named for the tool. 2. Add an `action.yml` and a `README.md` covering usage, inputs, and outputs. -3. Keep any non-trivial logic in its own script beside the `action.yml`, so that a linter reads it and a test can run it without a runner. Prefer Python over shell for anything that parses or compares. +3. Keep any non-trivial logic in its own script beside the `action.yml`, so that a + linter reads it and a test can run it without a runner. Prefer Python over shell for + anything that parses or compares. 4. Add a row to the table above. ## Reusable workflows @@ -43,3 +47,56 @@ Run a set of commands on the repository, with the required tools installed via ` run: | just lint ``` + +### `cut-release` + +Works out the next version from the tag history and publishes a GitHub release for it, +on a manual dispatch. The caller owns the `workflow_dispatch` trigger, because that is +the workflow a person picks in the Actions tab, and the `concurrency` group, because a +called workflow cannot serialise its caller. Copy this block whole rather than writing +one: + +```yaml +name: Release + +on: + workflow_dispatch: + inputs: + bump: + description: Which part of the version to raise. The first release has to be major, because the count starts from 0.0.0. + required: true + type: choice + default: patch + options: + - patch + - minor + - major + dry_run: + description: Work out the next version and stop without publishing + required: false + type: boolean + default: false + +concurrency: release + +permissions: {} + +jobs: + release: + uses: stacklet/github-tooling/.github/workflows/cut-release.yml@ + permissions: + contents: write + secrets: + STEP_SECURITY_API_KEY: ${{ secrets.STEP_SECURITY_API_KEY }} + with: + bump: ${{ inputs.bump }} + dry_run: ${{ inputs.dry_run }} +``` + +The workflow refuses to run against anything but the calling repository's default +branch, refuses a re-run of a run that already published, and refuses to publish over a +tag it did not create. The first release has to be a major bump, because the version +walk starts from 0.0.0 and a patch from there spends `v0.0.1` permanently. + +`STEP_SECURITY_API_KEY` is optional. Without it the Harden Runner step is skipped and +the rest runs unchanged. From d1fae41175379631c78288bd91299882c65bda5a Mon Sep 17 00:00:00 2001 From: Cory Johns Date: Mon, 19 Oct 2026 19:18:15 +0000 Subject: [PATCH 2/9] address cubic review: claim the tag before the release, widen release_branches, fix the re-run wording Co-Authored-By: Claude Opus 5 --- .github/workflows/cut-release.yml | 42 ++++++++++++++++++++++++------- README.md | 4 +-- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 34b7a1f..0d0c3c7 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -91,6 +91,12 @@ jobs: github_token: ${{ github.token }} force_bump: ${{ inputs.bump }} push: false + # `release_branches` defaults to `^master$,^main$`, and a branch + # outside that list gets a version postfixed with the commit hash + # and no tag at all. The guard above already confines this job to + # the caller's default branch, so widening it here is what makes + # that guard's generality real. + release_branches: '.*' - name: Report the version env: @@ -158,9 +164,6 @@ jobs: # so without one of those it exits 1 with "not a git repository". `gh api` # is unaffected because its path is literal. # - # Publishing the release locks the tag, so the tag is created here rather - # than by the version job. - # # zizmor rejects a release action here as work the runner already does, # which is why this is the CLI rather than ncipollo/release-action. - name: Publish the release @@ -176,12 +179,9 @@ jobs: echo "That should not happen on the default branch with an explicit bump." >&2 exit 1 fi - # The version job derives the number by incrementing the highest tag, - # so the name was free when it ran. A tag pushed from anywhere else - # since then still collides, and `gh release create` answers a - # collision by releasing the existing tag and ignoring --target, which - # publishes an immutable release for a commit nobody chose. This - # narrows the window rather than closing it. + # This check is for the clean error, not for correctness. The ref + # write below is what actually claims the name. Without the check a + # collision surfaces as a raw 422 from the API. # # `git/matching-refs` answers "no such tag" with 200 and an empty # array, so a non-zero exit means the check itself failed rather than @@ -198,8 +198,32 @@ jobs: echo "a tag it did not create. Refusing." >&2 exit 1 fi + # Claiming the name and publishing the release are two calls, so the + # order decides which way the gap between them fails. Creating the + # tag first fails closed: `POST git/refs` answers an existing ref with + # 422, so a tag that lands after the check above costs this run and + # nothing else. + # + # Letting `gh release create` make the tag instead fails open. It + # answers a name that is already taken by releasing the tag that is + # there and ignoring --target, which publishes an immutable release + # against a commit nobody chose. Immutability makes the name + # unusable afterwards even if the release is deleted. + gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f "ref=refs/tags/${TAG}" \ + -f "sha=${COMMIT}" > /dev/null + # The tag now exists with no release locking it. Anything that stops + # this step from here has to take it back out: the version job reads + # the highest tag, so a tag left behind makes the next dispatch work + # out the version after this one and skip this number silently. + trap 'gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG}" > /dev/null 2>&1 || true' EXIT + # --target is redundant now that the tag exists at the right commit, + # and `gh` ignores it for an existing tag. It stays as the statement + # of which commit this release is for. gh release create "${TAG}" \ --repo "${GITHUB_REPOSITORY}" \ --target "${COMMIT}" \ --title "${TAG}" \ --notes "${NOTES}" + # Publishing is what locks the tag, so past this point it must stay. + trap - EXIT diff --git a/README.md b/README.md index 62b6373..cd721d7 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,8 @@ jobs: ``` The workflow refuses to run against anything but the calling repository's default -branch, refuses a re-run of a run that already published, and refuses to publish over a -tag it did not create. The first release has to be a major bump, because the version +branch, refuses any re-run of a run it has already started, and refuses to publish over +a tag it did not create. A retry means dispatching again. The first release has to be a major bump, because the version walk starts from 0.0.0 and a patch from there spends `v0.0.1` permanently. `STEP_SECURITY_API_KEY` is optional. Without it the Harden Runner step is skipped and From 4cf3bea0eeda3a632fd3b5ec2c7149805bd752bd Mon Sep 17 00:00:00 2001 From: Cory Johns Date: Mon, 19 Oct 2026 19:24:52 +0000 Subject: [PATCH 3/9] arm the tag cleanup before the ref write, and verify ownership before deleting Co-Authored-By: Claude Opus 5 --- .github/workflows/cut-release.yml | 34 ++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 0d0c3c7..26574fc 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -209,14 +209,38 @@ jobs: # there and ignoring --target, which publishes an immutable release # against a commit nobody chose. Immutability makes the name # unusable afterwards even if the release is deleted. + # Between the ref write and the release there is a tag with nothing + # locking it, and the version job reads the highest tag, so a tag + # left behind makes the next dispatch work out the version after + # this one and skip this number silently. + # + # The cleanup is armed before the write rather than after it. Arming + # it after leaves a window, short but real, where the tag exists and + # nothing would take it back out. That ordering is only safe because + # the cleanup checks that the tag is this run's before removing it, + # so the 422 path below, where the tag belongs to someone else, does + # not delete their tag. Every check that fails to confirm leaves the + # tag alone, including an API error, since a tag left behind costs a + # version number and a wrong delete costs a release. + # + # bash runs an EXIT trap when a signal terminates it, so this covers + # a cancelled job as well as a failed command. SIGKILL defeats it, + # and nothing here can change that. + cleanup() { + local ref_sha + ref_sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha 2>/dev/null) || return 0 + if [ "${ref_sha}" != "${COMMIT}" ]; then + return 0 + fi + if gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" > /dev/null 2>&1; then + return 0 + fi + gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG}" > /dev/null 2>&1 || true + } + trap cleanup EXIT gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ -f "ref=refs/tags/${TAG}" \ -f "sha=${COMMIT}" > /dev/null - # The tag now exists with no release locking it. Anything that stops - # this step from here has to take it back out: the version job reads - # the highest tag, so a tag left behind makes the next dispatch work - # out the version after this one and skip this number silently. - trap 'gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG}" > /dev/null 2>&1 || true' EXIT # --target is redundant now that the tag exists at the right commit, # and `gh` ignores it for an existing tag. It stays as the statement # of which commit this release is for. From dfd17e3174284e72384c77009554ef12238eb14d Mon Sep 17 00:00:00 2001 From: Cory Johns Date: Mon, 19 Oct 2026 19:33:25 +0000 Subject: [PATCH 4/9] summarise the workflow comments, dropping API and error-code detail Co-Authored-By: Claude Opus 5 --- .github/workflows/cut-release.yml | 139 +++++++++++------------------- .github/workflows/release.yml | 10 +-- 2 files changed, 53 insertions(+), 96 deletions(-) diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 26574fc..bd37489 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -37,9 +37,8 @@ jobs: env: HARDEN_RUNNER: ${{ secrets.STEP_SECURITY_API_KEY != '' }} # zizmor: ignore[secrets-outside-env] steps: - # First, ahead of the guards below, because egress policy only covers - # what runs after it. This job fetches and runs a third-party action. - # The publish job holds the write token but runs nothing but `gh`. + # First, because egress policy only covers what runs after it. This is + # the job that runs a third-party action. The publish job runs only `gh`. - name: Harden Runner if: env.HARDEN_RUNNER == 'true' uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 @@ -47,9 +46,8 @@ jobs: use-policy-store: true api-key: ${{ secrets.STEP_SECURITY_API_KEY }} # zizmor: ignore[secrets-outside-env] - # The tag action reads an unrecognised `force_bump` as no bump at all and - # falls back to the commit messages, so without this a typo publishes a - # version nobody asked for. + # An unrecognised bump does not fail the tag action. It falls back to + # reading the commit messages and publishes a version nobody asked for. - name: Check the requested bump if: ${{ !contains(fromJSON('["patch", "minor", "major"]'), inputs.bump) }} env: @@ -59,11 +57,10 @@ jobs: echo "'${BUMP}'." >&2 exit 1 - # workflow_dispatch takes a ref, so the caller can start against any - # branch. A release cut from a topic branch names code that never merged, - # and immutability makes that permanent. The branch comes from the - # calling repository, so this holds for a caller whose default branch has - # another name. + # A dispatch can target any branch, and a release cut from a topic branch + # names code that never merged. Immutability makes that permanent. + # Reading the branch from the calling repository keeps this right for a + # caller that does not use `main`. - name: Refuse to release from anywhere but the default branch if: ${{ github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }} env: @@ -75,15 +72,12 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # The next version comes from the tag history, and the action walks - # the local checkout when the compare API is unavailable. + # The version comes from the tag history, so all of it has to be here. fetch-depth: 0 persist-credentials: false - # Works out the version and stops. `push: false` leaves every ref write to - # the publish job, so one step owns the tag. Splitting them lets a failure - # halfway through leave a bare tag with no release, which is mutable and - # in the way of the retry. + # Works out the version and stops. Every ref write belongs to the publish + # job, so one place owns the tag. - name: Work out the next version id: version uses: mathieudutour/github-tag-action@af99e60ce8132224b8e6ebab5023449fe256ed46 # v7 @@ -91,11 +85,10 @@ jobs: github_token: ${{ github.token }} force_bump: ${{ inputs.bump }} push: false - # `release_branches` defaults to `^master$,^main$`, and a branch - # outside that list gets a version postfixed with the commit hash - # and no tag at all. The guard above already confines this job to - # the caller's default branch, so widening it here is what makes - # that guard's generality real. + # The action tags only its own idea of a release branch, which does + # not cover every default branch. The guard above already confines + # this to the caller's default branch, so widening it here is what + # makes that guard general. release_branches: '.*' - name: Report the version @@ -112,9 +105,9 @@ jobs: echo "| Dry run | ${DRY_RUN} |" } >> "${GITHUB_STEP_SUMMARY}" - # The tag action reports v0.0.0 as the previous tag when no tag exists, so - # this fires only on the first release. A patch bump from that baseline - # lands v0.0.1 permanently, so the input description is not enough. + # Only fires on the first release, where the baseline is 0.0.0. A patch + # from there lands v0.0.1 permanently, so this is enforced rather than + # left to the input description. - name: Require the first release to be major if: ${{ steps.version.outputs.previous_tag == 'v0.0.0' && inputs.bump != 'major' }} env: @@ -126,27 +119,20 @@ jobs: publish: needs: version - # `success()` is implicit on a job `if:` that names no status function, and - # it keeps the version job's three guards binding. Spelling it out matters - # because adding `always()` or a `failure()` branch later detaches them - # silently, and that fails open: `new_tag` is already set by the time the - # first-release guard runs. + # `success()` is implicit here, and it is what keeps the version job's + # guards binding. Spelled out because adding `always()` or a `failure()` + # branch later would detach them silently, and that fails open. # - # `inputs.dry_run`, not `github.event.inputs.dry_run`. The former keeps a - # boolean a boolean. The latter yields the string "false", which is truthy, - # so the negation is always false and this job never runs. + # `inputs.dry_run`, not `github.event.inputs.dry_run`. The latter is a + # string, so the negation would never hold and this job would never run. if: ${{ success() && !inputs.dry_run }} runs-on: ubuntu-latest permissions: contents: write steps: - # Re-running stays on offer for 30 days, and the two modes go wrong in - # opposite directions. "Re-run all jobs" re-runs the version job, and - # because `force_bump` overrides the no-new-commits early return it works - # out the version after this one and publishes that too. "Re-run failed - # jobs" replays the succeeded version job's outputs and republishes the - # same tag. run_attempt catches both. Retry by dispatching again, which - # reads the same latest tag and so arrives at the same number. + # Both re-run modes go wrong. One recomputes the version and publishes + # the one after this. The other replays the old outputs and republishes + # the same tag. Dispatch again instead, which arrives at the same number. - name: Refuse a re-run if: ${{ github.run_attempt != 1 }} run: | @@ -155,17 +141,12 @@ jobs: exit 1 # No checkout: the release is cut against a commit SHA and carries no - # assets. That also keeps this job, the one holding `contents: write`, - # free of third-party actions, which is why it has no Harden Runner step. - # - # The checkout was doing a second job though, which is why --repo appears - # below. `gh` resolves the repository from --repo, then GH_REPO, then the - # git remotes of the working directory. It never reads GITHUB_REPOSITORY, - # so without one of those it exits 1 with "not a git repository". `gh api` - # is unaffected because its path is literal. + # assets. That keeps the job holding `contents: write` free of + # third-party actions, which is why it has no Harden Runner step. Without + # a checkout `gh` cannot infer the repository, hence `--repo` below. # # zizmor rejects a release action here as work the runner already does, - # which is why this is the CLI rather than ncipollo/release-action. + # hence the CLI. - name: Publish the release env: GH_TOKEN: ${{ github.token }} @@ -179,18 +160,10 @@ jobs: echo "That should not happen on the default branch with an explicit bump." >&2 exit 1 fi - # This check is for the clean error, not for correctness. The ref - # write below is what actually claims the name. Without the check a - # collision surfaces as a raw 422 from the API. - # - # `git/matching-refs` answers "no such tag" with 200 and an empty - # array, so a non-zero exit means the check itself failed rather than - # the name being free. `git/ref/tags/...` answers 404, which shares an - # exit status with a 401, a 5xx, or a dropped connection, and would - # leave the guard passing whenever the API misbehaved. Keeping the - # call out of an `if` matters for the same reason: errexit is - # suppressed for a condition. matching-refs is a prefix match, hence - # the exact-ref filter. + # A courtesy check, not the guard. It turns a name that is already + # taken into a readable error rather than a raw API failure. Claiming + # the ref below is what actually settles the name. The call stays out + # of an `if` so that a failure of the check itself stops the run. matches=$(gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${TAG}" \ --jq '[.[] | select(.ref == "refs/tags/" + env.TAG)] | length') if [ "${matches}" != "0" ]; then @@ -198,34 +171,21 @@ jobs: echo "a tag it did not create. Refusing." >&2 exit 1 fi - # Claiming the name and publishing the release are two calls, so the - # order decides which way the gap between them fails. Creating the - # tag first fails closed: `POST git/refs` answers an existing ref with - # 422, so a tag that lands after the check above costs this run and - # nothing else. - # - # Letting `gh release create` make the tag instead fails open. It - # answers a name that is already taken by releasing the tag that is - # there and ignoring --target, which publishes an immutable release - # against a commit nobody chose. Immutability makes the name - # unusable afterwards even if the release is deleted. - # Between the ref write and the release there is a tag with nothing - # locking it, and the version job reads the highest tag, so a tag - # left behind makes the next dispatch work out the version after - # this one and skip this number silently. + # Claim the tag ref, then lock it in by publishing the release + # against it. The other order fails open: handed a name already + # taken, the release call publishes against whatever commit that tag + # points at, and immutability makes that permanent. # - # The cleanup is armed before the write rather than after it. Arming - # it after leaves a window, short but real, where the tag exists and - # nothing would take it back out. That ordering is only safe because - # the cleanup checks that the tag is this run's before removing it, - # so the 422 path below, where the tag belongs to someone else, does - # not delete their tag. Every check that fails to confirm leaves the - # tag alone, including an API error, since a tag left behind costs a - # version number and a wrong delete costs a release. + # Claiming first leaves the tag briefly unlocked, and a tag left + # behind makes the next dispatch skip this version number. So the + # cleanup is armed before the claim, leaving no window where the tag + # is unowned, and it is conditional on the tag being this run's so + # that it never deletes one the claim lost to. Anything it cannot + # confirm it leaves alone: a stray tag costs a number, a wrong delete + # costs a release. # - # bash runs an EXIT trap when a signal terminates it, so this covers - # a cancelled job as well as a failed command. SIGKILL defeats it, - # and nothing here can change that. + # An EXIT trap covers a cancelled job as well as a failed command, + # though not a SIGKILL. cleanup() { local ref_sha ref_sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha 2>/dev/null) || return 0 @@ -241,9 +201,8 @@ jobs: gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ -f "ref=refs/tags/${TAG}" \ -f "sha=${COMMIT}" > /dev/null - # --target is redundant now that the tag exists at the right commit, - # and `gh` ignores it for an existing tag. It stays as the statement - # of which commit this release is for. + # Redundant once the tag exists, but it states which commit this + # release is for. gh release create "${TAG}" \ --repo "${GITHUB_REPOSITORY}" \ --target "${COMMIT}" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17e69c0..2d3f099 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,10 +18,8 @@ on: type: boolean default: false -# Two runs would otherwise read the same latest tag and claim the same version. -# The second fails at the release step rather than corrupting anything, but -# serialising stops it happening at all. Only one run queues behind the active -# one, so a third dispatch drops the second. +# Two runs would otherwise claim the same version. The second fails safely at +# the release step, but serialising stops it happening at all. # # This belongs to the dispatched workflow rather than the called one, so every # caller carries it. Copy this file rather than writing one. @@ -33,8 +31,8 @@ jobs: release: uses: ./.github/workflows/cut-release.yml # The ceiling for the called workflow, not what each job gets. It narrows - # this to read for the job running the third-party version action, and only - # its publish job keeps write. + # this to read for the job running the third-party action, and only its + # publish job keeps write. permissions: contents: write secrets: From 7b8142b5cf6491e33713841beec58dd74139635c Mon Sep 17 00:00:00 2001 From: Cory Johns Date: Mon, 19 Oct 2026 19:36:52 +0000 Subject: [PATCH 5/9] drop the stale --target comment Co-Authored-By: Claude Opus 5 --- .github/workflows/cut-release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index bd37489..08fe299 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -201,8 +201,6 @@ jobs: gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ -f "ref=refs/tags/${TAG}" \ -f "sha=${COMMIT}" > /dev/null - # Redundant once the tag exists, but it states which commit this - # release is for. gh release create "${TAG}" \ --repo "${GITHUB_REPOSITORY}" \ --target "${COMMIT}" \ From bc459ca3392db43093f2c37a66884c668feccc7c Mon Sep 17 00:00:00 2001 From: Cory Johns Date: Mon, 19 Oct 2026 19:51:54 +0000 Subject: [PATCH 6/9] tell a confirmed absence apart from a failed release lookup, and check the bump case-sensitively Co-Authored-By: Claude Opus 5 --- .github/workflows/cut-release.yml | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 08fe299..17dbfe3 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -46,16 +46,22 @@ jobs: use-policy-store: true api-key: ${{ secrets.STEP_SECURITY_API_KEY }} # zizmor: ignore[secrets-outside-env] - # An unrecognised bump does not fail the tag action. It falls back to - # reading the commit messages and publishes a version nobody asked for. + # The tag action throws on a bump it does not recognise, which is a + # confusing place to find out. Checked in the shell rather than an `if:` + # because workflow expressions compare case-insensitively, so `Major` + # would clear one and then fail inside the action. - name: Check the requested bump - if: ${{ !contains(fromJSON('["patch", "minor", "major"]'), inputs.bump) }} env: BUMP: ${{ inputs.bump }} run: | - echo "The bump has to be patch, minor, or major. This run asked for" >&2 - echo "'${BUMP}'." >&2 - exit 1 + case "${BUMP}" in + patch | minor | major) ;; + *) + echo "The bump has to be patch, minor, or major. This run asked for" >&2 + echo "'${BUMP}'." >&2 + exit 1 + ;; + esac # A dispatch can target any branch, and a release cut from a topic branch # names code that never merged. Immutability makes that permanent. @@ -187,12 +193,19 @@ jobs: # An EXIT trap covers a cancelled job as well as a failed command, # though not a SIGKILL. cleanup() { - local ref_sha + local ref_sha releases ref_sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha 2>/dev/null) || return 0 if [ "${ref_sha}" != "${COMMIT}" ]; then return 0 fi - if gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" > /dev/null 2>&1; then + # Listing answers "no release" with an empty result and a success, + # which is what tells a confirmed absence apart from a lookup that + # failed. Asking for the release by tag reports both the same way, + # and deleting on an unconfirmed absence would strip the tag off a + # release that did publish. + releases=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/releases" \ + --jq '.[] | select(.tag_name == env.TAG) | .id') || return 0 + if [ -n "${releases}" ]; then return 0 fi gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG}" > /dev/null 2>&1 || true From 631e97b637f87d7acbd72ebdf2225d486df02c6d Mon Sep 17 00:00:00 2001 From: Cory Johns Date: Mon, 19 Oct 2026 20:07:33 +0000 Subject: [PATCH 7/9] record the tag claim rather than inferring it, and read previous_version Co-Authored-By: Claude Opus 5 --- .github/workflows/cut-release.yml | 46 ++++++++++++++++++------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 17dbfe3..2ff3aa1 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -113,9 +113,11 @@ jobs: # Only fires on the first release, where the baseline is 0.0.0. A patch # from there lands v0.0.1 permanently, so this is enforced rather than - # left to the input description. + # left to the input description. Reads `previous_version` rather than + # `previous_tag`, which carries the action's tag prefix and would make + # this a second copy of a default owned elsewhere. - name: Require the first release to be major - if: ${{ steps.version.outputs.previous_tag == 'v0.0.0' && inputs.bump != 'major' }} + if: ${{ steps.version.outputs.previous_version == '0.0.0' && inputs.bump != 'major' }} env: BUMP: ${{ inputs.bump }} run: | @@ -182,28 +184,33 @@ jobs: # taken, the release call publishes against whatever commit that tag # points at, and immutability makes that permanent. # - # Claiming first leaves the tag briefly unlocked, and a tag left + # Claiming first leaves the tag briefly unlocked, and one left # behind makes the next dispatch skip this version number. So the - # cleanup is armed before the claim, leaving no window where the tag - # is unowned, and it is conditional on the tag being this run's so - # that it never deletes one the claim lost to. Anything it cannot - # confirm it leaves alone: a stray tag costs a number, a wrong delete - # costs a release. + # cleanup is armed before the claim, and acts only on a claim that + # returned. Ownership is recorded rather than read back off the tag, + # because a tag that beat us to the name is almost always at this + # same commit: every run targets the default-branch tip. + # + # What is left is the one statement between the claim landing and + # `claimed=1`, and it fails the safe way. A stray tag costs a version + # number, a wrong delete costs a release. # # An EXIT trap covers a cancelled job as well as a failed command, # though not a SIGKILL. + claimed=0 cleanup() { - local ref_sha releases - ref_sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha 2>/dev/null) || return 0 - if [ "${ref_sha}" != "${COMMIT}" ]; then - return 0 - fi - # Listing answers "no release" with an empty result and a success, - # which is what tells a confirmed absence apart from a lookup that - # failed. Asking for the release by tag reports both the same way, - # and deleting on an unconfirmed absence would strip the tag off a - # release that did publish. - releases=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/releases" \ + [ "${claimed}" = 1 ] || return 0 + local releases + # The release call can land while this run dies before clearing + # the trap, so a tag of ours can already be locked. Listing + # answers "no release" with an empty result and a success, which + # tells a confirmed absence apart from a lookup that failed. + # Asking for the release by tag reports both the same way, and + # deleting on an unconfirmed absence would strip the tag off a + # release that did publish. One page is enough, newest first, and + # it keeps this to a bounded number of calls inside whatever + # grace period a cancelled job gets. + releases=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ --jq '.[] | select(.tag_name == env.TAG) | .id') || return 0 if [ -n "${releases}" ]; then return 0 @@ -214,6 +221,7 @@ jobs: gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ -f "ref=refs/tags/${TAG}" \ -f "sha=${COMMIT}" > /dev/null + claimed=1 gh release create "${TAG}" \ --repo "${GITHUB_REPOSITORY}" \ --target "${COMMIT}" \ From cc264d0037f686eb9d6e25692e6047e695febfdf Mon Sep 17 00:00:00 2001 From: Cory Johns Date: Mon, 19 Oct 2026 20:42:23 +0000 Subject: [PATCH 8/9] also require the tag to be unmoved before cleanup deletes it Co-Authored-By: Claude Opus 5 --- .github/workflows/cut-release.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 2ff3aa1..578aa05 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -200,7 +200,17 @@ jobs: claimed=0 cleanup() { [ "${claimed}" = 1 ] || return 0 - local releases + local ref_sha releases + # Both conditions are load-bearing and neither replaces the other. + # `claimed` answers whether this run created the tag, which the + # commit cannot: a tag that beat us to the name is almost always + # at this same commit. The commit answers whether anyone has moved + # the tag since, which `claimed` cannot. Dropping either one makes + # this destroy a tag it does not own. + ref_sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha 2>/dev/null) || return 0 + if [ "${ref_sha}" != "${COMMIT}" ]; then + return 0 + fi # The release call can land while this run dies before clearing # the trap, so a tag of ours can already be locked. Listing # answers "no release" with an empty result and a success, which From dfced5fad0b8c025aebdcb102010026688af418b Mon Sep 17 00:00:00 2001 From: Cory Johns Date: Mon, 19 Oct 2026 20:57:27 +0000 Subject: [PATCH 9/9] fold the publish-step rationale into one block Co-Authored-By: Claude Opus 5 --- .github/workflows/cut-release.yml | 50 +++++++++++++------------------ 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 578aa05..e5fe311 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -168,10 +168,9 @@ jobs: echo "That should not happen on the default branch with an explicit bump." >&2 exit 1 fi - # A courtesy check, not the guard. It turns a name that is already - # taken into a readable error rather than a raw API failure. Claiming - # the ref below is what actually settles the name. The call stays out - # of an `if` so that a failure of the check itself stops the run. + # A courtesy check, not the guard: claiming the ref below settles + # the name. Kept out of an `if` so a failure of the check itself + # stops the run. matches=$(gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${TAG}" \ --jq '[.[] | select(.ref == "refs/tags/" + env.TAG)] | length') if [ "${matches}" != "0" ]; then @@ -184,42 +183,33 @@ jobs: # taken, the release call publishes against whatever commit that tag # points at, and immutability makes that permanent. # - # Claiming first leaves the tag briefly unlocked, and one left - # behind makes the next dispatch skip this version number. So the - # cleanup is armed before the claim, and acts only on a claim that - # returned. Ownership is recorded rather than read back off the tag, - # because a tag that beat us to the name is almost always at this - # same commit: every run targets the default-branch tip. + # Claiming first leaves the tag briefly unlocked, so a run that stops + # in between has to remove it, or the next dispatch skips this + # version number. Three things decide whether removing it is safe, + # and dropping any one of them deletes a tag this run does not own: # - # What is left is the one statement between the claim landing and - # `claimed=1`, and it fails the safe way. A stray tag costs a version - # number, a wrong delete costs a release. + # claimed this run created the tag. The commit cannot say so, + # because a tag that beat us to the name is almost + # always at this same default-branch tip. + # unmoved nobody re-pointed it since. `claimed` cannot say so. + # no release absence confirmed by a listing that succeeded, not + # inferred from a lookup that failed. The release call + # can land while this run dies, so the tag may already + # be locked. # - # An EXIT trap covers a cancelled job as well as a failed command, - # though not a SIGKILL. + # The trap is armed before the claim, leaving one statement in which + # a kill strands the tag. That is the safe direction: a stray tag + # costs a version number, a wrong delete costs a release. An EXIT + # trap covers a cancelled job, though not a SIGKILL. claimed=0 cleanup() { [ "${claimed}" = 1 ] || return 0 local ref_sha releases - # Both conditions are load-bearing and neither replaces the other. - # `claimed` answers whether this run created the tag, which the - # commit cannot: a tag that beat us to the name is almost always - # at this same commit. The commit answers whether anyone has moved - # the tag since, which `claimed` cannot. Dropping either one makes - # this destroy a tag it does not own. ref_sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha 2>/dev/null) || return 0 if [ "${ref_sha}" != "${COMMIT}" ]; then return 0 fi - # The release call can land while this run dies before clearing - # the trap, so a tag of ours can already be locked. Listing - # answers "no release" with an empty result and a success, which - # tells a confirmed absence apart from a lookup that failed. - # Asking for the release by tag reports both the same way, and - # deleting on an unconfirmed absence would strip the tag off a - # release that did publish. One page is enough, newest first, and - # it keeps this to a bounded number of calls inside whatever - # grace period a cancelled job gets. + # Newest first, so a release made seconds ago is on the first page. releases=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ --jq '.[] | select(.tag_name == env.TAG) | .id') || return 0 if [ -n "${releases}" ]; then