diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 81ecbe5..d7c2226 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -10,9 +10,13 @@ name: auto-release # # Only the NEWEST dated version is ever tagged: older CHANGELOG versions are left # alone, because tagging them at the current HEAD would mis-point an immutable -# tag at the wrong code. Idempotent: when the newest version is already tagged, -# `detect` reports should_release=false and neither `tag` nor `release` runs, so -# an ordinary (non-release) push to main does nothing. +# tag at the wrong code. Idempotent: when the newest version is already tagged AND +# its GitHub Release exists, `detect` reports need_tag=need_release=false and +# nothing runs, so an ordinary (non-release) push to main does nothing. If the tag +# exists but its Release is MISSING (e.g. a transient publish failure), `detect` +# sets need_release=true and re-invokes `release` ALONE — built from the tagged +# commit (release_ref), never the moved-on HEAD — so a flaky publish never +# permanently wedges the version. on: push: branches: [main] @@ -39,7 +43,9 @@ jobs: contents: read outputs: version: ${{ steps.detect.outputs.version }} - should_release: ${{ steps.detect.outputs.should_release }} + need_tag: ${{ steps.detect.outputs.need_tag }} + need_release: ${{ steps.detect.outputs.need_release }} + release_ref: ${{ steps.detect.outputs.release_ref }} steps: - name: Check out the pushed commit (full history + tags) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -49,10 +55,14 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Detect the newest untagged CHANGELOG version + - name: Detect the newest CHANGELOG version needing a tag or a re-release id: detect - # No ${{ }} interpolation in this script (injection-safe, zizmor): it - # reads CHANGELOG.md from the checkout and writes only to $GITHUB_OUTPUT. + # No ${{ }} interpolation in this run script (injection-safe, zizmor): it + # reads CHANGELOG.md from the checkout, queries git + gh, and writes only + # to $GITHUB_OUTPUT. GH_TOKEN arrives via env (not inline), so `gh release + # view` can read the repo's releases under the floor `contents: read`. + env: + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail # The first heading of the form "## [X.Y.Z] - " is the newest @@ -62,17 +72,40 @@ jobs: | sed -E 's/^## \[([0-9]+\.[0-9]+\.[0-9]+)\] - .*/\1/' || true)" if [ -z "$version" ]; then echo "No dated CHANGELOG version found; nothing to release." - echo "should_release=false" >> "$GITHUB_OUTPUT" + echo "need_tag=false" >> "$GITHUB_OUTPUT" + echo "need_release=false" >> "$GITHUB_OUTPUT" exit 0 fi echo "version=$version" >> "$GITHUB_OUTPUT" tag="v$version" if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then - echo "$tag already exists; nothing to release." - echo "should_release=false" >> "$GITHUB_OUTPUT" + # The tag already exists, so it is NEVER moved (immutable). Re-release + # ONLY when its GitHub Release is missing: a transient publish failure + # must not permanently wedge the version. Build the re-release FROM the + # tagged commit, not the current (moved-on) main HEAD — resolve the tag + # to its immutable commit SHA and hand it to release.yml as `ref`. + echo "need_tag=false" >> "$GITHUB_OUTPUT" + # Deliberate fail-open: ANY non-zero from `gh release view` (a true 404 + # or a transient rate-limit/auth blip) counts as "Release missing" and + # re-releases. Safe — `gh release create` (release.yml) has no --clobber, + # so a false positive just errors on the existing release and the + # success-gated prune never runs: one red run, no data loss. Parsing the + # error to isolate a real 404 was rejected — it would hinge on gh's + # wording and could misclassify a genuine missing-Release, re-wedging the + # very failure this heals. + if gh release view "$tag" >/dev/null 2>&1; then + echo "$tag is tagged and released; nothing to do." + echo "need_release=false" >> "$GITHUB_OUTPUT" + else + commit="$(git rev-parse --verify "$tag^{commit}")" + echo "$tag is tagged but has NO GitHub Release; re-releasing from $commit." + echo "need_release=true" >> "$GITHUB_OUTPUT" + echo "release_ref=$commit" >> "$GITHUB_OUTPUT" + fi else - echo "$tag has no git tag; will tag and release." - echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "$tag has no git tag; will tag and release at github.sha." + echo "need_tag=true" >> "$GITHUB_OUTPUT" + echo "need_release=true" >> "$GITHUB_OUTPUT" fi # Create and push the annotated tag with the GITHUB_TOKEN. This is the ONLY @@ -81,7 +114,7 @@ jobs: # release below invokes release.yml explicitly rather than relying on the tag. tag: needs: detect - if: needs.detect.outputs.should_release == 'true' + if: needs.detect.outputs.need_tag == 'true' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -121,7 +154,18 @@ jobs: # attestations: write). release: needs: [detect, tag] - if: needs.detect.outputs.should_release == 'true' + # Run when a release is needed AND the tag is in place — either the tag job + # just created it (success) or it already existed and was skipped. !cancelled() + # is required because `tag` is skipped on the re-release path; a bare success() + # would skip this job too. !cancelled() — not always(), matching the tripwire + # convention in release.yml — still fires on a SKIPPED `tag` but NOT on a + # cancelled run, so a manual cancel can never publish a half-finished state. A + # FAILED tag job (result neither success nor skipped) blocks the release, so a + # half-made tag never publishes. + if: >- + !cancelled() + && needs.detect.outputs.need_release == 'true' + && (needs.tag.result == 'success' || needs.tag.result == 'skipped') permissions: contents: write # release.yml: create the Release + verify the tag id-token: write # release.yml: OIDC for build-provenance attestation @@ -129,3 +173,7 @@ jobs: uses: ./.github/workflows/release.yml with: tag: v${{ needs.detect.outputs.version }} + # Empty on the fresh-tag path → release.yml falls back to github.sha (the + # commit just tagged). The resolved tagged-commit SHA on the re-release + # path, so the Release is built from the tag, not a newer main HEAD. + ref: ${{ needs.detect.outputs.release_ref }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce51749..cab7e74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,19 @@ on: tag: required: true type: string + # Exact commit to build the release FROM. Defaults to github.sha (the + # triggering commit). auto-release passes the tagged commit's RESOLVED SHA + # when re-publishing a Release for an already-existing tag, so the Release + # is built from the tagged commit — never a newer main HEAD that has moved + # on. A resolved commit SHA (not a tag name) keeps the anti-tag-move + # property: an immutable SHA cannot be re-pointed between checkout steps. + # `tag` and `ref` are an unenforced caller CONTRACT — --verify-tag checks + # only that the tag exists, not that it points at `ref`. The sole caller + # (auto-release) binds them: ref = `git rev-parse "$tag^{commit}"`. + ref: + required: false + type: string + default: '' # Never run two releases of the same tag concurrently, and never cancel a # release mid-flight — a half-published release is worse than a queued one. @@ -74,10 +87,12 @@ jobs: - name: Check out the pushed commit uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - # github.sha, not the tag ref: checkout re-resolves a NAMED ref at each - # job's start, so a tag moved after the push could differ here. The sha - # is the commit the pushed tag pointed at, immutable for this run. - ref: ${{ github.sha }} + # inputs.ref when auto-release re-releases an existing tag (its resolved + # tagged-commit SHA), else github.sha — the commit the pushed tag / this + # run pointed at. Both are IMMUTABLE commit SHAs, never a tag NAME: + # checkout re-resolves a named ref at each job's start, so a moved tag + # could differ here, whereas a pinned SHA cannot. + ref: ${{ inputs.ref || github.sha }} persist-credentials: false - name: Set up Go @@ -173,11 +188,12 @@ jobs: - name: Check out the pushed commit uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - # github.sha (the commit the pushed tag pointed at), not the tag NAME - # and not the default-branch tip, so the binaries below are built from - # the exact commit verify gated — a tag re-pointed mid-run changes - # nothing here. - ref: ${{ github.sha }} + # inputs.ref (the re-release path's resolved tagged-commit SHA) or + # github.sha (the commit the pushed tag pointed at) — an immutable SHA, + # not the tag NAME and not the default-branch tip, so the binaries below + # are built from the exact commit verify gated. Both jobs resolve the + # SAME value, so verify and publish build the identical commit. + ref: ${{ inputs.ref || github.sha }} # No job step pushes to any branch, so no git-stored credentials are # needed: gh uses GH_TOKEN, and the no-branch-commit tripwire reads the # default-branch ref through the API with GH_TOKEN, not git ls-remote. diff --git a/CHANGELOG.md b/CHANGELOG.md index dc3db34..fc60ae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ called out in a **Breaking** section. See ## [Unreleased] +### Fixed + +- **Release automation self-heals a missing GitHub Release.** When a version's + git tag exists but its GitHub Release is absent — for example a transient CI + failure between tagging and publishing — the auto-release workflow now + re-publishes the Release instead of leaving the version permanently + un-released. The re-release is built from the tagged commit, never a newer + `main`, so the published binaries always match the tag. + ## [0.7.3] - 2026-07-08 ### Fixed