Skip to content

Auto Release

Auto Release #316

Workflow file for this run

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