Auto Release #316
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Auto Release | |
| # Publishes an annotated git tag + GitHub Release automatically once a version's CHANGELOG | |
| # section has been closed out and merged to `main`, and CI has gone green on that commit. Mirrors | |
| # RustyNES's automated-release pattern (`release-auto.yml`) adapted to RustySNES's own | |
| # conventions rather than copied wholesale: | |
| # | |
| # - RustySNES's crate `Cargo.toml` versions stay pinned at 0.1.0 (nothing here is published to | |
| # crates.io; versioning lives entirely in the git tag), so this can't key off a Cargo.toml | |
| # version bump the way RustyNES does. Instead the trigger signal is CHANGELOG.md's own | |
| # structure: an EMPTY `## [Unreleased]` section immediately followed by a real | |
| # `## [X.Y.Z] "Name" - date` heading means that version was just closed out (the | |
| # release-closeout PR ceremony this project already uses — e.g. PR #31 for v0.4.0, PR #35 for | |
| # v0.5.0) and is ready to tag. A non-empty `[Unreleased]` means there's nothing to release yet. | |
| # - Release notes ARE the CHANGELOG section itself (`docs/adr/0007`'s tag-body-is-the- | |
| # release-note convention) -- no separate maintainer-authored notes file to keep in sync; | |
| # every PR this project lands already writes its CHANGELOG entry at release-note quality. | |
| # - Creates a real ANNOTATED tag (`git tag -a vX.Y.Z -F <notes>`), not just the lightweight tag | |
| # `gh release create` would imply on its own, preserving the same annotated-tag convention | |
| # every prior release (v0.1.0-v0.4.0, hand-cut) used -- `git show vX.Y.Z` keeps working | |
| # identically for a bot-cut release as for a hand-cut one. | |
| # | |
| # Flow: PR merged to main (CHANGELOG `[Unreleased]` -> `[X.Y.Z]`, the release-closeout PR) -> CI | |
| # runs on main and goes green -> this workflow fires (workflow_run: CI completed/success on | |
| # main) -> if no `vX.Y.Z` tag exists yet for the closed-out version, it creates the tag + | |
| # GitHub Release, then invokes release.yml (workflow_call, since a bot-pushed tag doesn't trigger | |
| # `on: push: tags`) to build + attach the platform binaries + checksums. | |
| # | |
| # Idempotent: if the version's tag already exists, this is a clean no-op on every `main` build. | |
| on: | |
| workflow_run: | |
| workflows: ["CI"] | |
| types: [completed] | |
| branches: [main] | |
| permissions: | |
| contents: write | |
| concurrency: | |
| group: auto-release | |
| cancel-in-progress: false | |
| jobs: | |
| prepare: | |
| name: Prepare release (notes + tag) | |
| # Only act when CI actually SUCCEEDED on a push to main (not PRs / forks). | |
| if: > | |
| github.event.workflow_run.conclusion == 'success' && | |
| github.event.workflow_run.event == 'push' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| should_release: ${{ steps.decide.outputs.should_release }} | |
| tag: ${{ steps.decide.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| # Build the release from the exact commit CI went green on. Full history so the | |
| # annotated-tag creation and the tag-existence check both have what they need (a shallow | |
| # clone's tag/ref visibility isn't reliable here). | |
| ref: ${{ github.event.workflow_run.head_sha }} | |
| fetch-depth: 0 | |
| # DELIBERATELY NOT `persist-credentials: false`, and the only checkout in the repository | |
| # that keeps the token. Every other one was hardened in `v1.26.0` because checkout writes | |
| # the workflow `GITHUB_TOKEN` into `.git/config`, where anything the job then executes | |
| # from the tree can read it. | |
| # | |
| # This job is the exception because it genuinely needs it: it creates an annotated tag and | |
| # `git push origin "$TAG"` (below), which authenticates through exactly that credential. | |
| # The sibling project's audit concluded no job needed the token; that conclusion does NOT | |
| # transfer here, which is why this was audited per-site rather than applied blanket. | |
| # | |
| # The exposure is bounded by the trigger: `workflow_run` on a completed CI run of `main`, | |
| # never a pull request, so the tree this job executes is already-reviewed code. | |
| - name: Decide whether a closed-out version needs releasing | |
| id: decide | |
| shell: bash | |
| env: | |
| # Required by the `gh api` tag-existence check below. `gh` errors out without it rather | |
| # than falling back to the ambient credential, and this step previously used only plain | |
| # `git`, so it had no token of its own. Same form as the release steps further down. | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| # ANY non-blank content under [Unreleased] (not just a "- " bullet -- a stray heading | |
| # like a bare "### Added" with no bullets under it yet is still content, not an | |
| # all-clear to release) means the next version hasn't been closed out yet -- nothing | |
| # to release. Exit status of the awk itself carries the answer. | |
| if awk ' | |
| /^## \[Unreleased\]/ { f=1; next } | |
| f && /^## \[/ { exit } | |
| f && NF { found=1 } | |
| END { exit !found } | |
| ' CHANGELOG.md; then | |
| echo "[Unreleased] still has content -- no version to release yet." | |
| echo "should_release=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| header="$(awk '/^## \[Unreleased\]/{f=1; next} f && /^## \[/{print; exit}' CHANGELOG.md)" | |
| version="$(printf '%s' "$header" | sed -nE 's/^## \[([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/p')" | |
| if [ -z "$version" ]; then | |
| echo "No closed-out '## [X.Y.Z]' section found after [Unreleased] -- nothing to release." | |
| echo "should_release=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| tag="v${version}" | |
| echo "tag=${tag}" >> "$GITHUB_OUTPUT" | |
| echo "version=${version}" >> "$GITHUB_OUTPUT" | |
| echo "header=${header}" >> "$GITHUB_OUTPUT" | |
| # FAIL CLOSED. This was `git ls-remote --exit-code --tags origin "refs/tags/${tag}"`, | |
| # which collapses THREE outcomes into two: tag present (0), tag absent (2), and lookup | |
| # failed (non-zero, various). Reading any non-zero as "absent" means a transient network | |
| # blip, an auth hiccup, or a rate limit sends an ALREADY-RELEASED version down the | |
| # `should_release=true` path — re-tagging a published release, which | |
| # `to-dos/ROADMAP.md`'s release ceremony treats as immutable. | |
| # | |
| # `git/matching-refs` is chosen over `git/ref/tags/<tag>` deliberately: it answers | |
| # "absent" with HTTP 200 and an empty array, so a genuine miss can never be confused with | |
| # an error and no error-body parsing is needed. It matches by PREFIX, though, so the exact | |
| # ref is compared in `jq` — that is not theoretical, `v1.2` prefix-matches several real | |
| # tags while exact-matching none. | |
| # | |
| # VERIFIED against the live API, because review challenged exactly this and asserted the | |
| # opposite (that `matching-refs` 404s on an absent tag, which would fail every release | |
| # closed): | |
| # | |
| # matching-refs/tags/v99.99.99 -> HTTP 200, body `[]`, gh exit 0 | |
| # matching-refs/tags/zzz-not-a-tag -> HTTP 200, body `[]`, gh exit 0 | |
| # git/ref/tags/v99.99.99 -> 404, gh exit 1 <- the rejected endpoint | |
| # | |
| # So a non-zero exit here means a REAL failure (network, auth, rate limit) and nothing | |
| # else, which is what makes aborting on it correct rather than over-strict. Do not "fix" | |
| # this by treating 404 as absent: that would reintroduce the exact three-way collapse | |
| # this replaced, letting an auth failure read as "no tag, go ahead and release". | |
| # | |
| # Every failure path aborts the job (the script runs under `set -euo pipefail`) rather | |
| # than resolving to a release decision. | |
| if ! matching="$(gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${tag}")"; then | |
| echo "::error::Tag lookup for ${tag} failed. Refusing to guess whether it exists." | |
| exit 1 | |
| fi | |
| if printf '%s' "$matching" | jq -e --arg ref "refs/tags/${tag}" 'any(.[]; .ref == $ref)' >/dev/null; then | |
| echo "Tag ${tag} already exists -- nothing to release." | |
| echo "should_release=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "Version ${version} has no ${tag} tag yet -- will release." | |
| echo "should_release=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Resolve release title + notes from the CHANGELOG section | |
| if: steps.decide.outputs.should_release == 'true' | |
| id: notes | |
| env: | |
| # Routed through env: rather than interpolated directly into the script (GitHub Actions | |
| # substitutes step outputs as raw text BEFORE the shell parses the script -- a header | |
| # containing a literal `"` (e.g. `## [0.6.0] "Shippable" - ...`) would otherwise close | |
| # the surrounding `header="..."` string early and corrupt the value silently, not error | |
| # loudly; env: values are passed as real argv/environment data, immune to this). | |
| VERSION: ${{ steps.decide.outputs.version }} | |
| HEADER: ${{ steps.decide.outputs.header }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| version="$VERSION" | |
| header="$HEADER" | |
| body_file="$(mktemp)" | |
| # The full "## [X.Y.Z] ..." section body, up to (not including) the next "## [" heading. | |
| awk -v ver="$version" ' | |
| $0 ~ ("^## \\[" ver "\\]") { f=1; next } | |
| f && /^## \[/ { exit } | |
| f { print } | |
| ' CHANGELOG.md > "$body_file" | |
| # Trim leading/trailing blank lines: drop leading blanks, reverse, drop what are now | |
| # the leading blanks (the original trailing ones), reverse back. `tac` is coreutils, | |
| # present on the ubuntu runner. | |
| trimmed="$(mktemp)" | |
| awk 'NF{p=1} p' "$body_file" | tac | awk 'NF{p=1} p' | tac > "$trimmed" | |
| mv "$trimmed" "$body_file" | |
| if [ ! -s "$body_file" ]; then | |
| echo "::error::CHANGELOG section for ${version} is empty -- nothing to put in the tag/release." | |
| exit 1 | |
| fi | |
| # Title matches every prior hand-cut release's exact format, e.g. `v0.4.0 "Completion"` | |
| # (name="" e.g. `## [0.4.0] "Completion" - 2026-07-08` -> quoted theme -> `v0.4.0 "Completion"`). | |
| name="$(printf '%s' "$header" | sed -nE 's/^## \[[0-9]+\.[0-9]+\.[0-9]+\][[:space:]]*("[^"]*").*/\1/p')" | |
| if [ -n "$name" ]; then | |
| title="v${version} ${name}" | |
| else | |
| title="v${version}" | |
| fi | |
| echo "body_file=${body_file}" >> "$GITHUB_OUTPUT" | |
| echo "title=${title}" >> "$GITHUB_OUTPUT" | |
| echo "Resolved title: ${title}" | |
| - name: Create the annotated tag + push it | |
| if: steps.decide.outputs.should_release == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| TAG: ${{ steps.decide.outputs.tag }} | |
| BODY_FILE: ${{ steps.notes.outputs.body_file }} | |
| HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git tag -a "$TAG" -F "$BODY_FILE" "$HEAD_SHA" | |
| git push origin "$TAG" | |
| - name: Create the GitHub Release | |
| if: steps.decide.outputs.should_release == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| # Same rationale as the "Resolve release title" step: TITLE routinely contains a | |
| # literal `"` (e.g. `v0.6.0 "Shippable"`), which would corrupt a direct | |
| # `--title "${{ steps.notes.outputs.title }}"` interpolation the same way `header` did. | |
| TAG: ${{ steps.decide.outputs.tag }} | |
| TITLE: ${{ steps.notes.outputs.title }} | |
| BODY_FILE: ${{ steps.notes.outputs.body_file }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| gh release create "$TAG" \ | |
| --title "$TITLE" \ | |
| --notes-file "$BODY_FILE" \ | |
| --latest | |
| build: | |
| name: Build + attach artifacts | |
| needs: prepare | |
| if: needs.prepare.outputs.should_release == 'true' | |
| permissions: | |
| contents: write | |
| # Reuse the Release build matrix; it attaches the platform binaries + checksums to the | |
| # release created above and never overwrites the body. | |
| uses: ./.github/workflows/release.yml | |
| with: | |
| tag: ${{ needs.prepare.outputs.tag }} |