Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 62 additions & 14 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -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] - <date>" is the newest
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -121,11 +154,26 @@ 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
attestations: write # release.yml: publish + read back the attestations
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 }}
34 changes: 25 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading