Skip to content

Auto Release

Auto Release #301

Workflow file for this run

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 }}