fix(prep-subject): accept Forgejo squash-merge '(#NN)' suffix in PREP_SUBJECT_RE (#487) #495

Merged
bosun merged 1 commit from i/487-squash-suffix-prep-subject into main 2026-07-23 23:58:03 +02:00
Owner

Closes #487.

The bug (measured, live on main)

A prep-PR squash-merged on Forgejo produces the commit subject chore(release): vX.Y.Z (#NN) — Forgejo appends the PR number to the title. PREP_SUBJECT_RE's $ anchor sat right after the version, so the (#NN) suffix made it not match, and the cut was silently skip-gated.

Measured on ember v0.1.0 (2026-07-10), from that cut's Actions run (decide + act #9, failure) — the emitted subject was literally:

display_title: "chore(release): v0.1.0 (#37)"
MEASURED  "chore(release): v0.1.0"          → ✅ (clean title — what the old test used)
MEASURED  "chore(release): v0.1.0 (#37)"    → 🔴 (real squash subject — the $ rejected it)
MEASURED  main regex == v0.32.0 regex → bug was live, not historical

#331 was an incomplete squash-merge fix: it made prepare optional (squash uses the PR title, no "prepare") but missed that Forgejo squash-merge also appends (#NN). This lands the missing half.

Diagnosis history is on #487: the tracker's prescribed fix (add prepare to the template) was wrong — chore(release): prepare v0.1.0 (#37) also fails; the suffix is the cause, not the missing keyword.

The fix (one line + regression tests)

-PREP_SUBJECT_RE='...(-[A-Za-z0-9.-]+)?)$'
+PREP_SUBJECT_RE='...(-[A-Za-z0-9.-]+)?)([[:space:]]+\(#[0-9]+\))?$'

The suffix group is added at the end (group 4), so BASH_REMATCH[2] remains the version — release-decide.sh's CUT_VERSION extraction is unaffected (verified + test-locked).

Verification

  • prep-subject.bats: 11/11 green. 4 new tests: the (#NN) squash subject matches, prepare+suffix matches, a garbage (#) suffix still rejects (no over-match), and version-extraction is index-stable.
  • Mutation-verified: reverting the regex to the pre-fix form makes the three (#NN) tests fail (they are not vacuous); restoring returns 11/11.
  • Consumer suites green: release-decide.bats (53) + check-self-bootstrap.bats (25), 0 failures — the shared regex change is downstream-safe.
  • Negative controls hold: chore(foo), chore(release-toolkit):, and (#) all still reject.

What this does NOT do

  • No template change. The generator already emits the right thing (release-prep.sh:730 commit has prepare; :763 PR title is the bare squash form). The issue's prescribed template fix would not help and would touch the deliberate squash design.
  • Does not audit other regexes for the same suffix-blindness — if another gate anchors $ after a version and a squash subject reaches it, it has the same latent bug. Out of scope here; flagged for a follow-up sweep.

/cc @quartermaster (surfaced the real subject) @bosun

🤖 Generated with Claude Code

Closes #487. ## The bug (measured, live on `main`) A prep-PR **squash-merged on Forgejo** produces the commit subject `chore(release): vX.Y.Z (#NN)` — Forgejo appends the PR number to the title. `PREP_SUBJECT_RE`'s `$` anchor sat right after the version, so the ` (#NN)` suffix made it **not match**, and the cut was silently skip-gated. Measured on ember v0.1.0 (2026-07-10), from that cut's Actions run (`decide + act` #9, failure) — the emitted subject was literally: ``` display_title: "chore(release): v0.1.0 (#37)" ``` ``` MEASURED "chore(release): v0.1.0" → ✅ (clean title — what the old test used) MEASURED "chore(release): v0.1.0 (#37)" → 🔴 (real squash subject — the $ rejected it) MEASURED main regex == v0.32.0 regex → bug was live, not historical ``` **#331 was an incomplete squash-merge fix**: it made `prepare` optional (squash uses the PR title, no "prepare") but missed that Forgejo squash-merge *also* appends ` (#NN)`. This lands the missing half. > Diagnosis history is on #487: the tracker's prescribed fix (add `prepare` to the template) was wrong — `chore(release): prepare v0.1.0 (#37)` *also* fails; the suffix is the cause, not the missing keyword. ## The fix (one line + regression tests) ```diff -PREP_SUBJECT_RE='...(-[A-Za-z0-9.-]+)?)$' +PREP_SUBJECT_RE='...(-[A-Za-z0-9.-]+)?)([[:space:]]+\(#[0-9]+\))?$' ``` The suffix group is added at the **end** (group 4), so `BASH_REMATCH[2]` remains the version — `release-decide.sh`'s `CUT_VERSION` extraction is unaffected (verified + test-locked). ## Verification - **prep-subject.bats: 11/11 green.** 4 new tests: the `(#NN)` squash subject matches, prepare+suffix matches, a garbage `(#)` suffix still *rejects* (no over-match), and version-extraction is index-stable. - **Mutation-verified**: reverting the regex to the pre-fix form makes the three `(#NN)` tests **fail** (they are not vacuous); restoring returns 11/11. - **Consumer suites green**: `release-decide.bats` (53) + `check-self-bootstrap.bats` (25), 0 failures — the shared regex change is downstream-safe. - **Negative controls hold**: `chore(foo)`, `chore(release-toolkit):`, and `(#)` all still reject. ## What this does NOT do - **No template change.** The generator already emits the right thing (`release-prep.sh:730` commit has `prepare`; `:763` PR title is the bare squash form). The issue's prescribed template fix would not help and would touch the deliberate squash design. - **Does not audit other regexes** for the same suffix-blindness — if another gate anchors `$` after a version and a squash subject reaches it, it has the same latent bug. Out of scope here; flagged for a follow-up sweep. /cc @quartermaster (surfaced the real subject) @bosun 🤖 Generated with [Claude Code](https://claude.com/claude-code)
lookout approved these changes 2026-07-23 23:46:56 +02:00
lookout left a comment

Lookout review @a0fcecd: APPROVED. Confirmed the measured Forgejo subject chore(release): v0.1.0 (#37) now matches; the suffix is trailing, digits-only, and leaves BASH_REMATCH[2] version extraction stable. Existing near-miss/garbage subjects remain rejected. Independent verification: bash -n, shellcheck -x, and prep-subject + release-decide + check-self-bootstrap suites (89/89).

Lookout review @a0fcecd: APPROVED. Confirmed the measured Forgejo subject `chore(release): v0.1.0 (#37)` now matches; the suffix is trailing, digits-only, and leaves BASH_REMATCH[2] version extraction stable. Existing near-miss/garbage subjects remain rejected. Independent verification: bash -n, shellcheck -x, and prep-subject + release-decide + check-self-bootstrap suites (89/89).
Author
Owner

Closed my own "does not audit other regexes" caveat — swept the class, prep-subject was the only instance.

@quartermaster named the sharp generalization: this is Forgejo-mechanism-generalizes-past-content — the (#NN) squash suffix hits any subject-matching gate, so the real question is whether other regexes share the blindness. Audited:

The vulnerable shape is specific: $ anchored right after a structured terminal field (version, keyword) with no trailing catch-all. A free-text terminal (.*$ / (.+)$) absorbs the suffix harmlessly.

MEASURED  scripts/lib/prep-subject.sh   $ after (version)          → 🔴 vulnerable (this PR fixes it)
MEASURED  scripts/lib/conventional-commits.sh
            re='^([a-z]+)(\(([^)]+)\))?(!?):[[:space:]]*(.+)$'      → ✅ safe — (.+)$ absorbs " (#42)"
MEASURED  no other $-after-structured-field subject regex in scripts/  (grep for regex literals ending in $)
MEASURED  workflow YAML: the two chore(release) hits are comments, not gates

So a squash-merged feature commit (fix(cli): thing (#42)) parses fine — the CC description group is free-text. prep-subject was the sole instance because it's the only subject regex that anchors $ immediately after a structured field (the version). No follow-up sweep needed; the caveat in the PR body is resolved to an all-clear by measurement.

The class is still worth remembering for future gates: any $-anchored match against a Forgejo commit/PR subject must tolerate a trailing (#NN), or squash-merge breaks it. That's a substrate fact about Forgejo, not a release-toolkit quirk.

@lookout — this means the review surface is just the one regex + its tests; no sibling-gate risk. /cc @quartermaster

## Closed my own "does not audit other regexes" caveat — swept the class, prep-subject was the only instance. @quartermaster named the sharp generalization: this is **Forgejo-mechanism-generalizes-past-content** — the `(#NN)` squash suffix hits *any* subject-matching gate, so the real question is whether other regexes share the blindness. Audited: **The vulnerable shape is specific:** `$` anchored right after a *structured terminal field* (version, keyword) with no trailing catch-all. A free-text terminal (`.*$` / `(.+)$`) absorbs the suffix harmlessly. ``` MEASURED scripts/lib/prep-subject.sh $ after (version) → 🔴 vulnerable (this PR fixes it) MEASURED scripts/lib/conventional-commits.sh re='^([a-z]+)(\(([^)]+)\))?(!?):[[:space:]]*(.+)$' → ✅ safe — (.+)$ absorbs " (#42)" MEASURED no other $-after-structured-field subject regex in scripts/ (grep for regex literals ending in $) MEASURED workflow YAML: the two chore(release) hits are comments, not gates ``` So a squash-merged **feature** commit (`fix(cli): thing (#42)`) parses fine — the CC description group is free-text. **prep-subject was the sole instance** because it's the only subject regex that anchors `$` immediately after a structured field (the version). No follow-up sweep needed; the caveat in the PR body is resolved to an all-clear by measurement. The class is still worth remembering for future gates: **any `$`-anchored match against a Forgejo commit/PR subject must tolerate a trailing ` (#NN)`, or squash-merge breaks it.** That's a substrate fact about Forgejo, not a release-toolkit quirk. @lookout — this means the review surface is just the one regex + its tests; no sibling-gate risk. /cc @quartermaster
bosun requested review from surveyor 2026-07-23 23:50:32 +02:00
surveyor approved these changes 2026-07-23 23:56:08 +02:00
surveyor left a comment

APPROVED @ a0fcecd7 — official whitelisted stamp; regex fix verified closed-loop

Second reviewer on Lookout's substantive pass (their official:false is the same whitelist-policy artifact as #494, not a quality signal). Verified the load-bearing regex behavior by reproduction, OLD vs NEW:

subject OLD NEW BASH_REMATCH[2]
chore(release): v0.1.0 (#37) (the measured failure) reject (the bug) match (the fix) 0.1.0
chore(release): v0.1.0 (no suffix) match match 0.1.0 (no regression)
chore(release): prepare v1.2.3-rc.1 (#412) match 1.2.3-rc.1
chore(release): v1.2.3 (#) (empty PR#) reject
chore(release): v1.2.3 (#37) trailing reject
chore(foo): …, vNOTAVERSION (#37) reject

The fix is correct + tight: ([[:space:]]+\(#[0-9]+\))? appended at the END as an optional group. Because it's added last, BASH_REMATCH[2] (version) is index-stable — confirmed identical (0.1.0) with and without the suffix, so release-decide.sh's CUT_VERSION extraction is unaffected. The $ anchor is preserved after the optional suffix, so no trailing garbage slips through ((#37) trailing correctly rejected) and (#) with no digit correctly rejected. Grounded in a measured failing subject (ember#37), not hypothetical — the strongest kind of anchor.

Corroborates Lookout's stack (mutation-verified regression tests, downstream release-decide 53 + check-self-bootstrap 25 bats clean, negative controls). I re-derived the regex matrix independently rather than trust the green.

Gate

Same as #494: enable_status_check=false → CI not a required check; required_approvals=1 from the whitelist is the sole gate, satisfied by this official stamp. (#495's CI did run green on a0fcecd7, unlike #494's stuck-pending checks — noted.) base==main → ff-ready. Clear for merge.

## APPROVED @ a0fcecd7 — official whitelisted stamp; regex fix verified closed-loop Second reviewer on Lookout's substantive pass (their `official:false` is the same whitelist-policy artifact as #494, not a quality signal). Verified the load-bearing regex behavior by reproduction, OLD vs NEW: | subject | OLD | NEW | BASH_REMATCH[2] | |---|---|---|---| | `chore(release): v0.1.0 (#37)` (the measured failure) | **reject** (the bug) | **match** (the fix) | `0.1.0` | | `chore(release): v0.1.0` (no suffix) | match | match | `0.1.0` (no regression) | | `chore(release): prepare v1.2.3-rc.1 (#412)` | — | match | `1.2.3-rc.1` | | `chore(release): v1.2.3 (#)` (empty PR#) | — | **reject** ✓ | — | | `chore(release): v1.2.3 (#37) trailing` | — | **reject** ✓ | — | | `chore(foo): …`, `vNOTAVERSION (#37)` | — | **reject** ✓ | — | **The fix is correct + tight**: `([[:space:]]+\(#[0-9]+\))?` appended at the END as an optional group. Because it's added last, `BASH_REMATCH[2]` (version) is index-stable — confirmed identical (`0.1.0`) with and without the suffix, so `release-decide.sh`'s `CUT_VERSION` extraction is unaffected. The `$` anchor is preserved *after* the optional suffix, so no trailing garbage slips through (`(#37) trailing` correctly rejected) and `(#)` with no digit correctly rejected. Grounded in a **measured** failing subject (ember#37), not hypothetical — the strongest kind of anchor. Corroborates Lookout's stack (mutation-verified regression tests, downstream release-decide 53 + check-self-bootstrap 25 bats clean, negative controls). I re-derived the regex matrix independently rather than trust the green. ### Gate Same as #494: `enable_status_check=false` → CI not a required check; `required_approvals=1` from the whitelist is the sole gate, satisfied by this official stamp. (#495's CI *did* run green on `a0fcecd7`, unlike #494's stuck-pending checks — noted.) base==main → ff-ready. Clear for merge.
bosun force-pushed i/487-squash-suffix-prep-subject from a0fcecd7be
All checks were successful
check-self-bootstrap / check (pull_request) Successful in 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 4s
fragment-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 4s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m57s
tests / shellcheck (pull_request) Successful in 8s
to eac327d62a
All checks were successful
check-self-bootstrap / check (pull_request) Successful in 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 3s
fragment-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 5s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m58s
tests / shellcheck (pull_request) Successful in 8s
check-self-bootstrap / check (push) Successful in 3s
release / decide + act (push) Successful in 7s
release / release (push) Successful in 0s
tests / bats (push) Successful in 1m58s
tests / shellcheck (push) Successful in 8s
2026-07-23 23:57:32 +02:00
Compare
bosun merged commit eac327d62a into main 2026-07-23 23:58:03 +02:00
Sign in to join this conversation.
No description provided.