Version derivation and release notes must read commit bodies (→ 4.0.0) - #178
Conversation
…t just subjects PR #177 review surfaced that compute_version_bump.sh scanned only commit subjects (%s). A conventional-commits BREAKING CHANGE is a *footer* — it lives in the body by definition — so the footer branch the code commented on was unreachable, and only `type!:` in a subject could ever produce a major bump. Concretely: 7ece9b0 (container-runner hardening, #2) declares in its body that the non-root gate now refuses named users — `USER nonroot` (the distroless convention) must become `USER 65532`. A real behavioural break, labelled as such by its author, that the tool couldn't see: it shipped as a patch, telling operators the opposite of the truth. The same %s-only blindness affected the release notes and CHANGELOG generation, so even had the version been right, the migration step would never have reached an operator. Fixes both: - compute_version_bump.sh reads each commit's subject AND body. Major is `type!:` in the subject or an uppercase `BREAKING CHANGE`/`BREAKING-CHANGE` marker anywhere; minor is `feat:` in the subject (type is declared in the subject, never the body). Case-sensitive on the marker so body prose like "not a breaking change" can't false-trigger now that bodies are read. - New scripts/extract-breaking-changes.sh emits a "⚠️ Breaking Changes" block (subject + the BREAKING CHANGE paragraph, so the migration text carries through). Wired into the RC notes, final release notes, and CHANGELOG steps; emits nothing when a range has no breaking change. Against v3.1.6..staging this now derives major → 4.0.0 (was silently 3.1.7) and surfaces the USER nonroot → USER 65532 migration. The bug outlived any single release: until the tool read bodies, every future footer-declared break would ship silently mis-versioned with its migration invisible. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…contained Adds to the built-in SELF_TEST suite the case that was silently broken: - BREAKING CHANGE footer in the BODY of a fix-subject commit → major (non- vacuous: subject-only derivation computes patch here, so this fails against the pre-fix script). - lowercase "breaking change" in body prose → patch (guards the case-sensitive marker, so reading bodies can't false-positive on prose). The harness gains optional per-commit bodies (subject~~BODY~~body) since a footer only exists in a body, and sets a local git identity in its temp repos so the suite runs on a fresh runner with no global git config (it aborted otherwise). Also dropped a dead duplicate invocation and its stale COMMITS comment. SELF_TEST=1 bash scripts/compute_version_bump.sh → 6 passed. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Self-reviewThe fix is verified against the real range and by the suite
One deliberate behaviour change I want on the recordI made the BREAKING-CHANGE marker case-sensitive (dropped Honest limitations — flagged, not fixed here
Scope I intentionally keptOnly the version derivation and the notes/CHANGELOG generation — the three spots blinded by |
mwiget
left a comment
There was a problem hiding this comment.
Approving. I ran both scripts against the real range rather than taking the numbers on trust, and they do what the description says.
$ bash scripts/compute_version_bump.sh --since-tag v3.1.6
BUMP_TYPE=major
TARGET_VERSION=4.0.0
$ SELF_TEST=1 bash scripts/compute_version_bump.sh
PASS: fix commits → patch PASS: no commits → patch
PASS: feat commit → minor PASS: BREAKING CHANGE footer in body → major
PASS: breaking ! → major PASS: lowercase breaking change in body → patch
I also scanned all 65 commits in v3.1.6..staging individually with the new marker regex: exactly one matches, 7ece9b0. So reading bodies didn't introduce a false positive anywhere in the range it will first be used on — which was my main worry about widening the search surface, and the reason the case-sensitivity decision matters. Test 6 pins that directly: lowercase prose "this is explicitly not a breaking change" stays a patch. Dropping -i was the right call and it needed the test.
extract-breaking-changes.sh produces the block with the migration sentence intact — USER nonroot → USER 65532, plus the reasoning about uid resolution. That's the thing an operator upgrading from 3.1.6 actually needs, and it now appears in RC notes, final notes and CHANGELOG. Empty range emits nothing and exits 0, so the three if [ -n "$BREAKING" ] guards behave.
Two incidental fixes worth noting since they're easy to miss in the diff: the self-test previously ran the script twice and threw the first result away, and it now sets its own git identity so it works on a fresh runner with no global config.
One thing I'd still tighten, non-blocking: the new tooling can fail silently, which is the failure mode this PR exists to eliminate.
extract-breaking-changes.sh:33 ends its range read with 2>/dev/null || true, so an unresolvable ref yields an empty commit list rather than an error — bash scripts/extract-breaking-changes.sh v9.9.9 v9.9.8 exits 0 with no output. And each of the three call sites in release.yml wraps the script in || true, so even the ${1:?usage} guard (which does correctly exit 1 on an empty LAST_FINAL) is swallowed.
Either layer alone would mask a misconfiguration; together nothing can surface one. A release with a wrong or empty LAST_FINAL would ship looking clean — no breaking-changes block, no error — which is precisely the shape of the 3.1.7 bug one level up: the tool that guarantees a break is visible, failing invisibly.
I don't think it blocks this PR, because triggering it needs a separate misconfiguration that would likely break neighbouring steps too, and the release fix shouldn't wait on a hypothetical. But || true on a script whose job is "never let a breaking change go unnoticed" is worth removing — let it fail the release loudly instead.
CI: P1 and P2 all green, including both unit suites and component. P3 · Integration Tests · Backend still running.
Once this lands and the RC validates, #177 needs its title, body and version claim updated from 3.1.7 to 4.0.0 — I'll re-review it then.
bonnyr-f5 round-3 BLOCK on #179. The round-2 blockers stay fixed (--first-parent reverted, SIGPIPE deterministic-fix, detector pair identical); this addresses the predicate itself, which round 2 did not examine. BLOCKER 1 — the marker regex was a WORD BOUNDARY, not a line anchor, so it fired on uppercase prose anywhere in a body while the comment claimed "footer only". Over the live v3.1.6..origin/main range the extractor shipped a fragment of this script's OWN changelog prose (#178) to operators as migration guidance. Both detectors are now anchored to the spec footer position, `^(**)?BREAKING[ -]CHANGE` (markdown-bold allowed), and are byte-identical across the two scripts (INV-15). Re-running the extractor over that range: the #178 prose bullet is gone; the real #2 container-hardening break (its only breaking signal in the whole range) is still detected, so the 4.0.0 major derivation is unchanged. I deliberately did NOT require a trailing colon: #2 declares its break as a line-start marker with no colon and no type!: subject, so a colon rule would UNDER-detect and silently ship 4.0.0 as a patch — a worse failure than prose. Also fixed from the same review: - _breaking_note emits the anchored footer paragraph(s), not the first prose match; captures EVERY footer (a second one was dropped) with no line cap (n>=40 truncated silently). - extract-breaking-changes.sh now fails CLOSED on an unresolvable range (validates both refs) instead of 2>/dev/null||true -> empty output rc 0, which fooled the reviewer mid-audit. Matches compute's fail-closed behaviour. - The consistency guard read %b only while the loop reads %s AND %b: it was blind to subject-declared breaks. It now re-derives from BOTH subjects and bodies, so dropping the loop's subject detector can no longer leave feat!: as a silent patch. - Self-test hardening (INV-16): the extractor's _expect_nonempty could never fail (it checked the note only inside a failure conjunct) -> rewritten to assert each detector fires POSITIVELY, with mid-line-prose / lowercase / indented negatives and a two-footer case. compute gains an assertion counter that fails on zero assertions, coverage for the unresolvable-since-tag guard, and a robust BASH_SOURCE self-invocation ($OLDPWD/$0 broke any non-cwd-relative call). - Nits: case-insensitive bang so `Feat!:` bumps major; `local _b`; harmonized the determinism comments (the bug is deterministic past the ~64 KB pipe buffer, not a race). Handed to sibling PRs (bonnyr flagged, out of this diff): the release.yml call sites' `|| true` and the head -40/-50 note truncation live in #181; wiring the extractor --self-test into CI plus a byte-identical-function assertion lands in #182's script-selftests job. I'll push both. Verified: shellcheck -S style clean on both; compute self-test 9/9 rc 0; extractor self-test 12/12 rc 0; detectors byte-identical; extractor re-run over the real release range shows the prose bullet gone and the real break retained. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Why
PR #177's review (thanks @mwiget) caught that the 3.1.7 the release automation derived is wrong — it should be 4.0.0 — and that the tooling structurally can't see why.
compute_version_bump.shscanned only commit subjects (%s). A conventional-commitsBREAKING CHANGEis a footer — it lives in the body by definition — so the footer branch the code's own comment described was unreachable; onlytype!:in a subject could ever bump major.7ece9b04(container-runner hardening, #2) declares in its body:A real break, labelled as such, that the tool couldn't see → it shipped as a patch. And the release-notes/CHANGELOG generation had the same
%s-only blindness, so even with the right version, the migration step would never reach an operator.What
compute_version_bump.shreads subject + body. Major =type!:in the subject or an uppercaseBREAKING CHANGE/BREAKING-CHANGEmarker anywhere; minor =feat:in the subject. Case-sensitive on the marker so body prose ("not a breaking change") can't false-trigger now that bodies are read.scripts/extract-breaking-changes.shemits a⚠️ Breaking Changesblock (subject + the BREAKING CHANGE paragraph, carrying the migration text). Wired into the RC notes, final release notes, and CHANGELOG steps; emits nothing when a range has none.Verified
bash scripts/compute_version_bump.shagainstv3.1.6..staging→BUMP_TYPE=major,TARGET_VERSION=4.0.0(was 3.1.7).extract-breaking-changes.sh v3.1.6 staging→ theUSER nonroot → USER 65532migration block; empty for a range with no breaking change.bash -nclean on both scripts;release.ymlYAML validates.Effect on the release
Merging this to staging cuts a
v4.0.0-rc.*(validating the fix in the real pipeline), and then #177 (staging→main) cuts the final v4.0.0 with the migration in its notes. The bug outlived any single release: until the tool read bodies, every future footer-declared break would ship silently mis-versioned with its migration invisible.https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4