Repository navigation
Auto Release #312
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 a GitHub Release automatically once a new version goes FINAL-green on | |
| # `main`. The flow: | |
| # | |
| # PR merged to main (version bumped + CHANGELOG `[Unreleased]` -> `[X.Y.Z]`) | |
| # -> CI runs on main and goes green (all bot threads adjudicated, fixes landed) | |
| # -> this workflow fires (workflow_run: CI completed/success on main) | |
| # -> if no `vX.Y.Z` tag exists yet for the workspace version, it: | |
| # 1. resolves the release notes (maintainer-authored) + title | |
| # 2. creates the tag + GitHub Release with those notes | |
| # 3. invokes `release.yml` (workflow_call) to build + attach the | |
| # Linux / macOS-aarch64 / Windows binaries to the release | |
| # | |
| # Why workflow_call instead of letting the tag trigger release.yml: a tag pushed | |
| # by the built-in GITHUB_TOKEN does NOT trigger `on: push: tags` (GitHub's | |
| # recursion guard), so we invoke the build directly. | |
| # | |
| # Release notes are MAINTAINER-AUTHORED, never machine-generated: | |
| # * Preferred: a hand-written `.github/release-notes/vX.Y.Z.md` (the comprehensive, | |
| # technically-detailed notes — see .github/release-notes/README.md). NOTE: this | |
| # is deliberately NOT `docs/release-notes/`, which holds the engine-lineage | |
| # history archive (its `v2.0.0.md` would collide with RustyNES's own v2.0.0). | |
| # * Fallback: the `## [X.Y.Z]` section extracted from CHANGELOG.md. | |
| # * If NEITHER exists, the job FAILS loudly (a release must never ship empty | |
| # notes) so the maintainer adds them and re-runs. | |
| # The title's codename/theme is parsed from the CHANGELOG `## [X.Y.Z] - date - ...` | |
| # header line. release.yml never sets the release body, so the notes are preserved. | |
| # | |
| # Idempotent: if the version's tag already exists, the job is a clean no-op, so | |
| # this fires harmlessly on every main build (only a version bump produces a tag). | |
| on: | |
| workflow_run: | |
| workflows: ["CI"] | |
| types: [completed] | |
| branches: [main] | |
| permissions: | |
| contents: write | |
| concurrency: | |
| # Per-commit group so releases for DIFFERENT versions never supersede each other. | |
| # A single global group let GitHub cancel the older PENDING run whenever a newer | |
| # one queued behind an in-progress release (the slow binary build serializes the | |
| # group) — which silently dropped a middle version's release during a rapid train | |
| # (v2.0.6 was skipped between v2.0.5 and v2.0.7 this way). Keying the group on the | |
| # head SHA serializes each commit only with itself (still de-duping re-runs of the | |
| # same commit) while letting distinct versions release independently; the | |
| # tag-existence check + idempotent `gh release create` already make cross-version | |
| # concurrency safe. | |
| group: auto-release-${{ github.event.workflow_run.head_sha }} | |
| 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-26.04 | |
| # Bounded like every other job (v2.3.7). `build` below cannot carry one — | |
| # `timeout-minutes` is not valid on a job that uses `uses:` — so its budget | |
| # lives on the jobs inside `release.yml`, which already carry their own. | |
| # | |
| # Challenged in review on #406, which claimed the restriction was lifted in | |
| # late 2022. It was not. GitHub's workflow-syntax and reuse-workflows pages | |
| # state neither way, so it was checked against the schema rather than | |
| # recalled; `actionlint` on exactly this shape: | |
| # | |
| # when a reusable workflow is called with "uses", "timeout-minutes" is not | |
| # available. only following keys are allowed: "name", "uses", "with", | |
| # "secrets", "needs", "if", and "permissions" | |
| # | |
| # So adding one here is a hard syntax error, not the harmless no-op it would | |
| # be if the key were merely ignored. | |
| timeout-minutes: 15 | |
| outputs: | |
| should_release: ${{ steps.decide.outputs.should_release }} | |
| tag: ${{ steps.decide.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| # Build the release from the exact commit CI went green on. A shallow | |
| # checkout suffices: we only read plain text files (Cargo.toml, | |
| # CHANGELOG.md, and the optional `.github/release-notes/vX.Y.Z.md` | |
| # override), and the tag existence check is an API call, not a Git | |
| # operation. | |
| ref: ${{ github.event.workflow_run.head_sha }} | |
| # Completes the #318 sweep — this was the last checkout in the repo | |
| # still persisting credentials, held back only because the tag check | |
| # used `git ls-remote origin`. That check is now `gh api` (see the | |
| # step below), which authenticates with GH_TOKEN and needs nothing | |
| # from `.git/config`, so the exception no longer has a reason to | |
| # exist and all 19 checkouts are uniform. | |
| persist-credentials: false | |
| - name: Decide whether a new version needs releasing | |
| id: decide | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| # Workspace version from [workspace.package] in Cargo.toml. | |
| version="$(awk -F'"' '/^\[workspace\.package\]/{f=1} f && /^version[[:space:]]*=/{print $2; exit}' Cargo.toml)" | |
| if [ -z "$version" ]; then | |
| echo "::error::Could not parse [workspace.package] version from Cargo.toml" | |
| exit 1 | |
| fi | |
| tag="v${version}" | |
| echo "tag=${tag}" >> "$GITHUB_OUTPUT" | |
| echo "version=${version}" >> "$GITHUB_OUTPUT" | |
| # Tag-existence check, FAIL-CLOSED. The previous implementation was | |
| # git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1 | |
| # which collapsed three distinct outcomes into two: tag present, tag | |
| # absent, and *lookup failed* all became a simple true/false, with any | |
| # non-zero exit read as "absent". A transient network or auth blip | |
| # therefore pushed an already-released version down the | |
| # should_release=true path. This decides the entire release, so | |
| # guessing is the one thing it must not do. | |
| # | |
| # `git/matching-refs` is used rather than `git/ref/tags/$tag` because | |
| # it answers "absent" with HTTP 200 and an empty array instead of a | |
| # 404 — so a genuine miss never looks like an error, and no error-body | |
| # parsing is needed to tell them apart. It matches by PREFIX, so | |
| # `tags/v2.2.1` would also return `v2.2.10`; the jq filter compares the | |
| # full ref for exactness. | |
| # | |
| # Everything here fails closed under `set -euo pipefail`: a gh/API | |
| # failure, malformed JSON, or an unexpected count aborts the job | |
| # rather than resolving to a release decision. It also needs no Git | |
| # credentials, which is what let the checkout above join the rest of | |
| # the #318 sweep. | |
| refs_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${tag}")" | |
| # The `type != "array"` guard is load-bearing, not belt-and-braces. | |
| # Without it, a body of `{}` makes `.[]` iterate zero object VALUES, | |
| # so the filter yields an empty list and `length` is 0 — identical to | |
| # a genuine "tag absent", which takes the RELEASE path. That is a | |
| # fail-OPEN on the one decision this step exists to get right. | |
| # (Other malformed shapes — a bare string, null, an object with | |
| # entries — do abort on their own, because `.[]` or `.ref` errors; | |
| # `{}` is the shape that slips through, which is exactly why an | |
| # explicit type check is needed rather than relying on jq erroring.) | |
| count="$(printf '%s\n' "$refs_json" \ | |
| | jq --arg r "refs/tags/${tag}" ' | |
| if type != "array" then | |
| error("expected a JSON array from git/matching-refs") | |
| else | |
| [.[] | select(.ref == $r)] | length | |
| end')" | |
| case "$count" in | |
| 0) | |
| echo "Version ${version} has no ${tag} tag yet - will release." | |
| echo "should_release=true" >> "$GITHUB_OUTPUT" | |
| ;; | |
| 1) | |
| echo "Tag ${tag} already exists - nothing to release." | |
| echo "should_release=false" >> "$GITHUB_OUTPUT" | |
| ;; | |
| *) | |
| echo "::error::Unexpected match count (${count}) for refs/tags/${tag} - refusing to guess." | |
| printf '%s\n' "$refs_json" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| - name: Resolve release notes + title | |
| if: steps.decide.outputs.should_release == 'true' | |
| id: notes | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| version="${{ steps.decide.outputs.version }}" | |
| override=".github/release-notes/v${version}.md" | |
| body_file="$(mktemp)" | |
| if [ -f "$override" ]; then | |
| echo "Using maintainer-authored notes: ${override}" | |
| cp "$override" "$body_file" | |
| else | |
| echo "No ${override}; extracting the [${version}] section from CHANGELOG.md" | |
| awk -v ver="$version" ' | |
| $0 ~ ("^## \\[" ver "\\]") { f=1; next } | |
| f && /^## \[/ { exit } | |
| f { print } | |
| ' CHANGELOG.md > "$body_file" | |
| fi | |
| # Strip leading and 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::No release notes for ${version} - add .github/release-notes/v${version}.md or a CHANGELOG '## [${version}]' section, then re-run." | |
| exit 1 | |
| fi | |
| # Title codename/theme from the CHANGELOG header, e.g. | |
| # ## [1.9.9] - 2026-06-26 - "Workshop" (iOS ...) -> "Workshop" (iOS ...) | |
| header="$(grep -m1 -E "^## \[${version}\]" CHANGELOG.md || true)" | |
| theme="$(printf '%s' "$header" | sed -E 's/^## \[[^]]*\][[:space:]]*-[[:space:]]*[0-9-]+[[:space:]]*-[[:space:]]*//')" | |
| if [ -n "$theme" ] && [ "$theme" != "$header" ]; then | |
| title="RustyNES v${version} — ${theme}" | |
| else | |
| title="RustyNES v${version}" | |
| fi | |
| echo "body_file=${body_file}" >> "$GITHUB_OUTPUT" | |
| echo "title=${title}" >> "$GITHUB_OUTPUT" | |
| echo "Resolved title: ${title}" | |
| - name: Create tag + GitHub Release | |
| if: steps.decide.outputs.should_release == 'true' | |
| # Pass every dynamic value through the environment and reference it as a | |
| # shell variable ("$RELEASE_TITLE"), never via a `${{ }}` expression | |
| # interpolated straight into the `run:` script. The title is derived from | |
| # the CHANGELOG header, which contains double quotes (the "Codename" and | |
| # any quoted phrase like "unexpected read") and `;` / `(` / `)` — splicing | |
| # that into the shell command text breaks quoting (a codename-only header | |
| # merely got its quotes stripped, but a header with a quoted multi-word | |
| # phrase split into bare words and a `;` was read as a command separator, | |
| # failing the v2.1.7 auto-release). Env-var expansion is quote-safe. | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| RELEASE_TAG: ${{ steps.decide.outputs.tag }} | |
| RELEASE_TARGET: ${{ github.event.workflow_run.head_sha }} | |
| RELEASE_TITLE: ${{ steps.notes.outputs.title }} | |
| RELEASE_BODY_FILE: ${{ steps.notes.outputs.body_file }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| gh release create "$RELEASE_TAG" \ | |
| --target "$RELEASE_TARGET" \ | |
| --title "$RELEASE_TITLE" \ | |
| --notes-file "$RELEASE_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 to the | |
| # release created above and never overwrites the body. | |
| uses: ./.github/workflows/release.yml | |
| with: | |
| tag: ${{ needs.prepare.outputs.tag }} |