From bd500ee021a0fc814b8bf999107e09fc75100a9a Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 18:52:01 -0400 Subject: [PATCH 01/10] docs: define how release versions are derived (#54) --- AGENTS.md | 6 ++- docs/release-versioning.md | 96 ++++++++++++++++++++++++++++++++++++++ docs/repo-standard.md | 1 + 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 docs/release-versioning.md diff --git a/AGENTS.md b/AGENTS.md index 2191fe9..eacc1f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,8 +83,10 @@ substitute. its issue closed by hand. 4. **Commit with Conventional Commits** (`feat:`, `fix:`, `docs:`, `spec:`, `chore:`), optionally scoped — `fix(release):`. The release pipeline derives - version bumps from these, so the type is not cosmetic. Mark breaking changes - with `!` (`feat!:`) or a `BREAKING CHANGE:` footer. + version bumps from the commits since the last tag (see + [docs/release-versioning.md](docs/release-versioning.md)), so the type is not + cosmetic. Mark breaking changes with `!` (`feat!:`) or a `BREAKING CHANGE:` + footer; a `Release-As: vX.Y.Z` footer sets the version exactly. 5. **Open the PR with a closing keyword** so the issue auto-closes on merge: `Closes #123` in the body. Fill in `.github/PULL_REQUEST_TEMPLATE.md` honestly — only tick test boxes for suites actually run, and paste the evidence. diff --git a/docs/release-versioning.md b/docs/release-versioning.md new file mode 100644 index 0000000..fdee9d4 --- /dev/null +++ b/docs/release-versioning.md @@ -0,0 +1,96 @@ +# Release Versioning + +Every push to `main` cuts a release. The release workflow (`.github/workflows/release.yml`, `version` job) derives the next version from the commit messages since the latest release tag. This document defines the rule. + +## The Rule + +The workflow scans every commit in `..HEAD`. It finds the bump for each commit, and the highest bump wins. + +| Row | The commits in the range include | Below 1.0.0 | 1.0.0 and above | +|---|---|---|---| +| `release-as` | a `Release-As: vX.Y.Z` line | exactly that version | exactly that version | +| `breaking` | a subject `type!:` / `type(scope)!:`, or a line starting `BREAKING CHANGE:` / `BREAKING-CHANGE:` | minor | major | +| `feature` | a subject `feat:` / `feat(scope):` | minor | minor | +| `other` | anything else (`fix`, `docs`, `chore`, `test`, `spec`, `ci`, non-conventional) | patch | patch | +| `no-tag` | there is no release tag | start from `v0.0.0`, then apply the rows above | — | + +- A minor bump resets the patch number. A major bump resets the minor and patch numbers. +- The latest tag is the highest version among tags that match `vX.Y.Z` exactly. Other tags are ignored. Versions are compared as numbers, so `v0.10.0` is greater than `v0.9.0`. +- If the range is empty (HEAD is already tagged), the release is still a patch. + +## What Counts + +| Signal | Where it must be | Example | +|---|---|---| +| Commit type (`feat`, `fix!`, …) | subject line (first line) only | `feat(ts): add flag` | +| `BREAKING CHANGE:` / `BREAKING-CHANGE:` | start of any line in the message | footer in the squash body | +| `Release-As:` | start of any line in the message | footer in the squash body | + +Only the subject decides the commit type because GitHub's default squash body lists the branch commits as `* …` bullets. A `* feat: x` bullet under a `fix:` subject is therefore history, not a feature, and it does not count. A breaking-change footer is a deliberate statement, so it counts wherever it starts a line. + +## Why the Whole Range + +One release can cover several merges. v0.2.25 covers #56 and #58, because the release run for `a654ff8` (#56) never reached the version job. If the workflow looked only at the last commit, a `feat` or breaking change in an earlier merge would be lost. + +## `Release-As` + +A `Release-As` footer sets the version exactly and overrides the computed bump. + +| Item | Rule | +|---|---| +| Syntax | `Release-As: vX.Y.Z`. The key is case-insensitive, the `v` is optional, and whitespace around the value is ignored. | +| Valid value | three numeric parts, strictly greater than the latest tag | +| Malformed value (for example, `Release-As: 0.3`) | the release job fails | +| Value not greater than the latest tag | the release job fails | +| Two different values in the range | the release job fails | +| The same value more than once | accepted | +| Scope | applies only to the release whose range contains it; the next release computes normally | + +If the job fails, no tag is created. To recover, push a new commit (for example, a revert or a commit with a corrected `Release-As` footer). + +## Release Notes + +If the range contains breaking commits, the workflow generates a **⚠️ Breaking changes** section. The section lists the subject of each breaking commit and its `BREAKING CHANGE:` text. It is passed to `gh release create --notes-file … --generate-notes`, which prepends it to GitHub's generated notes. If there are no breaking commits, the notes are GitHub's generated notes only. You do not have to edit the notes by hand. + +## Guidance for Whoever Squash-Merges + +| Part | What goes there | +|---|---| +| Squash title | the Conventional Commit subject, which sets the type: `fix(release): … (#54)`, `feat!: … (#47)` | +| Squash body | footers: `BREAKING CHANGE: `, `Release-As: vX.Y.Z` | + +Check the title before you merge. GitHub pre-fills it from the PR title, and a wrong type gives a wrong version. + +## Worked Examples + +| Change | Shipped as | With this rule | +|---|---|---| +| #47 `feat!: dockerd-parity listening socket` | v0.2.22 (patch) | v0.3.0 (`breaking`, below 1.0) | +| #53 `fix!: deny percent-encoded request paths` | v0.2.26 (patch, notes written by hand) | v0.3.0 (`breaking`), with a generated breaking-changes section | +| #56 + #58 (both `fix`) | v0.2.25 | v0.2.25 (`other`) | +| #54 (this change), squash body `Release-As: v0.3.0` | — | v0.3.0 (`release-as`). It corrects the version for the breaking changes in #47 and #53. | + +Other examples: `fix!: x` on v1.4.2 gives v2.0.0. `feat: x` on v1.4.2 gives v1.5.0. `fix: x` with no tag gives v0.0.1. + +## What Is Unchanged + +- Every push to `main` still releases. +- Each release is still created as a draft prerelease and published automatically by the `publish-release` job once every artifact has uploaded. +- Pre-release tags, CHANGELOG files, and the release concurrency setting are out of scope. + +## Rejected Alternatives + +| Alternative | Why it was rejected | +|---|---| +| release-please / semantic-release | Heavier. They take over changelogs and release PRs, which this repo does not use. | +| Correct only AGENTS.md (say that every release is a patch) | Breaking changes would still ship as patches with hand-written notes, as #47 and #53 did. | +| `feat` gives a patch below 1.0 | Then a feature and a fix would look the same. Below 1.0, minor is the only signal left for "new behaviour". | + +## Implementation and Tests + +| File | Purpose | +|---|---| +| `scripts/release-version.sh` | Reads NUL-separated commit messages on stdin. Takes the latest tag (or empty) as its argument. Prints `bump=…` and `tag=vX.Y.Z`. | +| `scripts/release-notes.sh` | Reads the same input and prints the breaking-changes section, or nothing. | +| `scripts/release-version_test.sh` | One test case per row of the rule table, plus edge cases. | +| `make test-release` | Runs the tests. The tests also run in CI (`release-scripts` job). | diff --git a/docs/repo-standard.md b/docs/repo-standard.md index 3732855..08394ba 100644 --- a/docs/repo-standard.md +++ b/docs/repo-standard.md @@ -73,6 +73,7 @@ Build verification steps must be documented in the project README or a dedicated ## Versioning - SemVer (`vMAJOR.MINOR.PATCH`) +- The bump is derived from Conventional Commit types since the last tag; see [release-versioning.md](release-versioning.md) for this repo's rule - CHANGELOG per Keep a Changelog - Pre-release tags (e.g. `v1.0.0-rc.1`) publish with `--prerelease` From bc06e1e5578a13282cccb3259e743597ea2187e4 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 18:53:54 -0400 Subject: [PATCH 02/10] docs: newest Release-As decides, so a bad footer is recoverable (#54) --- docs/release-versioning.md | 19 +++++++++---------- docs/repo-standard.md | 2 +- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/release-versioning.md b/docs/release-versioning.md index fdee9d4..04ec44b 100644 --- a/docs/release-versioning.md +++ b/docs/release-versioning.md @@ -8,11 +8,11 @@ The workflow scans every commit in `..HEAD`. It finds the bump for e | Row | The commits in the range include | Below 1.0.0 | 1.0.0 and above | |---|---|---|---| -| `release-as` | a `Release-As: vX.Y.Z` line | exactly that version | exactly that version | +| `release-as` | a `Release-As: vX.Y.Z` line (the newest one decides) | exactly that version | exactly that version | | `breaking` | a subject `type!:` / `type(scope)!:`, or a line starting `BREAKING CHANGE:` / `BREAKING-CHANGE:` | minor | major | | `feature` | a subject `feat:` / `feat(scope):` | minor | minor | | `other` | anything else (`fix`, `docs`, `chore`, `test`, `spec`, `ci`, non-conventional) | patch | patch | -| `no-tag` | there is no release tag | start from `v0.0.0`, then apply the rows above | — | +| `no-tag` | there is no tag matching `vX.Y.Z` | start from `v0.0.0`, then apply the rows above | — | - A minor bump resets the patch number. A major bump resets the minor and patch numbers. - The latest tag is the highest version among tags that match `vX.Y.Z` exactly. Other tags are ignored. Versions are compared as numbers, so `v0.10.0` is greater than `v0.9.0`. @@ -38,19 +38,18 @@ A `Release-As` footer sets the version exactly and overrides the computed bump. | Item | Rule | |---|---| -| Syntax | `Release-As: vX.Y.Z`. The key is case-insensitive, the `v` is optional, and whitespace around the value is ignored. | +| Syntax | `Release-As: vX.Y.Z`. The key is case-insensitive, the `v` is optional (`0.3.0` and `v0.3.0` are the same value), and whitespace around the value is ignored. | +| Several in the range | only the newest one decides; older ones are ignored | | Valid value | three numeric parts, strictly greater than the latest tag | -| Malformed value (for example, `Release-As: 0.3`) | the release job fails | -| Value not greater than the latest tag | the release job fails | -| Two different values in the range | the release job fails | -| The same value more than once | accepted | +| Newest value malformed (for example, `Release-As: 0.3`) | the `version` job's bump step fails | +| Newest value not greater than the latest tag | the `version` job's bump step fails | | Scope | applies only to the release whose range contains it; the next release computes normally | -If the job fails, no tag is created. To recover, push a new commit (for example, a revert or a commit with a corrected `Release-As` footer). +The bump step runs before the tag is created, so when it fails no tag or release exists. To recover, merge a commit whose message carries a corrected `Release-As` footer: it becomes the newest one and decides. (Once the bump step has passed, a later failure, for example in `gh release create` or an artifact job, leaves the tag and draft behind; that is unchanged by this rule.) ## Release Notes -If the range contains breaking commits, the workflow generates a **⚠️ Breaking changes** section. The section lists the subject of each breaking commit and its `BREAKING CHANGE:` text. It is passed to `gh release create --notes-file … --generate-notes`, which prepends it to GitHub's generated notes. If there are no breaking commits, the notes are GitHub's generated notes only. You do not have to edit the notes by hand. +If the range contains breaking commits, the workflow generates a **⚠️ Breaking changes** section. The section lists the subject of each breaking commit and its `BREAKING CHANGE:` text. It is passed to `gh release create --notes-file … --generate-notes`, which prepends it to GitHub's generated notes. If there are no breaking commits, the notes are GitHub's generated notes only. You no longer have to add the breaking-change section by hand; impact or migration detail beyond the footer text still needs a human. ## Guidance for Whoever Squash-Merges @@ -59,7 +58,7 @@ If the range contains breaking commits, the workflow generates a **⚠️ Breaki | Squash title | the Conventional Commit subject, which sets the type: `fix(release): … (#54)`, `feat!: … (#47)` | | Squash body | footers: `BREAKING CHANGE: `, `Release-As: vX.Y.Z` | -Check the title before you merge. GitHub pre-fills it from the PR title, and a wrong type gives a wrong version. +Check the pre-filled title and body before you merge: a wrong type gives a wrong version. ## Worked Examples diff --git a/docs/repo-standard.md b/docs/repo-standard.md index 08394ba..20a527f 100644 --- a/docs/repo-standard.md +++ b/docs/repo-standard.md @@ -73,7 +73,7 @@ Build verification steps must be documented in the project README or a dedicated ## Versioning - SemVer (`vMAJOR.MINOR.PATCH`) -- The bump is derived from Conventional Commit types since the last tag; see [release-versioning.md](release-versioning.md) for this repo's rule +- In this repo, the bump is derived from Conventional Commit types since the last tag; see [release-versioning.md](release-versioning.md) - CHANGELOG per Keep a Changelog - Pre-release tags (e.g. `v1.0.0-rc.1`) publish with `--prerelease` From 8bfac4d69735d2416dcd7541edbe15a4c64924a5 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 18:55:18 -0400 Subject: [PATCH 03/10] test: pin the release version rules (#54) --- .github/workflows/ci.yml | 6 + Makefile | 7 +- scripts/release-version_test.sh | 194 ++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 1 deletion(-) create mode 100755 scripts/release-version_test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a4638d..92a8d69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,12 @@ jobs: - run: make test-ts - run: make build-ts + release-scripts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: make test-release + integration: runs-on: ubuntu-latest needs: [go] diff --git a/Makefile b/Makefile index d98aa56..6dde96b 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ LISTENER_SPEC ?= spec/listener.qnt ROUTER_SPEC := spec/router.qnt BACKEND ?= -.PHONY: build clean test lint verify typecheck test-spec validate ci-verify release-verify +.PHONY: build clean test lint verify typecheck test-spec validate ci-verify release-verify test-release .PHONY: build-go test-go lint-go build-rs test-rs build-ts test-ts # ─── Go ────────────────────────────────────────────── @@ -142,6 +142,11 @@ test-integration-sock-ts: validate: typecheck verify lint-go test-go +# ─── Release scripts ───────────────────────────────── + +test-release: + bash scripts/release-version_test.sh + # ─── Reproducible build verification ───────────────── .PHONY: verify-reproducible-go verify-reproducible-rs verify-reproducible-ts verify-reproducible-all diff --git a/scripts/release-version_test.sh b/scripts/release-version_test.sh new file mode 100755 index 0000000..dcbbe7c --- /dev/null +++ b/scripts/release-version_test.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# Tests for release-version.sh and release-notes.sh. The rules are defined in +# docs/release-versioning.md; case names match the rows of its rule table. +# Plain bash, portable to macOS /bin/bash 3.2. +set -u + +DIR=$(cd "$(dirname "$0")" && pwd) +VERSION_SCRIPT="$DIR/release-version.sh" +NOTES_SCRIPT="$DIR/release-notes.sh" + +TMP=$(mktemp -d "${TMPDIR:-/tmp}/release-version-test.XXXXXX") +trap 'rm -rf "$TMP"' EXIT + +passed=0 +failed=0 + +pass() { + passed=$((passed + 1)) + echo "PASS: $1" +} + +fail() { + failed=$((failed + 1)) + echo "FAIL: $1 ($2)" +} + +# feed MSG... — write each message NUL-terminated, newest first; no args = empty range. +feed() { + if [ $# -gt 0 ]; then + printf '%s\0' "$@" + fi +} + +# run SCRIPT ARG MSG... — runs SCRIPT with ARG, stdin from MSG...; sets rc, out, err. +run() { + local script=$1 arg=$2 + shift 2 + feed "$@" >"$TMP/in" + bash "$script" "$arg" <"$TMP/in" >"$TMP/out" 2>"$TMP/err" + rc=$? + out=$(cat "$TMP/out") + err=$(cat "$TMP/err") +} + +# expect NAME LATEST BUMP TAG MSG... — script exits 0 and prints exactly bump=BUMP and tag=TAG. +expect() { + local name=$1 latest=$2 bump=$3 tag=$4 + shift 4 + if [ ! -f "$VERSION_SCRIPT" ]; then + fail "$name" "want bump=$bump tag=$tag, got script not found: $VERSION_SCRIPT" + return + fi + run "$VERSION_SCRIPT" "$latest" "$@" + local want + want=$(printf 'bump=%s\ntag=%s' "$bump" "$tag") + if [ "$rc" -ne 0 ]; then + fail "$name" "want bump=$bump tag=$tag, got exit $rc: $err" + elif [ "$out" != "$want" ]; then + fail "$name" "want $(echo "$want" | tr '\n' ' '), got $(echo "$out" | tr '\n' ' ')" + else + pass "$name" + fi +} + +# expect_error NAME LATEST MSG... — script exits non-zero and explains why on stderr. +expect_error() { + local name=$1 latest=$2 + shift 2 + if [ ! -f "$VERSION_SCRIPT" ]; then + fail "$name" "want non-zero exit with stderr message, got script not found: $VERSION_SCRIPT" + return + fi + run "$VERSION_SCRIPT" "$latest" "$@" + if [ "$rc" -eq 0 ]; then + fail "$name" "want non-zero exit, got exit 0: $(echo "$out" | tr '\n' ' ')" + elif [ -z "$err" ]; then + fail "$name" "want a message on stderr, got exit $rc with empty stderr" + else + pass "$name" + fi +} + +# expect_notes NAME NEEDLE... -- MSG... — notes exit 0 and contain every NEEDLE. +expect_notes() { + local name=$1 + shift + local needles=() + while [ $# -gt 0 ] && [ "$1" != "--" ]; do + needles[${#needles[@]}]=$1 + shift + done + shift + if [ ! -f "$NOTES_SCRIPT" ]; then + fail "$name" "want breaking-changes section, got script not found: $NOTES_SCRIPT" + return + fi + run "$NOTES_SCRIPT" "" "$@" + if [ "$rc" -ne 0 ]; then + fail "$name" "want exit 0, got exit $rc: $err" + return + fi + local n + for n in "${needles[@]}"; do + case "$out" in + *"$n"*) ;; + *) + fail "$name" "want output containing '$n', got '$out'" + return + ;; + esac + done + pass "$name" +} + +# expect_no_notes NAME MSG... — notes exit 0 and print nothing. +expect_no_notes() { + local name=$1 + shift + if [ ! -f "$NOTES_SCRIPT" ]; then + fail "$name" "want empty output, got script not found: $NOTES_SCRIPT" + return + fi + run "$NOTES_SCRIPT" "" "$@" + if [ "$rc" -ne 0 ]; then + fail "$name" "want exit 0, got exit $rc: $err" + elif [ -n "$out" ]; then + fail "$name" "want empty output, got '$out'" + else + pass "$name" + fi +} + +NL=$'\n' + +# ─── other ─────────────────────────────────────────── +expect other v0.2.25 patch v0.2.26 "fix: x" +expect other/non-conventional v0.2.25 patch v0.2.26 "Update README" +expect other/docs-chore-test v0.2.25 patch v0.2.26 "docs: a" "chore: b" "test: c" "spec: d" "ci: e" +expect other/empty-range v0.2.25 patch v0.2.26 + +# ─── feature ───────────────────────────────────────── +expect feature v0.2.25 minor v0.3.0 "feat: x" +expect feature/scoped v0.2.25 minor v0.3.0 "feat(ts): x" +expect feature/body-bullet-under-fix v0.2.25 patch v0.2.26 "fix: x (#1)${NL}${NL}* feat: x${NL}* fix: y" +expect feature/mid-line v0.2.25 patch v0.2.26 "fix: handle feat: prefix" +expect feature/highest-wins v0.2.25 minor v0.3.0 "fix: a" "feat: b" "docs: c" +expect feature/above-1.0 v1.4.2 minor v1.5.0 "feat: x" + +# ─── breaking ──────────────────────────────────────── +expect breaking v0.2.25 minor v0.3.0 "fix!: x" +expect breaking/scoped-feat v0.2.25 minor v0.3.0 "feat(rs)!: x" +expect breaking/footer-under-fix v0.2.25 minor v0.3.0 "fix: x${NL}${NL}BREAKING CHANGE: y is gone" +expect breaking/hyphen-footer v0.2.25 minor v0.3.0 "fix: x${NL}${NL}BREAKING-CHANGE: y is gone" +expect breaking/footer-mid-line v0.2.25 patch v0.2.26 "fix: x${NL}${NL}This is not a BREAKING CHANGE: y" +expect breaking/above-1.0 v1.4.2 major v2.0.0 "fix!: x" +expect breaking/highest-wins v1.4.2 major v2.0.0 "fix: a" "fix!: b" "feat: c" + +# ─── no-tag ────────────────────────────────────────── +expect no-tag "" patch v0.0.1 "fix: x" +expect no-tag/feature "" minor v0.1.0 "feat: x" + +# ─── release-as ────────────────────────────────────── +expect release-as v0.2.26 release-as v0.3.0 "fix: x${NL}${NL}Release-As: v0.3.0" +expect release-as/lowercase-no-v v0.2.26 release-as v0.3.0 "fix: x${NL}${NL}release-as: 0.3.0" +expect release-as/whitespace v0.2.26 release-as v0.3.0 "fix: x${NL}${NL}Release-As: v0.3.0 " +expect release-as/overrides-breaking v0.2.26 release-as v0.3.0 "fix!: x${NL}${NL}Release-As: v0.3.0" +expect release-as/numeric-compare v0.9.3 release-as v0.10.0 "fix: x${NL}${NL}Release-As: v0.10.0" +expect_error release-as/not-greater v0.2.26 "fix: x${NL}${NL}Release-As: v0.2.26" +expect_error release-as/malformed v0.2.26 "fix: x${NL}${NL}Release-As: 0.3" +expect release-as/newest-wins v0.2.26 release-as v0.4.0 \ + "fix: b${NL}${NL}Release-As: v0.4.0" \ + "fix: a${NL}${NL}Release-As: v0.3.0" +expect release-as/newest-wins-in-message v0.2.26 release-as v0.4.0 \ + "fix: a${NL}${NL}Release-As: v0.3.0${NL}Release-As: v0.4.0" +expect release-as/recovery v0.2.26 release-as v0.3.0 \ + "fix: b${NL}${NL}Release-As: v0.3.0" \ + "fix: a${NL}${NL}Release-As: 0.3" +expect_error release-as/newest-malformed v0.2.26 \ + "fix: b${NL}${NL}Release-As: 0.3" \ + "fix: a${NL}${NL}Release-As: v0.3.0" + +# ─── notes ─────────────────────────────────────────── +expect_notes notes/breaking \ + "Breaking changes" "fix: x" "y is gone" -- \ + "docs: a" "fix: x${NL}${NL}BREAKING CHANGE: y is gone" "feat: b" +expect_notes notes/breaking-subject \ + "Breaking changes" "feat(rs)!: drop z" -- \ + "feat(rs)!: drop z" +expect_no_notes notes/none "fix: a" "feat: b${NL}${NL}* feat!: bullet only" +expect_no_notes notes/empty-range + +echo +echo "$passed passed, $failed failed" +[ "$failed" -eq 0 ] From 0cd1ad9ce7cc1f948183927ebfaef1be484140fd Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 19:07:25 -0400 Subject: [PATCH 04/10] test: use git's real log framing and close review gaps (#54) --- scripts/release-version_test.sh | 109 ++++++++++++++++++++++++-------- 1 file changed, 83 insertions(+), 26 deletions(-) diff --git a/scripts/release-version_test.sh b/scripts/release-version_test.sh index dcbbe7c..4f0e9e4 100755 --- a/scripts/release-version_test.sh +++ b/scripts/release-version_test.sh @@ -24,33 +24,46 @@ fail() { echo "FAIL: $1 ($2)" } -# feed MSG... — write each message NUL-terminated, newest first; no args = empty range. +# feed MSG... — write messages in `git log -z --format=%B` framing, newest first: each +# record is the full message ending in "\n", followed by NUL (the last record too). +# No args = empty range (no bytes). feed() { if [ $# -gt 0 ]; then - printf '%s\0' "$@" + printf '%s\n\0' "$@" fi } -# run SCRIPT ARG MSG... — runs SCRIPT with ARG, stdin from MSG...; sets rc, out, err. +# unrunnable SCRIPT — prints why SCRIPT cannot be run, or nothing. Checked up front so a +# missing script never counts as the non-zero exit an error case is looking for. +unrunnable() { + if [ ! -f "$1" ]; then + echo "script not found: $1" + elif [ ! -x "$1" ]; then + echo "script not executable: $1" + fi +} + +# run SCRIPT ARG — runs SCRIPT ARG (directly, so the executable bit and shebang are +# exercised) with stdin from $TMP/in; sets rc, out, err. +# $(…) strips trailing newlines, so "exactly two lines" is checked on the content: +# out must equal "bump=…tag=…" with nothing before, between, or after. run() { - local script=$1 arg=$2 - shift 2 - feed "$@" >"$TMP/in" - bash "$script" "$arg" <"$TMP/in" >"$TMP/out" 2>"$TMP/err" + "$1" "$2" <"$TMP/in" >"$TMP/out" 2>"$TMP/err" rc=$? out=$(cat "$TMP/out") err=$(cat "$TMP/err") } -# expect NAME LATEST BUMP TAG MSG... — script exits 0 and prints exactly bump=BUMP and tag=TAG. -expect() { - local name=$1 latest=$2 bump=$3 tag=$4 - shift 4 - if [ ! -f "$VERSION_SCRIPT" ]; then - fail "$name" "want bump=$bump tag=$tag, got script not found: $VERSION_SCRIPT" +# check_version NAME LATEST BUMP TAG — on stdin $TMP/in, exits 0 and prints exactly +# bump=BUMP and tag=TAG. +check_version() { + local name=$1 latest=$2 bump=$3 tag=$4 why + why=$(unrunnable "$VERSION_SCRIPT") + if [ -n "$why" ]; then + fail "$name" "want bump=$bump tag=$tag, got $why" return fi - run "$VERSION_SCRIPT" "$latest" "$@" + run "$VERSION_SCRIPT" "$latest" local want want=$(printf 'bump=%s\ntag=%s' "$bump" "$tag") if [ "$rc" -ne 0 ]; then @@ -62,15 +75,25 @@ expect() { fi } +# expect NAME LATEST BUMP TAG MSG... — check_version on the fed messages. +expect() { + local name=$1 latest=$2 bump=$3 tag=$4 + shift 4 + feed "$@" >"$TMP/in" + check_version "$name" "$latest" "$bump" "$tag" +} + # expect_error NAME LATEST MSG... — script exits non-zero and explains why on stderr. expect_error() { - local name=$1 latest=$2 + local name=$1 latest=$2 why shift 2 - if [ ! -f "$VERSION_SCRIPT" ]; then - fail "$name" "want non-zero exit with stderr message, got script not found: $VERSION_SCRIPT" + why=$(unrunnable "$VERSION_SCRIPT") + if [ -n "$why" ]; then + fail "$name" "want non-zero exit with stderr message, got $why" return fi - run "$VERSION_SCRIPT" "$latest" "$@" + feed "$@" >"$TMP/in" + run "$VERSION_SCRIPT" "$latest" if [ "$rc" -eq 0 ]; then fail "$name" "want non-zero exit, got exit 0: $(echo "$out" | tr '\n' ' ')" elif [ -z "$err" ]; then @@ -82,7 +105,7 @@ expect_error() { # expect_notes NAME NEEDLE... -- MSG... — notes exit 0 and contain every NEEDLE. expect_notes() { - local name=$1 + local name=$1 why shift local needles=() while [ $# -gt 0 ] && [ "$1" != "--" ]; do @@ -90,11 +113,13 @@ expect_notes() { shift done shift - if [ ! -f "$NOTES_SCRIPT" ]; then - fail "$name" "want breaking-changes section, got script not found: $NOTES_SCRIPT" + why=$(unrunnable "$NOTES_SCRIPT") + if [ -n "$why" ]; then + fail "$name" "want breaking-changes section, got $why" return fi - run "$NOTES_SCRIPT" "" "$@" + feed "$@" >"$TMP/in" + run "$NOTES_SCRIPT" "" if [ "$rc" -ne 0 ]; then fail "$name" "want exit 0, got exit $rc: $err" return @@ -114,13 +139,15 @@ expect_notes() { # expect_no_notes NAME MSG... — notes exit 0 and print nothing. expect_no_notes() { - local name=$1 + local name=$1 why shift - if [ ! -f "$NOTES_SCRIPT" ]; then - fail "$name" "want empty output, got script not found: $NOTES_SCRIPT" + why=$(unrunnable "$NOTES_SCRIPT") + if [ -n "$why" ]; then + fail "$name" "want empty output, got $why" return fi - run "$NOTES_SCRIPT" "" "$@" + feed "$@" >"$TMP/in" + run "$NOTES_SCRIPT" "" if [ "$rc" -ne 0 ]; then fail "$name" "want exit 0, got exit $rc: $err" elif [ -n "$out" ]; then @@ -130,6 +157,12 @@ expect_no_notes() { fi } +# gitc ARGS... — git with a fixed identity and no signing or hooks, so it runs in CI. +gitc() { + git -c user.name=release-test -c user.email=release-test@example.com \ + -c commit.gpgsign=false -c core.hooksPath=/dev/null "$@" +} + NL=$'\n' # ─── other ─────────────────────────────────────────── @@ -143,6 +176,7 @@ expect feature v0.2.25 minor v0.3.0 "feat: x" expect feature/scoped v0.2.25 minor v0.3.0 "feat(ts): x" expect feature/body-bullet-under-fix v0.2.25 patch v0.2.26 "fix: x (#1)${NL}${NL}* feat: x${NL}* fix: y" expect feature/mid-line v0.2.25 patch v0.2.26 "fix: handle feat: prefix" +expect feature/body-line-not-subject v0.2.25 patch v0.2.26 "fix: x${NL}${NL}feat: y" expect feature/highest-wins v0.2.25 minor v0.3.0 "fix: a" "feat: b" "docs: c" expect feature/above-1.0 v1.4.2 minor v1.5.0 "feat: x" @@ -152,7 +186,9 @@ expect breaking/scoped-feat v0.2.25 minor v0.3.0 "feat(rs)!: x" expect breaking/footer-under-fix v0.2.25 minor v0.3.0 "fix: x${NL}${NL}BREAKING CHANGE: y is gone" expect breaking/hyphen-footer v0.2.25 minor v0.3.0 "fix: x${NL}${NL}BREAKING-CHANGE: y is gone" expect breaking/footer-mid-line v0.2.25 patch v0.2.26 "fix: x${NL}${NL}This is not a BREAKING CHANGE: y" +expect breaking/body-bang-not-subject v0.2.25 patch v0.2.26 "fix: x${NL}${NL}fix!: y" expect breaking/above-1.0 v1.4.2 major v2.0.0 "fix!: x" +expect breaking/feat-bang-major v1.4.2 major v2.0.0 "feat!: x" expect breaking/highest-wins v1.4.2 major v2.0.0 "fix: a" "fix!: b" "feat: c" # ─── no-tag ────────────────────────────────────────── @@ -166,7 +202,10 @@ expect release-as/whitespace v0.2.26 release-as v0.3.0 "fix: x${NL}${NL}Release- expect release-as/overrides-breaking v0.2.26 release-as v0.3.0 "fix!: x${NL}${NL}Release-As: v0.3.0" expect release-as/numeric-compare v0.9.3 release-as v0.10.0 "fix: x${NL}${NL}Release-As: v0.10.0" expect_error release-as/not-greater v0.2.26 "fix: x${NL}${NL}Release-As: v0.2.26" +expect_error release-as/below-latest v0.2.26 "fix: x${NL}${NL}Release-As: v0.2.9" expect_error release-as/malformed v0.2.26 "fix: x${NL}${NL}Release-As: 0.3" +expect_error release-as/trailing-text-rc v0.2.26 "fix: x${NL}${NL}Release-As: v0.3.0-rc1" +expect_error release-as/trailing-text-fourth-part v0.2.26 "fix: x${NL}${NL}Release-As: v0.3.0.1" expect release-as/newest-wins v0.2.26 release-as v0.4.0 \ "fix: b${NL}${NL}Release-As: v0.4.0" \ "fix: a${NL}${NL}Release-As: v0.3.0" @@ -179,6 +218,24 @@ expect_error release-as/newest-malformed v0.2.26 \ "fix: b${NL}${NL}Release-As: 0.3" \ "fix: a${NL}${NL}Release-As: v0.3.0" +# ─── input framing ─────────────────────────────────── +# The oldest record carries the feat and has no final NUL; dropping it would give a patch. +printf '%s\n\0%s\n' "fix: b" "feat: a" >"$TMP/in" +check_version framing/no-final-nul v0.2.26 minor v0.3.0 + +# End to end: real `git log -z --format=%B` output. The feat is the oldest commit, so a +# script that reads only the first line of the whole stream gives a patch. +repo="$TMP/repo" +if gitc init -q "$repo" && + gitc -C "$repo" commit -q --allow-empty -m "feat: a" && + gitc -C "$repo" commit -q --allow-empty -m "fix: b" -m "* chore: x" && + gitc -C "$repo" commit -q --allow-empty -m "docs: c" && + git -C "$repo" log -z --format=%B >"$TMP/in"; then + check_version framing/git-log-end-to-end v0.2.26 minor v0.3.0 +else + fail framing/git-log-end-to-end "want a throwaway git repo, got git failure" +fi + # ─── notes ─────────────────────────────────────────── expect_notes notes/breaking \ "Breaking changes" "fix: x" "y is gone" -- \ From f0322538dd1bddc986a3fc6e39e9b61796416334 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 19:07:51 -0400 Subject: [PATCH 05/10] docs: state the in-message Release-As and notes detection rules (#54) --- docs/release-versioning.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release-versioning.md b/docs/release-versioning.md index 04ec44b..c918898 100644 --- a/docs/release-versioning.md +++ b/docs/release-versioning.md @@ -39,7 +39,7 @@ A `Release-As` footer sets the version exactly and overrides the computed bump. | Item | Rule | |---|---| | Syntax | `Release-As: vX.Y.Z`. The key is case-insensitive, the `v` is optional (`0.3.0` and `v0.3.0` are the same value), and whitespace around the value is ignored. | -| Several in the range | only the newest one decides; older ones are ignored | +| Several in the range | only the newest one decides; older ones are ignored. Within one message, the last `Release-As` line decides. | | Valid value | three numeric parts, strictly greater than the latest tag | | Newest value malformed (for example, `Release-As: 0.3`) | the `version` job's bump step fails | | Newest value not greater than the latest tag | the `version` job's bump step fails | @@ -49,7 +49,7 @@ The bump step runs before the tag is created, so when it fails no tag or release ## Release Notes -If the range contains breaking commits, the workflow generates a **⚠️ Breaking changes** section. The section lists the subject of each breaking commit and its `BREAKING CHANGE:` text. It is passed to `gh release create --notes-file … --generate-notes`, which prepends it to GitHub's generated notes. If there are no breaking commits, the notes are GitHub's generated notes only. You no longer have to add the breaking-change section by hand; impact or migration detail beyond the footer text still needs a human. +If the range contains breaking commits, the workflow generates a **⚠️ Breaking changes** section. A commit is breaking for the notes by exactly the `breaking` row's rule (a `!` subject or a `BREAKING CHANGE:` / `BREAKING-CHANGE:` line), so a `* feat!: …` bullet in a squash body does not add an entry. The section lists the subject of each breaking commit and its `BREAKING CHANGE:` text. It is passed to `gh release create --notes-file … --generate-notes`, which prepends it to GitHub's generated notes. If there are no breaking commits, the notes are GitHub's generated notes only. You no longer have to add the breaking-change section by hand; impact or migration detail beyond the footer text still needs a human. ## Guidance for Whoever Squash-Merges From 654d0b82c68dbc987f752bc12a6c846890c66ed0 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 19:10:08 -0400 Subject: [PATCH 06/10] fix(release): derive the version bump from commit types (#54) --- .github/workflows/release.yml | 15 ++++---- scripts/release-notes.sh | 24 +++++++++++++ scripts/release-version.sh | 68 +++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) create mode 100755 scripts/release-notes.sh create mode 100755 scripts/release-version.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6601e9b..7ae8132 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,14 +46,16 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - - name: Bump patch version + # Rules: docs/release-versioning.md + - name: Bump version from commit types id: bump run: | - LATEST=$(git tag --list 'v*' --sort=-v:refname | head -1 || echo "v0.0.0") - MAJOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f1) - MINOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f2) - PATCH=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f3) - echo "tag=v${MAJOR}.${MINOR}.$((PATCH + 1))" >> "$GITHUB_OUTPUT" + set -euo pipefail + LATEST=$(git tag --list --sort=-v:refname | grep -E -m1 '^v[0-9]+\.[0-9]+\.[0-9]+$' || true) + RANGE=${LATEST:+$LATEST..}HEAD + echo "Latest tag: ${LATEST:-none}; range: $RANGE" + git log -z --format=%B "$RANGE" | scripts/release-version.sh "$LATEST" | tee -a "$GITHUB_OUTPUT" + git log -z --format=%B "$RANGE" | scripts/release-notes.sh > "$RUNNER_TEMP/release-notes.md" - name: Create tag run: | git config user.name "github-actions[bot]" @@ -64,6 +66,7 @@ jobs: run: | gh release create "${{ steps.bump.outputs.tag }}" \ --title "${{ steps.bump.outputs.tag }}" \ + --notes-file "$RUNNER_TEMP/release-notes.md" \ --generate-notes \ --draft \ --prerelease diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 0000000..6a85575 --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Prints a breaking-changes section for the release notes, or nothing if there are none. +# Usage: git log -z --format=%B ..HEAD | release-notes.sh +# A commit is breaking by the `breaking` row of docs/release-versioning.md. +set -euo pipefail + +BREAKING_SUBJECT='^[a-z]+(\([^)]*\))?!:' +FOOTER='^BREAKING[ -]CHANGE:' + +entries="" +while IFS= read -r -d '' msg || [ -n "$msg" ]; do + subject=${msg%%$'\n'*} + footers=$(grep -E "$FOOTER" <<<"$msg" || true) + if [[ $subject =~ $BREAKING_SUBJECT ]] || [ -n "$footers" ]; then + entries+="- $subject"$'\n' + if [ -n "$footers" ]; then + entries+=$(sed -E -e "s/$FOOTER[[:space:]]*/ - /" <<<"$footers")$'\n' + fi + fi +done + +if [ -n "$entries" ]; then + printf '## ⚠️ Breaking changes\n\n%s' "$entries" +fi diff --git a/scripts/release-version.sh b/scripts/release-version.sh new file mode 100755 index 0000000..6e04e2a --- /dev/null +++ b/scripts/release-version.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Derives the next release version from the commit messages since the latest tag. +# Usage: git log -z --format=%B ..HEAD | release-version.sh +# Prints bump= and tag=vX.Y.Z. Rules: docs/release-versioning.md. +set -euo pipefail + +SEMVER='^v?([0-9]+)\.([0-9]+)\.([0-9]+)$' +BREAKING_SUBJECT='^[a-z]+(\([^)]*\))?!:' +FEATURE_SUBJECT='^feat(\([^)]*\))?:' + +die() { + echo "release-version: $*" >&2 + exit 1 +} + +latest=${1:-v0.0.0} +[[ $latest =~ $SEMVER ]] || die "latest tag '$latest' is not vX.Y.Z" +major=$((10#${BASH_REMATCH[1]})) +minor=$((10#${BASH_REMATCH[2]})) +patch=$((10#${BASH_REMATCH[3]})) + +# 0 = patch, 1 = minor, 2 = major (breaking) +level=0 +release_as="" +release_as_found=0 + +while IFS= read -r -d '' msg || [ -n "$msg" ]; do + subject=${msg%%$'\n'*} + if [[ $subject =~ $BREAKING_SUBJECT ]] || grep -Eq '^BREAKING[ -]CHANGE:' <<<"$msg"; then + level=2 + elif [[ $subject =~ $FEATURE_SUBJECT ]] && [ "$level" -lt 1 ]; then + level=1 + fi + # Input is newest first, so the first message with a Release-As line decides. + if [ "$release_as_found" -eq 0 ]; then + line=$(grep -i '^release-as:' <<<"$msg" | tail -n 1 || true) + if [ -n "$line" ]; then + release_as_found=1 + release_as=$(sed -e 's/^[^:]*://' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' <<<"$line") + fi + fi +done + +if [ "$release_as_found" -eq 1 ]; then + [[ $release_as =~ $SEMVER ]] || die "Release-As value '$release_as' is not vX.Y.Z" + ra_major=$((10#${BASH_REMATCH[1]})) + ra_minor=$((10#${BASH_REMATCH[2]})) + ra_patch=$((10#${BASH_REMATCH[3]})) + if [ "$ra_major" -gt "$major" ] || + { [ "$ra_major" -eq "$major" ] && [ "$ra_minor" -gt "$minor" ]; } || + { [ "$ra_major" -eq "$major" ] && [ "$ra_minor" -eq "$minor" ] && [ "$ra_patch" -gt "$patch" ]; }; then + echo "bump=release-as" + echo "tag=v$ra_major.$ra_minor.$ra_patch" + exit 0 + fi + die "Release-As v$ra_major.$ra_minor.$ra_patch is not greater than the latest tag v$major.$minor.$patch" +fi + +if [ "$level" -eq 2 ] && [ "$major" -ge 1 ]; then + echo "bump=major" + echo "tag=v$((major + 1)).0.0" +elif [ "$level" -ge 1 ]; then + echo "bump=minor" + echo "tag=v$major.$((minor + 1)).0" +else + echo "bump=patch" + echo "tag=v$major.$minor.$((patch + 1))" +fi From 48b8374ea2b31170da3a4b8c94aa7aef70bbf9c2 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 19:13:49 -0400 Subject: [PATCH 07/10] test: pin multi-line footers and case-insensitive types (#54) --- scripts/release-version_test.sh | 42 +++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/scripts/release-version_test.sh b/scripts/release-version_test.sh index 4f0e9e4..c2884a7 100755 --- a/scripts/release-version_test.sh +++ b/scripts/release-version_test.sh @@ -104,6 +104,7 @@ expect_error() { } # expect_notes NAME NEEDLE... -- MSG... — notes exit 0 and contain every NEEDLE. +# A NEEDLE starting with "!" must NOT appear in the notes. expect_notes() { local name=$1 why shift @@ -126,11 +127,23 @@ expect_notes() { fi local n for n in "${needles[@]}"; do - case "$out" in - *"$n"*) ;; + case "$n" in + !*) + case "$out" in + *"${n#!}"*) + fail "$name" "want output without '${n#!}', got '$out'" + return + ;; + esac + ;; *) - fail "$name" "want output containing '$n', got '$out'" - return + case "$out" in + *"$n"*) ;; + *) + fail "$name" "want output containing '$n', got '$out'" + return + ;; + esac ;; esac done @@ -170,6 +183,7 @@ expect other v0.2.25 patch v0.2.26 "fix: x" expect other/non-conventional v0.2.25 patch v0.2.26 "Update README" expect other/docs-chore-test v0.2.25 patch v0.2.26 "docs: a" "chore: b" "test: c" "spec: d" "ci: e" expect other/empty-range v0.2.25 patch v0.2.26 +expect other/case-insensitive v0.2.25 patch v0.2.26 "Fix: a" # ─── feature ───────────────────────────────────────── expect feature v0.2.25 minor v0.3.0 "feat: x" @@ -179,6 +193,7 @@ expect feature/mid-line v0.2.25 patch v0.2.26 "fix: handle feat: prefix" expect feature/body-line-not-subject v0.2.25 patch v0.2.26 "fix: x${NL}${NL}feat: y" expect feature/highest-wins v0.2.25 minor v0.3.0 "fix: a" "feat: b" "docs: c" expect feature/above-1.0 v1.4.2 minor v1.5.0 "feat: x" +expect feature/case-insensitive v0.2.25 minor v0.3.0 "FEAT: a" # ─── breaking ──────────────────────────────────────── expect breaking v0.2.25 minor v0.3.0 "fix!: x" @@ -190,6 +205,9 @@ expect breaking/body-bang-not-subject v0.2.25 patch v0.2.26 "fix: x${NL}${NL}fix expect breaking/above-1.0 v1.4.2 major v2.0.0 "fix!: x" expect breaking/feat-bang-major v1.4.2 major v2.0.0 "feat!: x" expect breaking/highest-wins v1.4.2 major v2.0.0 "fix: a" "fix!: b" "feat: c" +expect breaking/case-insensitive v1.0.0 major v2.0.0 "Feat!: a" +# The footer token is case-sensitive (Conventional Commits): a lowercase one is plain text. +expect breaking/footer-lowercase v0.2.25 patch v0.2.26 "fix: x${NL}${NL}breaking change: y" # ─── no-tag ────────────────────────────────────────── expect no-tag "" patch v0.0.1 "fix: x" @@ -243,6 +261,22 @@ expect_notes notes/breaking \ expect_notes notes/breaking-subject \ "Breaking changes" "feat(rs)!: drop z" -- \ "feat(rs)!: drop z" +expect_notes notes/breaking-subject-case-insensitive \ + "Breaking changes" "Feat!: a" -- \ + "Feat!: a" +# The footer value runs to a blank line, the next footer token, or the end of the message. +expect_notes notes/multi-line-footer \ + "- fix: x${NL} - line one${NL} continues here" "!Release-As" -- \ + "fix: x${NL}${NL}BREAKING CHANGE: line one${NL}continues here${NL}${NL}Release-As: v0.3.0" +expect_notes notes/footer-ends-at-next-token \ + " - line one" "!Refs" -- \ + "fix: x${NL}${NL}BREAKING CHANGE: line one${NL}Refs: #1" +expect_notes notes/bare-footer \ + "Breaking changes" "- fix: x" "!${NL} -" -- \ + "fix: x${NL}${NL}BREAKING CHANGE:" +expect_notes notes/crlf \ + "Breaking changes" "fix!: x" " - y is gone" "!"$'\r' -- \ + "fix!: x"$'\r'"${NL}"$'\r'"${NL}BREAKING CHANGE: y is gone"$'\r' expect_no_notes notes/none "fix: a" "feat: b${NL}${NL}* feat!: bullet only" expect_no_notes notes/empty-range From 659c852e7637e097b3228e2598e583c76b941e95 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 19:14:55 -0400 Subject: [PATCH 08/10] fix(release): multi-line breaking footers, case-insensitive types (#54) --- .github/workflows/release.yml | 2 +- scripts/release-notes.sh | 39 ++++++++++++++++++++++++++++++----- scripts/release-version.sh | 4 ++-- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ae8132..fff7dbf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: id: bump run: | set -euo pipefail - LATEST=$(git tag --list --sort=-v:refname | grep -E -m1 '^v[0-9]+\.[0-9]+\.[0-9]+$' || true) + LATEST=$(git tag --list --merged HEAD --sort=-v:refname | grep -E -m1 '^v[0-9]+\.[0-9]+\.[0-9]+$' || true) RANGE=${LATEST:+$LATEST..}HEAD echo "Latest tag: ${LATEST:-none}; range: $RANGE" git log -z --format=%B "$RANGE" | scripts/release-version.sh "$LATEST" | tee -a "$GITHUB_OUTPUT" diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh index 6a85575..73c2d6c 100755 --- a/scripts/release-notes.sh +++ b/scripts/release-notes.sh @@ -4,17 +4,46 @@ # A commit is breaking by the `breaking` row of docs/release-versioning.md. set -euo pipefail -BREAKING_SUBJECT='^[a-z]+(\([^)]*\))?!:' +BREAKING_SUBJECT='^[A-Za-z]+(\([^)]*\))?!:' FOOTER='^BREAKING[ -]CHANGE:' +TOKEN='^[A-Za-z-]+: ' + +# footers MSG — prints each BREAKING CHANGE footer as a " - " bullet. A footer runs until +# a blank line, the next footer token line, or the end of the message. +footers() { + local line text in_footer=0 started=0 + while IFS= read -r line; do + line=${line%$'\r'} + if [[ $line =~ $FOOTER ]]; then + in_footer=1 + started=0 + text=${line#BREAKING?CHANGE:} + elif [ "$in_footer" -eq 1 ] && [ -n "$line" ] && ! [[ $line =~ $TOKEN ]]; then + text=$line + else + in_footer=0 + continue + fi + text=${text#"${text%%[![:space:]]*}"} + [ -n "$text" ] || continue + if [ "$started" -eq 0 ]; then + echo " - $text" + started=1 + else + echo " $text" + fi + done <<<"$1" +} entries="" while IFS= read -r -d '' msg || [ -n "$msg" ]; do subject=${msg%%$'\n'*} - footers=$(grep -E "$FOOTER" <<<"$msg" || true) - if [[ $subject =~ $BREAKING_SUBJECT ]] || [ -n "$footers" ]; then + subject=${subject%$'\r'} + if [[ $subject =~ $BREAKING_SUBJECT ]] || grep -Eq "$FOOTER" <<<"$msg"; then entries+="- $subject"$'\n' - if [ -n "$footers" ]; then - entries+=$(sed -E -e "s/$FOOTER[[:space:]]*/ - /" <<<"$footers")$'\n' + body=$(footers "$msg") + if [ -n "$body" ]; then + entries+="$body"$'\n' fi fi done diff --git a/scripts/release-version.sh b/scripts/release-version.sh index 6e04e2a..b2703a4 100755 --- a/scripts/release-version.sh +++ b/scripts/release-version.sh @@ -5,8 +5,8 @@ set -euo pipefail SEMVER='^v?([0-9]+)\.([0-9]+)\.([0-9]+)$' -BREAKING_SUBJECT='^[a-z]+(\([^)]*\))?!:' -FEATURE_SUBJECT='^feat(\([^)]*\))?:' +BREAKING_SUBJECT='^[A-Za-z]+(\([^)]*\))?!:' +FEATURE_SUBJECT='^[Ff][Ee][Aa][Tt](\([^)]*\))?:' die() { echo "release-version: $*" >&2 From 61a8498e4491e9ed6feb236f5c93a9a271c5ee4f Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 19:15:28 -0400 Subject: [PATCH 09/10] docs: state type case and multi-line footer rules (#54) --- docs/release-versioning.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release-versioning.md b/docs/release-versioning.md index c918898..c33f5dc 100644 --- a/docs/release-versioning.md +++ b/docs/release-versioning.md @@ -26,6 +26,8 @@ The workflow scans every commit in `..HEAD`. It finds the bump for e | `BREAKING CHANGE:` / `BREAKING-CHANGE:` | start of any line in the message | footer in the squash body | | `Release-As:` | start of any line in the message | footer in the squash body | +Types are matched case-insensitively (`Feat!:` counts as breaking), as Conventional Commits allows. The `BREAKING CHANGE:` / `BREAKING-CHANGE:` footer token is case-sensitive, as Conventional Commits requires. + Only the subject decides the commit type because GitHub's default squash body lists the branch commits as `* …` bullets. A `* feat: x` bullet under a `fix:` subject is therefore history, not a feature, and it does not count. A breaking-change footer is a deliberate statement, so it counts wherever it starts a line. ## Why the Whole Range @@ -49,7 +51,7 @@ The bump step runs before the tag is created, so when it fails no tag or release ## Release Notes -If the range contains breaking commits, the workflow generates a **⚠️ Breaking changes** section. A commit is breaking for the notes by exactly the `breaking` row's rule (a `!` subject or a `BREAKING CHANGE:` / `BREAKING-CHANGE:` line), so a `* feat!: …` bullet in a squash body does not add an entry. The section lists the subject of each breaking commit and its `BREAKING CHANGE:` text. It is passed to `gh release create --notes-file … --generate-notes`, which prepends it to GitHub's generated notes. If there are no breaking commits, the notes are GitHub's generated notes only. You no longer have to add the breaking-change section by hand; impact or migration detail beyond the footer text still needs a human. +If the range contains breaking commits, the workflow generates a **⚠️ Breaking changes** section. A commit is breaking for the notes by exactly the `breaking` row's rule (a `!` subject or a `BREAKING CHANGE:` / `BREAKING-CHANGE:` line), so a `* feat!: …` bullet in a squash body does not add an entry. A footer's text runs from `BREAKING CHANGE:` to the next blank line, the next footer-token line (any `Word: …` line, for example `Release-As:` or `Note:`), or the end of the message; continuation lines are kept and indented under the entry. Put migration notes directly under the footer, before any other token line. The section lists the subject of each breaking commit and its `BREAKING CHANGE:` text. It is passed to `gh release create --notes-file … --generate-notes`, which prepends it to GitHub's generated notes. If there are no breaking commits, the notes are GitHub's generated notes only. You no longer have to add the breaking-change section by hand; impact or migration detail beyond the footer text still needs a human. ## Guidance for Whoever Squash-Merges From 5aa7299a3c45313f04e72af3c3f453c901631d42 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 19:21:04 -0400 Subject: [PATCH 10/10] docs: release tags must be reachable from HEAD (#54) --- docs/release-versioning.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-versioning.md b/docs/release-versioning.md index c33f5dc..4359fc3 100644 --- a/docs/release-versioning.md +++ b/docs/release-versioning.md @@ -15,7 +15,7 @@ The workflow scans every commit in `..HEAD`. It finds the bump for e | `no-tag` | there is no tag matching `vX.Y.Z` | start from `v0.0.0`, then apply the rows above | — | - A minor bump resets the patch number. A major bump resets the minor and patch numbers. -- The latest tag is the highest version among tags that match `vX.Y.Z` exactly. Other tags are ignored. Versions are compared as numbers, so `v0.10.0` is greater than `v0.9.0`. +- The latest tag is the highest version among tags that match `vX.Y.Z` exactly and are reachable from the commit being released (`git tag --merged HEAD`). Other tags are ignored. Versions are compared as numbers, so `v0.10.0` is greater than `v0.9.0`. - If the range is empty (HEAD is already tagged), the release is still a patch. ## What Counts