|
| 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 }} |
0 commit comments