Skip to content

Commit d88a996

Browse files
authored
ci: add release-auto.yml, automating the release-cutting ceremony (#38)
ci: add release-auto.yml, automating the release-cutting ceremony -- mirrors RustyNES's release-auto.yml pattern (fires when CI completes successfully on main, invokes release.yml via workflow_call) adapted to RustySNES's own conventions: the trigger is CHANGELOG.md's own structure (empty [Unreleased] immediately followed by a real ## [X.Y.Z] heading) rather than a Cargo.toml version bump, and it creates a real annotated tag sourced directly from the CHANGELOG section (docs/adr/0007's tag-body-is-the-release-note convention). A real correctness bug (empty-Unreleased detection too narrow, only checked bullet lines not any non-blank content) found by Copilot review and fixed. Verified idempotent against multiple CHANGELOG states. Directly addresses this session's own recurring manual-release-ceremony bottleneck. CI green, human-reviewed.
1 parent 32981a6 commit d88a996

4 files changed

Lines changed: 219 additions & 3 deletions

File tree

‎.github/workflows/release-auto.yml‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
name: Auto Release
2+
3+
# Publishes an annotated git tag + GitHub Release automatically once a version's CHANGELOG
4+
# section has been closed out and merged to `main`, and CI has gone green on that commit. Mirrors
5+
# RustyNES's automated-release pattern (`release-auto.yml`) adapted to RustySNES's own
6+
# conventions rather than copied wholesale:
7+
#
8+
# - RustySNES's crate `Cargo.toml` versions stay pinned at 0.1.0 (nothing here is published to
9+
# crates.io; versioning lives entirely in the git tag), so this can't key off a Cargo.toml
10+
# version bump the way RustyNES does. Instead the trigger signal is CHANGELOG.md's own
11+
# structure: an EMPTY `## [Unreleased]` section immediately followed by a real
12+
# `## [X.Y.Z] "Name" - date` heading means that version was just closed out (the
13+
# release-closeout PR ceremony this project already uses — e.g. PR #31 for v0.4.0, PR #35 for
14+
# v0.5.0) and is ready to tag. A non-empty `[Unreleased]` means there's nothing to release yet.
15+
# - Release notes ARE the CHANGELOG section itself (`docs/adr/0007`'s tag-body-is-the-
16+
# release-note convention) -- no separate maintainer-authored notes file to keep in sync;
17+
# every PR this project lands already writes its CHANGELOG entry at release-note quality.
18+
# - Creates a real ANNOTATED tag (`git tag -a vX.Y.Z -F <notes>`), not just the lightweight tag
19+
# `gh release create` would imply on its own, preserving the same annotated-tag convention
20+
# every prior release (v0.1.0-v0.4.0, hand-cut) used -- `git show vX.Y.Z` keeps working
21+
# identically for a bot-cut release as for a hand-cut one.
22+
#
23+
# Flow: PR merged to main (CHANGELOG `[Unreleased]` -> `[X.Y.Z]`, the release-closeout PR) -> CI
24+
# runs on main and goes green -> this workflow fires (workflow_run: CI completed/success on
25+
# main) -> if no `vX.Y.Z` tag exists yet for the closed-out version, it creates the tag +
26+
# GitHub Release, then invokes release.yml (workflow_call, since a bot-pushed tag doesn't trigger
27+
# `on: push: tags`) to build + attach the platform binaries + checksums.
28+
#
29+
# Idempotent: if the version's tag already exists, this is a clean no-op on every `main` build.
30+
31+
on:
32+
workflow_run:
33+
workflows: ["CI"]
34+
types: [completed]
35+
branches: [main]
36+
37+
permissions:
38+
contents: write
39+
40+
concurrency:
41+
group: auto-release
42+
cancel-in-progress: false
43+
44+
jobs:
45+
prepare:
46+
name: Prepare release (notes + tag)
47+
# Only act when CI actually SUCCEEDED on a push to main (not PRs / forks).
48+
if: >
49+
github.event.workflow_run.conclusion == 'success' &&
50+
github.event.workflow_run.event == 'push'
51+
runs-on: ubuntu-latest
52+
outputs:
53+
should_release: ${{ steps.decide.outputs.should_release }}
54+
tag: ${{ steps.decide.outputs.tag }}
55+
steps:
56+
- uses: actions/checkout@v7
57+
with:
58+
# Build the release from the exact commit CI went green on. Full history so the
59+
# annotated-tag creation and the `git ls-remote` existence check both have what they
60+
# need (a shallow clone's tag/ref visibility isn't reliable here).
61+
ref: ${{ github.event.workflow_run.head_sha }}
62+
fetch-depth: 0
63+
64+
- name: Decide whether a closed-out version needs releasing
65+
id: decide
66+
shell: bash
67+
run: |
68+
set -euo pipefail
69+
# ANY non-blank content under [Unreleased] (not just a "- " bullet -- a stray heading
70+
# like a bare "### Added" with no bullets under it yet is still content, not an
71+
# all-clear to release) means the next version hasn't been closed out yet -- nothing
72+
# to release. Exit status of the awk itself carries the answer.
73+
if awk '
74+
/^## \[Unreleased\]/ { f=1; next }
75+
f && /^## \[/ { exit }
76+
f && NF { found=1 }
77+
END { exit !found }
78+
' CHANGELOG.md; then
79+
echo "[Unreleased] still has content -- no version to release yet."
80+
echo "should_release=false" >> "$GITHUB_OUTPUT"
81+
exit 0
82+
fi
83+
84+
header="$(awk '/^## \[Unreleased\]/{f=1; next} f && /^## \[/{print; exit}' CHANGELOG.md)"
85+
version="$(printf '%s' "$header" | sed -nE 's/^## \[([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/p')"
86+
if [ -z "$version" ]; then
87+
echo "No closed-out '## [X.Y.Z]' section found after [Unreleased] -- nothing to release."
88+
echo "should_release=false" >> "$GITHUB_OUTPUT"
89+
exit 0
90+
fi
91+
92+
tag="v${version}"
93+
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
94+
echo "version=${version}" >> "$GITHUB_OUTPUT"
95+
echo "header=${header}" >> "$GITHUB_OUTPUT"
96+
if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
97+
echo "Tag ${tag} already exists -- nothing to release."
98+
echo "should_release=false" >> "$GITHUB_OUTPUT"
99+
else
100+
echo "Version ${version} has no ${tag} tag yet -- will release."
101+
echo "should_release=true" >> "$GITHUB_OUTPUT"
102+
fi
103+
104+
- name: Resolve release title + notes from the CHANGELOG section
105+
if: steps.decide.outputs.should_release == 'true'
106+
id: notes
107+
shell: bash
108+
run: |
109+
set -euo pipefail
110+
version="${{ steps.decide.outputs.version }}"
111+
header="${{ steps.decide.outputs.header }}"
112+
body_file="$(mktemp)"
113+
114+
# The full "## [X.Y.Z] ..." section body, up to (not including) the next "## [" heading.
115+
awk -v ver="$version" '
116+
$0 ~ ("^## \\[" ver "\\]") { f=1; next }
117+
f && /^## \[/ { exit }
118+
f { print }
119+
' CHANGELOG.md > "$body_file"
120+
121+
# Trim leading/trailing blank lines: drop leading blanks, reverse, drop what are now
122+
# the leading blanks (the original trailing ones), reverse back. `tac` is coreutils,
123+
# present on the ubuntu runner.
124+
trimmed="$(mktemp)"
125+
awk 'NF{p=1} p' "$body_file" | tac | awk 'NF{p=1} p' | tac > "$trimmed"
126+
mv "$trimmed" "$body_file"
127+
128+
if [ ! -s "$body_file" ]; then
129+
echo "::error::CHANGELOG section for ${version} is empty -- nothing to put in the tag/release."
130+
exit 1
131+
fi
132+
133+
# Title matches every prior hand-cut release's exact format, e.g. `v0.4.0 "Completion"`
134+
# (name="" e.g. `## [0.4.0] "Completion" - 2026-07-08` -> quoted theme -> `v0.4.0 "Completion"`).
135+
name="$(printf '%s' "$header" | sed -nE 's/^## \[[0-9]+\.[0-9]+\.[0-9]+\][[:space:]]*("[^"]*").*/\1/p')"
136+
if [ -n "$name" ]; then
137+
title="v${version} ${name}"
138+
else
139+
title="v${version}"
140+
fi
141+
142+
echo "body_file=${body_file}" >> "$GITHUB_OUTPUT"
143+
echo "title=${title}" >> "$GITHUB_OUTPUT"
144+
echo "Resolved title: ${title}"
145+
146+
- name: Create the annotated tag + push it
147+
if: steps.decide.outputs.should_release == 'true'
148+
env:
149+
GH_TOKEN: ${{ github.token }}
150+
shell: bash
151+
run: |
152+
set -euo pipefail
153+
git config user.name "github-actions[bot]"
154+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
155+
git tag -a "${{ steps.decide.outputs.tag }}" \
156+
-F "${{ steps.notes.outputs.body_file }}" \
157+
"${{ github.event.workflow_run.head_sha }}"
158+
git push origin "${{ steps.decide.outputs.tag }}"
159+
160+
- name: Create the GitHub Release
161+
if: steps.decide.outputs.should_release == 'true'
162+
env:
163+
GH_TOKEN: ${{ github.token }}
164+
shell: bash
165+
run: |
166+
set -euo pipefail
167+
gh release create "${{ steps.decide.outputs.tag }}" \
168+
--title "${{ steps.notes.outputs.title }}" \
169+
--notes-file "${{ steps.notes.outputs.body_file }}" \
170+
--latest
171+
172+
build:
173+
name: Build + attach artifacts
174+
needs: prepare
175+
if: needs.prepare.outputs.should_release == 'true'
176+
permissions:
177+
contents: write
178+
# Reuse the Release build matrix; it attaches the platform binaries + checksums to the
179+
# release created above and never overwrites the body.
180+
uses: ./.github/workflows/release.yml
181+
with:
182+
tag: ${{ needs.prepare.outputs.tag }}

