Skip to content

Version derivation and release notes must read commit bodies (→ 4.0.0) - #178

Merged
jgruberf5 merged 2 commits into
stagingfrom
fix/version-bump-reads-breaking-body
Aug 19, 2026
Merged

Version derivation and release notes must read commit bodies (→ 4.0.0)#178
jgruberf5 merged 2 commits into
stagingfrom
fix/version-bump-reads-breaking-body

Conversation

@jgruberf5

Copy link
Copy Markdown
Collaborator

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.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's own comment described was unreachable; only type!: in a subject could ever bump major.

7ece9b04 (container-runner hardening, #2) declares in its body:

BREAKING CHANGE, called out deliberately. … refuses named users — USER nonroot, the distroless convention, must become USER 65532.

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.sh reads subject + body. Major = type!: in the subject or an uppercase BREAKING CHANGE/BREAKING-CHANGE marker 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.
  • New scripts/extract-breaking-changes.sh emits a ⚠️ Breaking Changes block (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.sh against v3.1.6..stagingBUMP_TYPE=major, TARGET_VERSION=4.0.0 (was 3.1.7).
  • extract-breaking-changes.sh v3.1.6 staging → the USER nonroot → USER 65532 migration block; empty for a range with no breaking change.
  • bash -n clean on both scripts; release.yml YAML 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

…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
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

Self-review

The fix is verified against the real range and by the suite

  • compute_version_bump.sh against v3.1.6..stagingmajor / 4.0.0 (was 3.1.7). The sole trigger is 7ece9b04's body, confirmed by isolating it.
  • SELF_TEST=1 bash scripts/compute_version_bump.sh6 passed, including the new Test 5 (BREAKING CHANGE footer in a body → major). That test is non-vacuous by construction: its commit subject is fix: …, so the pre-fix subject-only script computes patch and the test fails against it — it only passes because the fix reads bodies.
  • extract-breaking-changes.sh v3.1.6 staging emits the USER nonroot → USER 65532 migration; empty on a clean range.

One deliberate behaviour change I want on the record

I made the BREAKING-CHANGE marker case-sensitive (dropped -i). The original was case-insensitive, which was harmless when it only read subjects — but now that it reads bodies, -i would match prose like "this is not a breaking change" and false-trigger a major. Uppercase-only is the conventional-commits spec form, 7ece9b04 uses it, and Test 6 pins that lowercase prose stays patch. If anyone relied on lowercase breaking change footers, they'd now derive a lower bump — but that's non-spec and the safer direction (under-, not over-, promoting).

Honest limitations — flagged, not fixed here

  • The self-test doesn't gate CI. No workflow runs SELF_TEST=1, and run_test echoes FAIL without a non-zero exit, so even a wired-in step wouldn't fail the build today. Making it gate (accumulate failures → exit 1, add a P1 CI step) is a real improvement, but it's a CI-workflow change I didn't want to fold into a release-critical PR mid-cut. Worth a follow-up.
  • False-positive surface. Reading bodies means a commit that quotes an uppercase BREAKING CHANGE (in an example or a revert) would over-promote. Rare, and over-promoting is the safe direction, but it's a real difference from subject-only scanning.

Scope I intentionally kept

Only the version derivation and the notes/CHANGELOG generation — the three spots blinded by %s. I didn't touch the RC/final job structure or the preflight. The 7ece9b04 change itself isn't re-litigated here; this PR only makes the tooling see what its author already declared.

@jgruberf5 jgruberf5 mentioned this pull request Aug 19, 2026

@mwiget mwiget left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 nonrootUSER 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.

@jgruberf5
jgruberf5 merged commit 4a52ed4 into staging Aug 19, 2026
25 checks passed
@jgruberf5
jgruberf5 deleted the fix/version-bump-reads-breaking-body branch August 19, 2026 23:27
jgruberf5 pushed a commit that referenced this pull request Aug 21, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants