From 09fcd3f8e57a8bb58a351cd8494359a3d6c8176d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:54:45 +0100 Subject: [PATCH] ci: auto-cut a release when a new CHANGELOG version lands on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make release.yml callable via workflow_call (a required 'tag' input) in addition to its tag-push trigger, and add auto-release.yml which, on a push to main, tags the newest un-released CHANGELOG version and invokes release.yml to build and publish it — no PAT, using the built-in token (a GITHUB_TOKEN-pushed tag would not trigger the tag event, so release.yml is called explicitly instead). release.yml's security posture is preserved: both jobs still check out github.sha (never the tag name), persist-credentials stays false, the no-branch-push tripwire and check-plan-shipped/attestation/prune gates are unchanged; the derived TAG is only ever a name (version stamp, release title, prune), never a checkout ref. auto-release tags only the NEWEST version (tagging the backlog at HEAD would mis-point immutable tags at the wrong code), is idempotent (no-op if that version is already tagged), and floors permissions at contents: read, escalating only the tag job to contents: write and granting the release call exactly the write scopes release.yml needs. Security-reviewed: PASS (anti-tag-move, tripwire, permission capping, injection, token, fail-closed attestation, idempotency all verified). --- .github/workflows/auto-release.yml | 131 +++++++++++++++++++++++++++++ .github/workflows/release.yml | 38 +++++++-- 2 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/auto-release.yml diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..81ecbe5 --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,131 @@ +name: auto-release + +# On every push to the default branch, tag-and-release the NEWEST dated +# CHANGELOG version when it has no git tag yet — WITHOUT a PAT. A tag pushed with +# the built-in GITHUB_TOKEN deliberately does NOT re-trigger release.yml's +# tag-push event, so this workflow instead creates the tag and then invokes +# release.yml directly as a reusable workflow (the `release` job below). That +# keeps the whole publish path inside GitHub's own token model — no personal +# access token, no elevated secret. +# +# 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. +on: + push: + branches: [main] + +# Serialise: never let two auto-release runs race to create the same tag, and +# never cancel one mid-flight — a half-created tag or release is worse than a +# queued one. +concurrency: + group: auto-release + cancel-in-progress: false + +# Floor for the workflow: read-only. Each job elevates itself to exactly the +# scopes it needs, and no more. +permissions: + contents: read + +jobs: + # Decide whether the newest dated CHANGELOG version still needs a tag. Pure + # read: no writes, no pushes, contents: read only. + detect: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + version: ${{ steps.detect.outputs.version }} + should_release: ${{ steps.detect.outputs.should_release }} + steps: + - name: Check out the pushed commit (full history + tags) + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # fetch-depth: 0 pulls every tag so the existence check below is + # authoritative; a shallow clone would omit tags and mis-report. + fetch-depth: 0 + persist-credentials: false + + - name: Detect the newest untagged CHANGELOG version + id: detect + # No ${{ }} interpolation in this script (injection-safe, zizmor): it + # reads CHANGELOG.md from the checkout and writes only to $GITHUB_OUTPUT. + run: | + set -euo pipefail + # The first heading of the form "## [X.Y.Z] - " is the newest + # dated release. Requiring the " - " date separator skips the + # "## [Unreleased]" heading, which carries no date. + version="$(grep -m1 -E '^## \[[0-9]+\.[0-9]+\.[0-9]+\] - ' CHANGELOG.md \ + | 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" + 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" + else + echo "$tag has no git tag; will tag and release." + echo "should_release=true" >> "$GITHUB_OUTPUT" + fi + + # Create and push the annotated tag with the GITHUB_TOKEN. This is the ONLY + # push this automation makes, and it targets a NEW tag ref only — never a + # branch. A GITHUB_TOKEN-pushed tag raises no tag-push event, which is why the + # release below invokes release.yml explicitly rather than relying on the tag. + tag: + needs: detect + if: needs.detect.outputs.should_release == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write # push the new tag ref + steps: + - name: Check out the pushed commit + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # github.sha = this main push's HEAD, the immutable commit to tag. + ref: ${{ github.sha }} + # persist-credentials: true is REQUIRED here so `git push` below + # authenticates as the GITHUB_TOKEN. Scoped to this single tag-creating + # job whose only step pushes one new tag ref; no other step runs, so + # the persisted credential is never exposed to untrusted input. + persist-credentials: true + + - name: Create and push the annotated tag at the released commit + # Values arrive via env (no ${{ }} inside the script): injection-safe. + env: + TAG: v${{ needs.detect.outputs.version }} + COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + # Annotated tag at github.sha — the exact commit this push built and the + # commit release.yml will check out (github.sha) and ship. + git tag -a "$TAG" -m "ferry $TAG" "$COMMIT" + git push origin "refs/tags/$TAG" + + # Cut the release by calling release.yml as a reusable workflow. Because it is + # CALLED (not triggered by the tag push), github.sha inside release.yml is this + # run's HEAD = the commit just tagged, so both of release.yml's jobs check out + # and ship exactly that commit — the same anti-tag-move property the tag-push + # entry point has. A called workflow is capped by the caller's grants, so this + # job hands down every scope release.yml's jobs need (contents/id-token/ + # attestations: write). + release: + needs: [detect, tag] + if: needs.detect.outputs.should_release == 'true' + 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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2edcb95..ce51749 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,16 @@ on: push: tags: - 'v*' + # Also callable as a reusable workflow: the auto-release workflow tags the + # newest CHANGELOG version with the GITHUB_TOKEN (which, by design, does NOT + # re-trigger the tag-push event above) and then invokes this workflow + # explicitly, passing the tag it just created. Both entry points converge on + # the same jobs below; only the tag NAME arrives differently (see TAG). + workflow_call: + inputs: + tag: + required: true + type: string # 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. @@ -27,6 +37,19 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false +# The tag name this run releases, derived ONCE for both entry points: +# - workflow_call: `inputs.tag` (the tag the auto-release workflow just made). +# - push-tag: `inputs` is empty, so this falls through to github.ref_name +# (the vX.Y.Z ref that triggered the run). +# Exported to every step as $TAG. Note this is only the tag NAME — neither job +# ever CHECKS OUT this name; both check out github.sha (see each checkout step), +# which is the immutable released commit for BOTH entry points: +# - push-tag: github.sha = the commit the pushed tag pointed at. +# - workflow_call: github.sha = the caller's HEAD = the commit auto-release +# just tagged. +env: + TAG: ${{ inputs.tag || github.ref_name }} + # Floor for the workflow: read-only. The release job below elevates itself to # contents: write only to create the Release and upload its assets; verify stays # read-only. No job pushes to any branch. @@ -70,9 +93,10 @@ jobs: # .abcd/development/plans/*.md plan exists, its Status: line must read # "shipped in "; a missing plan is fine (not every patch has one). # VERSION comes via env (not inlined into the run script) to keep the - # tag ref out of the shell expansion (injection-safe, zizmor). + # tag ref out of the shell expansion (injection-safe, zizmor). $TAG is + # the workflow-level env derived once for both entry points. env: - VERSION: ${{ github.ref_name }} + VERSION: ${{ env.TAG }} run: scripts/check-plan-shipped.sh "$VERSION" - name: Build @@ -182,10 +206,10 @@ jobs: cache: false - name: Cross-compile the four binaries (version-stamped from the tag) - # VERSION= stamps `ferry --version` via -ldflags. The binaries built + # VERSION=$TAG stamps `ferry --version` via -ldflags. The binaries built # here are the ones checksummed, attested, and uploaded below, so the # manifest, the published assets, and the reported version are all in sync. - run: make build VERSION="${GITHUB_REF_NAME}" + run: make build VERSION="$TAG" - name: Generate checksums.txt over the four binaries # Hash the exact binaries built above into a sha256sum-format manifest. @@ -218,7 +242,7 @@ jobs: bin/checksums.txt - name: Create the GitHub Release and upload the binaries + checksums.txt - run: gh release create "${GITHUB_REF_NAME}" bin/ferry-* bin/checksums.txt --verify-tag --title "${GITHUB_REF_NAME}" --notes "" + run: gh release create "$TAG" bin/ferry-* bin/checksums.txt --verify-tag --title "$TAG" --notes "" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -237,7 +261,7 @@ jobs: set -euo pipefail DL="$RUNNER_TEMP/attest-verify" mkdir -p "$DL" - gh release download "${GITHUB_REF_NAME}" \ + gh release download "$TAG" \ --repo "${GITHUB_REPOSITORY}" \ --pattern 'ferry-linux-amd64' \ --dir "$DL" @@ -259,7 +283,7 @@ jobs: # git tags, which are immutable once pushed). It never prunes the release just # created (--current), and refuses if a newer # patch than the current one somehow exists. See scripts/prune-releases.sh. - run: scripts/prune-releases.sh --current "${GITHUB_REF_NAME}" + run: scripts/prune-releases.sh --current "$TAG" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}