‎.github/workflows/release.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ on:
1010
tag:
1111
description: "Existing tag to build and attach artifacts to (e.g. v0.2.0)"
1212
required: true
13+
# Invoked directly by release-auto.yml right after it creates a tag: a tag pushed by the
14+
# built-in GITHUB_TOKEN does NOT trigger `on: push: tags` (GitHub's recursion guard), so
15+
# release-auto.yml calls this workflow rather than relying on that push to fire it.
16+
workflow_call:
17+
inputs:
18+
tag:
19+
description: "Existing tag to build and attach artifacts to (e.g. v0.5.0)"
20+
required: true
21+
type: string
1322
# A tag is normally pushed once, but this guards a mistaken re-push (delete+recreate the same
1423
# tag) from queuing a redundant duplicate build behind the one already running. Manual dispatches
1524
# for different tags get independent groups (keyed on the resolved tag, not the ref) so backfilling

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Added
1313

14+
- **Automated release-cutting (`release-auto.yml`) — `v0.6.0` "Shippable" work, pulled
15+
forward.** Mirrors RustyNES's `release-auto.yml` pattern (fires when `CI` completes
16+
successfully on `main`, invokes `release.yml` via `workflow_call` since a bot-pushed tag
17+
doesn't trigger `on: push: tags`) adapted to this project's own conventions, not copied:
18+
RustySNES's crate versions stay pinned at `0.1.0` (nothing publishes to crates.io), so the
19+
trigger is `CHANGELOG.md`'s own structure — an empty `[Unreleased]` immediately followed by a
20+
real `## [X.Y.Z] "Name" - date` heading means that version was just closed out and is ready to
21+
tag — rather than a Cargo.toml version bump. Creates a real annotated tag
22+
(`git tag -a -F <notes>`) sourced directly from the CHANGELOG section (`docs/adr/0007`'s
23+
tag-body-is-the-release-note convention), not a separate maintainer-authored notes file.
24+
Idempotent: a no-op once the version's tag already exists. `release.yml` gained a matching
25+
`workflow_call` trigger alongside its existing `push`/`workflow_dispatch` ones.
26+
1427
- **`ci.yml`'s `lint` job now gates on `cargo doc` too — `v0.6.0` "Shippable" work, pulled
1528
forward.** The doc-warnings build was previously reserved for the tag-only `full-test` job, so
1629
a broken intra-doc link or rustdoc-specific warning (neither caught by clippy's own lints)

‎to-dos/VERSION-PLAN.md‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,21 @@ isn't about emulation accuracy.
273273
(real-time headroom is fine at ~5.1×, but the target itself isn't met yet — an honest
274274
baseline, not a claim of hitting it).
275275
Establishes the number every future optimization pass is measured against.
276-
- New docs still needed: a `docs/audit/` directory for dense investigation write-ups (RustyNES's
277-
pattern for campaigns like the SPC7110 boot-crash trace this project still owes, `docs/cart.md`
278-
§SPC7110).
276+
- [x] `docs/audit/` — a decision-rationale / open-investigation directory (RustyNES's pattern),
277+
seeded with the full SPC7110 boot-crash trail (`docs/audit/spc7110-boot-crash-2026-07-08.md`).
278+
- [x] Automated release-cutting (`.github/workflows/release-auto.yml`): mirrors RustyNES's
279+
`release-auto.yml` pattern (fires on the `CI` workflow completing successfully on `main`,
280+
invokes `release.yml` via `workflow_call` since a bot-pushed tag doesn't trigger
281+
`on: push: tags`) adapted to this project's own conventions rather than copied — since
282+
RustySNES's crate `Cargo.toml` versions stay pinned at `0.1.0` (nothing here publishes to
283+
crates.io), the trigger signal is `CHANGELOG.md`'s own structure (an empty `[Unreleased]`
284+
immediately followed by a real `## [X.Y.Z] "Name" - date` heading means that version was
285+
just closed out and is ready to tag) rather than a Cargo.toml version bump, and it creates
286+
a real ANNOTATED tag (`git tag -a -F <notes>`) sourced directly from the CHANGELOG section
287+
(`docs/adr/0007`'s tag-body-is-the-release-note convention), not a separate
288+
maintainer-authored notes file. Idempotent (a no-op once the version's tag already exists).
289+
Directly closes the recurring manual-release-ceremony bottleneck this ladder's own v0.5.0
290+
cut ran into.
279291
- [x] ADR backfill for cross-cutting decisions made along this ladder. Save-state format was
280292
already covered (`docs/adr/0006`); the three real gaps are now filled: `docs/adr/0007`
281293
(the versioning/release-process adoption itself — this document + the tag-body-is-the-

0 commit comments

Comments
 (0)