fix: assert no shipped changelog heading is deleted or duplicated #134

Merged
dan-claude-bot merged 5 commits from fix/changelog-monotonic into main 2026-07-20 23:37:02 +00:00
dan-claude-bot commented 2026-07-20 20:00:42 +00:00 (Migrated from github.com)

Closes #133

The problem

Release headings are append-only: the ceremony (#111) adds one and nothing in CONTRIBUTING's release flow ever removes one. Nothing asserted that.

The arming rule in test/release.test.ts (rig#66) is thorough about what it covers, and narrow by design: it asks whether the top section agrees with package.json's version — one question, about one heading, the one a PR is about to write under. It says nothing about the rest of the file, and it cannot, because "a heading disappeared" is not a property of a tree. It is a property of a diff.

So this passes every guard the repo has:

-## 0.1.1 — 2026-07-19
+## Unreleased
+
+### Fixed
+
+- **An entry**

An author adding an entry under ## Unreleased typed over the heading below it instead of inserting above it. git merges that cleanly — a one-line edit in a file nobody touched concurrently, so no conflict and no signal. The arming test stays green and is not wrong to: the top section is still the right one for the version. But 0.1.1's body is now sitting under ## Unreleased, and 0.1.1 has no section at all. It surfaces at the next release, when release-notes.sh cannot find the section it extracts by heading — or worse, republishes the absorbed prose as if it were new.

cast is the more exposed of the two repos. release-notes.sh extracts with /^## / { grab = ($2 == ver); next } and no exit, so grab re-arms on every matching ## line. Two ## 0.1.1 headings make the published body absorb whatever sits between the copies, and an entry stranded there is dropped from the next release's notes as well. (rig's extractor has if (found) exit, so it truncates instead — same class, milder symptom. cast has the absorbing one.)

The fix

Ports .github/scripts/changelog-monotonic.sh from box (box#122, caught in review of box#118) rather than reimplementing the invariant a third time in TypeScript, keeping both halves:

  • Containment — the set of ## X.Y.Z headings on HEAD must be a superset of the set at the merge base. Exact, with no legitimate violation to carve an exception for. The stamp is covered for free: rewriting ## Unreleased## X.Y.Z — DATE adds a version heading and removes none.
  • Uniqueness on HEAD — because containment structurally cannot catch a duplicate. A duplicate is head-side surplus, and comm -23 (base minus head) is blind to extras on the head side; multiset comparison does not close it either. This is the half cast needs most, per the absorbing extractor above.

## Unreleased is deliberately outside the guarded set — the arming rule owns that heading, and the ceremony legitimately consumes it.

Wired into ci.yml exactly as box does: its own step (so a red run names the invariant that broke), pull requests only (on a push to main the merge base is HEAD, so the assert is vacuous), CHANGELOG_MONOTONIC_STRICT: '1', and fetch-depth: 0 on the checkout so a checkout that cannot reach the base ref fails loudly rather than skipping quietly forever.

What I tested

npm run check, npm run build, npm test — all green. 634 tests, 35 files, all passing, of which 11 are new (test/release.test.tschangelog-monotonic.sh — release headings are append-only (#133)), driving the real script as a subprocess against throwaway git repos with a base and a PR branch, the same way that file already drives release-notes.sh:

  • a branch that touches nothing passes, and reports how many headings it checked;
  • adding an entry the correct way (above the heading) passes;
  • RED when an entry replaced the shipped heading below it — the #133 failure, exactly, asserting 0.1.0 is not falsely accused alongside 0.1.1;
  • RED on a duplicated version heading — the case containment cannot see, and the case the existing "double re-arm" test does not cover (it counts duplicate ## Unreleased, not duplicate version headings);
  • the absorbing shape end to end: the real release-notes.sh is run against the duplicated tree and shown publishing the stranded entry;
  • ## Unreleased is not guarded — the ceremony stamp passes, and so does deleting Unreleased outright (red under the arming rule, not this one's business);
  • a changelog absent at the merge base is nothing-to-have-deleted, not a failure;
  • a missing changelog refuses by path — never a silent pass;
  • the skip path is a skip locally and a hard failure under STRICT=1, naming fetch-depth: 0;
  • ci.yml's wiring is pinned (PR-only, STRICT, origin/${{ github.base_ref }}, fetch-depth: 0), the same fail-closed way release.yml's is.

I also ran the script against the real tree three ways, to prove it catches the bug rather than just exiting 0: clean under STRICT=1 (exit 0, "all 2 release heading(s) … still present"); with ## 0.1.1 typed over (exit 1, DELETES release heading(s)); and with ## 0.1.1 duplicated (exit 1, DUPLICATE release heading(s)). bash -n and shellcheck clean.

Sibling issue for the same gap in rig: heavy-duty/rig#98.

🤖 Generated with Claude Code

Closes #133 ## The problem Release headings are append-only: the ceremony ([#111](https://github.com/heavy-duty/cast/issues/111)) adds one and nothing in CONTRIBUTING's release flow ever removes one. Nothing asserted that. The arming rule in `test/release.test.ts` (rig#66) is thorough about what it covers, and narrow by design: it asks whether the **top** section agrees with `package.json`'s version — one question, about **one heading**, the one a PR is about to write under. It says nothing about the rest of the file, and it cannot, because "a heading disappeared" is not a property of a tree. It is a property of a **diff**. So this passes every guard the repo has: ```diff -## 0.1.1 — 2026-07-19 +## Unreleased + +### Fixed + +- **An entry** ``` An author adding an entry under `## Unreleased` typed *over* the heading below it instead of inserting above it. git merges that cleanly — a one-line edit in a file nobody touched concurrently, so no conflict and no signal. The arming test stays green and is not wrong to: the top section is still the right one for the version. But `0.1.1`'s body is now sitting under `## Unreleased`, and `0.1.1` has no section at all. It surfaces at the *next* release, when `release-notes.sh` cannot find the section it extracts by heading — or worse, republishes the absorbed prose as if it were new. **cast is the more exposed of the two repos.** `release-notes.sh` extracts with `/^## / { grab = ($2 == ver); next }` and **no `exit`**, so `grab` re-arms on every matching `## ` line. Two `## 0.1.1` headings make the published body **absorb** whatever sits between the copies, and an entry stranded there is dropped from the next release's notes as well. (rig's extractor has `if (found) exit`, so it truncates instead — same class, milder symptom. cast has the absorbing one.) ## The fix Ports `.github/scripts/changelog-monotonic.sh` from box ([box#122](https://github.com/heavy-duty/box/issues/122), caught in review of box#118) rather than reimplementing the invariant a third time in TypeScript, keeping both halves: - **Containment** — the set of `## X.Y.Z` headings on HEAD must be a superset of the set at the merge base. Exact, with no legitimate violation to carve an exception for. The stamp is covered for free: rewriting `## Unreleased` → `## X.Y.Z — DATE` *adds* a version heading and removes none. - **Uniqueness on HEAD** — because containment structurally cannot catch a duplicate. A duplicate is head-side *surplus*, and `comm -23` (base minus head) is blind to extras on the head side; multiset comparison does not close it either. This is the half cast needs most, per the absorbing extractor above. `## Unreleased` is deliberately outside the guarded set — the arming rule owns that heading, and the ceremony legitimately consumes it. Wired into `ci.yml` exactly as box does: its own step (so a red run names the invariant that broke), **pull requests only** (on a push to main the merge base *is* HEAD, so the assert is vacuous), `CHANGELOG_MONOTONIC_STRICT: '1'`, and `fetch-depth: 0` on the checkout so a checkout that cannot reach the base ref fails loudly rather than skipping quietly forever. ## What I tested `npm run check`, `npm run build`, `npm test` — all green. **634 tests, 35 files, all passing**, of which 11 are new (`test/release.test.ts` — `changelog-monotonic.sh — release headings are append-only (#133)`), driving the real script as a subprocess against throwaway git repos with a base and a PR branch, the same way that file already drives `release-notes.sh`: - a branch that touches nothing passes, and reports how many headings it checked; - adding an entry the correct way (above the heading) passes; - **RED** when an entry replaced the shipped heading below it — the #133 failure, exactly, asserting `0.1.0` is not falsely accused alongside `0.1.1`; - **RED** on a duplicated *version* heading — the case containment cannot see, and the case the existing `"double re-arm"` test does *not* cover (it counts duplicate `## Unreleased`, not duplicate version headings); - the absorbing shape end to end: the real `release-notes.sh` is run against the duplicated tree and shown publishing the stranded entry; - `## Unreleased` is **not** guarded — the ceremony stamp passes, and so does deleting Unreleased outright (red under the arming rule, not this one's business); - a changelog absent at the merge base is nothing-to-have-deleted, not a failure; - a missing changelog refuses by path — never a silent pass; - the skip path is a **skip** locally and a hard **failure** under `STRICT=1`, naming `fetch-depth: 0`; - `ci.yml`'s wiring is pinned (PR-only, STRICT, `origin/${{ github.base_ref }}`, `fetch-depth: 0`), the same fail-closed way `release.yml`'s is. I also ran the script against the **real tree** three ways, to prove it catches the bug rather than just exiting 0: clean under `STRICT=1` (exit 0, "all 2 release heading(s) … still present"); with `## 0.1.1` typed over (exit 1, `DELETES release heading(s)`); and with `## 0.1.1` duplicated (exit 1, `DUPLICATE release heading(s)`). `bash -n` and `shellcheck` clean. Sibling issue for the same gap in rig: heavy-duty/rig#98. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
danmt (Migrated from github.com) reviewed 2026-07-20 20:00:42 +00:00
claude-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 20:09:35 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

🔧 Changes requested — I agree with most; feedback below.

The invariant is the right one and the reasoning for a separate script (diff-property, different degradation, arming fixtures aren't git repos) holds. Containment + uniqueness as two halves is correct, and the justification for why comm -23 can't see head-side surplus is exactly right. ## Unreleased staying outside the guarded set is the right call. One substantive problem.

  • .github/scripts/changelog-monotonic.sh:137the uniqueness half is gated behind base-side conditions it doesn't depend on. Uniqueness is a property of HEAD alone; it needs no base ref, no merge base, and no base blob. But dupes= sits downstream of all three. Concretely, .github/scripts/changelog-monotonic.sh:101:

    [ -n "$base_file" ] || {
      echo "... does not exist at the merge base ... — nothing could have been deleted."
      exit 0
    }
    

    A tree with two ## 0.1.1 headings exits 0 there, with a message that is true about deletion and silent about the duplicate that is actually present. The skip() paths have the same shape — locally (STRICT unset) a shallow clone or non-git tree returns 0 without ever looking at a duplicate the author is about to push. That's the half you argue cast needs most, given the absorbing grab re-arm, and it's the half with the extra ways to not run.

    Fix is a move, not a rewrite: hoist the headings_raw/dupes block to directly after the [ -f "$changelog" ] check, above git rev-parse --is-inside-work-tree. Then the skip messages are honest — they'd be skipping only containment, which is the only part that needed the history.

    The test at test/release.test.ts ("a changelog absent at the merge base is nothing-to-have-deleted") builds that fixture from a clean BASE, so it pins the exit-0 without pinning that uniqueness still ran. Worth a case where the PR introduces CHANGELOG.md carrying a duplicate — red today only if the block moves.

Secondary, your call: if: github.event_name == 'pull_request' is well-argued for containment (vacuous on a push to main) but not for uniqueness, which is not vacuous on any tree. If a duplicate ever reaches main by a route other than a PR, nothing asserts it. Running the script unconditionally and letting the merge-base path no-op on main would cover both, but I won't block on it.

Everything else checked out: printf '%s\n' "" feeding comm degrades to an empty missing rather than a false accusation; count survives grep -c returning 1 under pipefail; the $2 split matches release-notes.sh so the two can't disagree about what a section header is; ci.yml's ${{ github.base_ref }} is upstream-controlled, not fork-author-controlled. The absorbing-shape test driving the real release-notes.sh to show the stranded entry getting published is the right way to prove the assert guards a live defect.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

🔧 **Changes requested — I agree with most; feedback below.** The invariant is the right one and the reasoning for a separate script (diff-property, different degradation, arming fixtures aren't git repos) holds. Containment + uniqueness as two halves is correct, and the justification for why `comm -23` can't see head-side surplus is exactly right. `## Unreleased` staying outside the guarded set is the right call. One substantive problem. - `.github/scripts/changelog-monotonic.sh:137` — **the uniqueness half is gated behind base-side conditions it doesn't depend on.** Uniqueness is a property of HEAD alone; it needs no base ref, no merge base, and no base blob. But `dupes=` sits downstream of all three. Concretely, `.github/scripts/changelog-monotonic.sh:101`: [ -n "$base_file" ] || { echo "... does not exist at the merge base ... — nothing could have been deleted." exit 0 } A tree with two `## 0.1.1` headings exits **0** there, with a message that is true about deletion and silent about the duplicate that is actually present. The `skip()` paths have the same shape — locally (STRICT unset) a shallow clone or non-git tree returns 0 without ever looking at a duplicate the author is about to push. That's the half you argue cast needs most, given the absorbing `grab` re-arm, and it's the half with the extra ways to not run. Fix is a move, not a rewrite: hoist the `headings_raw`/`dupes` block to directly after the `[ -f "$changelog" ]` check, above `git rev-parse --is-inside-work-tree`. Then the skip messages are honest — they'd be skipping only containment, which is the only part that needed the history. The test at `test/release.test.ts` ("a changelog absent at the merge base is nothing-to-have-deleted") builds that fixture from a clean `BASE`, so it pins the exit-0 without pinning that uniqueness still ran. Worth a case where the PR *introduces* CHANGELOG.md carrying a duplicate — red today only if the block moves. Secondary, your call: `if: github.event_name == 'pull_request'` is well-argued for containment (vacuous on a push to main) but not for uniqueness, which is not vacuous on any tree. If a duplicate ever reaches main by a route other than a PR, nothing asserts it. Running the script unconditionally and letting the merge-base path no-op on main would cover both, but I won't block on it. Everything else checked out: `printf '%s\n' ""` feeding `comm` degrades to an empty `missing` rather than a false accusation; `count` survives `grep -c` returning 1 under `pipefail`; the `$2` split matches `release-notes.sh` so the two can't disagree about what a section header is; ci.yml's `${{ github.base_ref }}` is upstream-controlled, not fork-author-controlled. The absorbing-shape test driving the real `release-notes.sh` to show the stranded entry getting published is the right way to prove the assert guards a live defect. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-20 20:10:21 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is.

Port of box#126's append-only + uniqueness guard for shipped ## X.Y.Z headings.

What holds

  • Containment (base ⊆ head) catches deleted shipped headings; uniqueness on HEAD catches duplicates (comm -23 cannot).
  • STRICT + fetch-depth: 0 in CI; loud skip locally when base missing.
  • Ceremony stamp and re-arm trees pass (Unreleased not in guarded set).
  • Fixtures reconstruct delete-over-heading and dual-heading shapes; wiring pins in CI.

No blockers. CI green.

**Verdict: Approve** — I agree with this as-is. Port of box#126's append-only + uniqueness guard for shipped `## X.Y.Z` headings. ### What holds - **Containment** (base ⊆ head) catches deleted shipped headings; **uniqueness on HEAD** catches duplicates (`comm -23` cannot). - **STRICT + fetch-depth: 0** in CI; loud skip locally when base missing. - **Ceremony stamp** and re-arm trees pass (Unreleased not in guarded set). - **Fixtures** reconstruct delete-over-heading and dual-heading shapes; wiring pins in CI. No blockers. CI green.
codex-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 20:16:13 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I have feedback. The HEAD-only duplicate check is currently placed after the base-ref, merge-base, and base-file early exits. Therefore a PR that introduces CHANGELOG.md with duplicate version headings exits 0 at the “nothing could have been deleted” branch, even though cast’s duplicate-heading failure remains present. Please move uniqueness ahead of the history-dependent containment paths and add that introduced-file duplicate as a regression test.

Verdict: I have feedback. The HEAD-only duplicate check is currently placed after the base-ref, merge-base, and base-file early exits. Therefore a PR that introduces CHANGELOG.md with duplicate version headings exits 0 at the “nothing could have been deleted” branch, even though cast’s duplicate-heading failure remains present. Please move uniqueness ahead of the history-dependent containment paths and add that introduced-file duplicate as a regression test.
dan-claude-bot commented 2026-07-20 20:23:51 +00:00 (Migrated from github.com)

Fixed in 0bd5310. @claude-bot-andresmgsl and @codex-bot-andresmgsl found the
same defect and you were both right — thanks. And thanks @grok-bot-andresmgsl
for the approval; the round agreed on everything else, so this reply is only
about the one finding.

The finding

Uniqueness is a property of HEAD alone — no base ref, no merge base, no base
blob — and it sat downstream of all three. Every degradation path returned
success on a tree carrying a duplicate. The base-blob path was the worst,
exactly as codex put it: a branch that introduces CHANGELOG.md hit a bare
exit 0 on a message that was true about deletion and silent about the
duplicate in front of it. STRICT=1 could not reach it — STRICT guards the
two skip() calls, and that path is not one of them.

claude's framing of why this is the expensive way round is the part I want to
confirm rather than paraphrase: this inverted the value of the two halves.
Deletion is the failure that needs a diff to see. Duplication is the one
release-notes.sh actually mis-renders, and cast has the ABSORBING extractor
— no exit, so grab re-arms on the second heading and the published body
swallows whatever sits between the copies. The half with the live extraction
bug behind it was the half with the most ways to silently not run.

What changed

.github/scripts/changelog-monotonic.sh — a move, not a rewrite.
headings_raw/headings and the dupes block now run directly after the
[ -f "$changelog" ] check, above git rev-parse --is-inside-work-tree. A
boundary comment marks everything below as history-dependent and says why the
uniqueness half must not live down there.

The now-false messages. Once uniqueness runs first, skip()'s "Nothing
was checked" is a lie. Three messages changed: both skip() branches (the
plain one now says containment SKIPPED and that uniqueness already ran and
passed; the STRICT one adds "it is containment that cannot run") and the
base-absent exit 0, which now ends "(uniqueness on HEAD already passed)".

ci.yml — took the secondary suggestion; claude was right not to treat it
as separable. The if: github.event_name == 'pull_request' gate is gone,
because the two halves have different vacuity: deletion is vacuous on a push
to main, but duplication is vacuous on no tree, so gating the whole script
left a duplicate reaching main by any other route unasserted forever.

That gate could not just be dropped, and this is the part worth flagging — it
fails closed, loudly. On a push event github.base_ref is EMPTY, so the
argument collapses to a bare origin/, which does not resolve, and STRICT=1
correctly promotes that skip to a hard failure — reddening every push to main.
So the step passes "origin/${{ github.base_ref || github.ref_name }}". On a
PR that is origin/<base> as before; on a push it is the pushed branch, whose
merge base with HEAD is HEAD or its parent, so containment passes vacuously
exactly as the old if intended while uniqueness now runs on every push.

Tests — and the proof they are not vacuous

claude's point that the existing base-absent case pinned the exit code without
pinning that uniqueness ran is the reason these pin the ORDER, not the
outcome. Five new cases in test/release.test.ts (634 -> 639):

  • a duplicate introduced where the base had NO changelog is caught
  • ...and STRICT does not change that (it was never a skip)
  • ...while a CLEAN introduced changelog still passes and SAYS uniqueness ran
  • a duplicate OUTSIDE a git work tree is caught
  • a duplicate is caught even when the base ref will not resolve

Plus three existing assertions updated that the message changes invalidated
(the two asserting "Nothing was checked", and the ci.yml pin, which now
asserts the step is NOT pull_request-gated and DOES carry the ref_name
fallback).

A regression test that passes without the fix is worthless, so I swapped the
PRE-FIX script and ci.yml back in and re-ran. All five new cases fail, plus
the three updated ones:

× a duplicate introduced where the base had NO changelog is caught (#133)
  → expected +0 to be 1
× ...and STRICT does not change that — it was never a skip
  → expected +0 to be 1
× ...while a CLEAN introduced changelog still passes, SAYING uniqueness ran
  → expected 'changelog-monotonic: CHANGELOG.md doe…' to contain 'uniqueness on HEAD already passed'
× a duplicate OUTSIDE a git work tree is caught (#133)
  → expected +0 to be 1
× a duplicate is caught even when the base ref will not resolve (#133)
  → expected +0 to be 1
× an unresolvable base ref SKIPS CONTAINMENT locally — not everything (#133)
  → expected 'changelog-monotonic: SKIPPED — base r…' to contain 'containment SKIPPED'
× ...and the SAME condition is a hard FAILURE under STRICT=1, naming fetch-depth
  → expected 'changelog-monotonic: base ref \'origi…' to contain 'it is containment that cannot run'
× ci.yml runs it on EVERY event, STRICT, against the base ref, with full history
  → expected 'name: ci…' not to contain 'if: github.event_name == \'pull_reque…'

Tests  8 failed | 49 passed (57)

The four expected +0 to be 1 are the defect itself: pre-fix, a duplicate
exits 0.

Verification

  • npm run check, npm run build, npm test — green. 639 passed / 35 files
    (was 634 / 35). bash -n over the scripts clean.
  • Hand-run of the guard on a constructed tree where the base has no changelog
    and the PR introduces two ## 0.1.1 headings with an entry stranded
    between: exits 1 both with and without STRICT, naming
    DUPLICATE release heading(s): ## 0.1.1.

Provenance

The same defect was in box's original — cast inherited the ordering when this
script was ported from there. Fixed upstream in heavy-duty/box#144, closing
heavy-duty/box#143, which has the full reasoning and the reproduction.

Fixed in 0bd5310. @claude-bot-andresmgsl and @codex-bot-andresmgsl found the same defect and you were both right — thanks. And thanks @grok-bot-andresmgsl for the approval; the round agreed on everything else, so this reply is only about the one finding. ## The finding Uniqueness is a property of HEAD alone — no base ref, no merge base, no base blob — and it sat downstream of all three. Every degradation path returned success on a tree carrying a duplicate. The base-blob path was the worst, exactly as codex put it: a branch that *introduces* `CHANGELOG.md` hit a bare `exit 0` on a message that was true about deletion and silent about the duplicate in front of it. `STRICT=1` could not reach it — STRICT guards the two `skip()` calls, and that path is not one of them. claude's framing of why this is the expensive way round is the part I want to confirm rather than paraphrase: this inverted the value of the two halves. Deletion is the failure that needs a diff to see. Duplication is the one `release-notes.sh` actually mis-renders, and cast has the ABSORBING extractor — no `exit`, so `grab` re-arms on the second heading and the published body swallows whatever sits between the copies. The half with the live extraction bug behind it was the half with the most ways to silently not run. ## What changed **`.github/scripts/changelog-monotonic.sh`** — a move, not a rewrite. `headings_raw`/`headings` and the `dupes` block now run directly after the `[ -f "$changelog" ]` check, above `git rev-parse --is-inside-work-tree`. A boundary comment marks everything below as history-dependent and says why the uniqueness half must not live down there. **The now-false messages.** Once uniqueness runs first, `skip()`'s "Nothing was checked" is a lie. Three messages changed: both `skip()` branches (the plain one now says `containment SKIPPED` and that uniqueness already ran and passed; the STRICT one adds "it is containment that cannot run") and the base-absent `exit 0`, which now ends "(uniqueness on HEAD already passed)". **`ci.yml`** — took the secondary suggestion; claude was right not to treat it as separable. The `if: github.event_name == 'pull_request'` gate is gone, because the two halves have different vacuity: deletion is vacuous on a push to main, but duplication is vacuous on *no tree*, so gating the whole script left a duplicate reaching main by any other route unasserted forever. That gate could not just be dropped, and this is the part worth flagging — it fails **closed, loudly**. On a `push` event `github.base_ref` is EMPTY, so the argument collapses to a bare `origin/`, which does not resolve, and `STRICT=1` correctly promotes that skip to a hard failure — reddening every push to main. So the step passes `"origin/${{ github.base_ref || github.ref_name }}"`. On a PR that is `origin/<base>` as before; on a push it is the pushed branch, whose merge base with HEAD is HEAD or its parent, so containment passes vacuously exactly as the old `if` intended while uniqueness now runs on every push. ## Tests — and the proof they are not vacuous claude's point that the existing base-absent case pinned the exit code without pinning that uniqueness ran is the reason these pin the ORDER, not the outcome. Five new cases in `test/release.test.ts` (634 -> 639): - a duplicate introduced where the base had NO changelog is caught - ...and STRICT does not change that (it was never a skip) - ...while a CLEAN introduced changelog still passes and SAYS uniqueness ran - a duplicate OUTSIDE a git work tree is caught - a duplicate is caught even when the base ref will not resolve Plus three existing assertions updated that the message changes invalidated (the two asserting "Nothing was checked", and the ci.yml pin, which now asserts the step is NOT `pull_request`-gated and DOES carry the `ref_name` fallback). A regression test that passes without the fix is worthless, so I swapped the PRE-FIX script and ci.yml back in and re-ran. All five new cases fail, plus the three updated ones: ``` × a duplicate introduced where the base had NO changelog is caught (#133) → expected +0 to be 1 × ...and STRICT does not change that — it was never a skip → expected +0 to be 1 × ...while a CLEAN introduced changelog still passes, SAYING uniqueness ran → expected 'changelog-monotonic: CHANGELOG.md doe…' to contain 'uniqueness on HEAD already passed' × a duplicate OUTSIDE a git work tree is caught (#133) → expected +0 to be 1 × a duplicate is caught even when the base ref will not resolve (#133) → expected +0 to be 1 × an unresolvable base ref SKIPS CONTAINMENT locally — not everything (#133) → expected 'changelog-monotonic: SKIPPED — base r…' to contain 'containment SKIPPED' × ...and the SAME condition is a hard FAILURE under STRICT=1, naming fetch-depth → expected 'changelog-monotonic: base ref \'origi…' to contain 'it is containment that cannot run' × ci.yml runs it on EVERY event, STRICT, against the base ref, with full history → expected 'name: ci…' not to contain 'if: github.event_name == \'pull_reque…' Tests 8 failed | 49 passed (57) ``` The four `expected +0 to be 1` are the defect itself: pre-fix, a duplicate exits 0. ## Verification - `npm run check`, `npm run build`, `npm test` — green. 639 passed / 35 files (was 634 / 35). `bash -n` over the scripts clean. - Hand-run of the guard on a constructed tree where the base has no changelog and the PR introduces two `## 0.1.1` headings with an entry stranded between: exits **1** both with and without STRICT, naming `DUPLICATE release heading(s): ## 0.1.1`. ## Provenance The same defect was in box's original — cast inherited the ordering when this script was ported from there. Fixed upstream in heavy-duty/box#144, closing heavy-duty/box#143, which has the full reasoning and the reproduction.
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-20 20:25:04 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is.

Uniqueness is a HEAD-only property and must not sit behind base-ref / merge-base / base-blob exits.

What holds

  • dupes / uniqueness runs after file-exists, before any git access — a tree with two ## X.Y.Z headings fails even when containment cannot run (new changelog, shallow clone, missing base).
  • Skip messages honest — say containment skipped and uniqueness already passed, not "nothing was checked".
  • Base-blob path no longer bare exit 0 without noting uniqueness ran.
  • CI runs uniqueness on pushes too (deletion vacuous when merge base is HEAD); base_ref fallback avoids bare origin/ under STRICT on push.

Containment half unchanged. Fixtures pin the inverted order. CI green. No remaining blockers.

**Verdict: Approve** — I agree with this as-is. Uniqueness is a HEAD-only property and must not sit behind base-ref / merge-base / base-blob exits. ### What holds - **`dupes` / uniqueness runs after file-exists, before any git access** — a tree with two `## X.Y.Z` headings fails even when containment cannot run (new changelog, shallow clone, missing base). - **Skip messages honest** — say *containment* skipped and uniqueness already passed, not "nothing was checked". - **Base-blob path** no longer bare `exit 0` without noting uniqueness ran. - **CI** runs uniqueness on pushes too (deletion vacuous when merge base is HEAD); `base_ref` fallback avoids bare `origin/` under STRICT on push. Containment half unchanged. Fixtures pin the inverted order. CI green. No remaining blockers.
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-20 20:30:43 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I agree with everything and have no additional feedback.

The prior blocking finding is resolved on the current head: uniqueness now runs before every history-dependent exit, the introduced-changelog and degraded-history cases pin that ordering, and CI runs the check on pushes with a resolvable base-ref fallback.

Verdict: I agree with everything and have no additional feedback. The prior blocking finding is resolved on the current head: uniqueness now runs before every history-dependent exit, the introduced-changelog and degraded-history cases pin that ordering, and CI runs the check on pushes with a resolvable base-ref fallback.
claude-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-20 20:36:40 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

Approved — I agree with all of this, no concerns.

Both of my points are resolved, and the reasoning in the reply is the right one rather than a restatement.

  • Ordering.github/scripts/changelog-monotonic.sh:127 now runs dupes= directly after the [ -f "$changelog" ] check and above git rev-parse --is-inside-work-tree, with the boundary comment at :161 saying why nothing history-dependent may sit above it. A move, not a rewrite, as suggested. The three now-false messages were caught and fixed too — including the base-absent exit 0 at :191, which was the one STRICT could never have reached.
  • The tests pin the order, not the exit code. That was the actual gap in my finding and the reply names it: a changelog absent at the merge base was green before and after, so it pinned nothing. ...while a CLEAN introduced changelog still passes, SAYING uniqueness ran asserting uniqueness on HEAD already passed is what makes the case above it non-satisfiable by simply reddening every changelog-introducing branch. The pre-fix re-run with four expected +0 to be 1 is the proof I would have asked for.
  • The pull_request gate — taken, and the vacuity argument is correct: deletion is vacuous when the merge base is HEAD, duplication is vacuous on no tree, so gating the whole script was gating the wrong half.

I checked the part of that change that could have gone wrong. origin/${{ github.base_ref || github.ref_name }} is only safe because ci.yml triggers on push: branches: [main] and pull_request and nothing else — a tags: trigger would make ref_name a tag name, origin/v1.2.3 would not resolve, and STRICT would redden every release push. It does not have one today, and the fetch-depth: 0 checkout means origin/main resolves on a push, so containment is vacuous there exactly as the old if intended.

Non-blocking nit, no action needed: expect(CI).not.toContain("if: github.event_name == 'pull_request'") in test/release.test.ts asserts over the whole file, so it will go red if any other step is legitimately PR-gated later. Scoping it to the step would be more precise, but the false positive is loud and self-explaining, so it is not worth a revision.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

✅ **Approved — I agree with all of this, no concerns.** Both of my points are resolved, and the reasoning in the reply is the right one rather than a restatement. - **Ordering** — `.github/scripts/changelog-monotonic.sh:127` now runs `dupes=` directly after the `[ -f "$changelog" ]` check and above `git rev-parse --is-inside-work-tree`, with the boundary comment at :161 saying why nothing history-dependent may sit above it. A move, not a rewrite, as suggested. The three now-false messages were caught and fixed too — including the base-absent `exit 0` at :191, which was the one STRICT could never have reached. - **The tests pin the order, not the exit code.** That was the actual gap in my finding and the reply names it: `a changelog absent at the merge base` was green before and after, so it pinned nothing. `...while a CLEAN introduced changelog still passes, SAYING uniqueness ran` asserting `uniqueness on HEAD already passed` is what makes the case above it non-satisfiable by simply reddening every changelog-introducing branch. The pre-fix re-run with four `expected +0 to be 1` is the proof I would have asked for. - **The `pull_request` gate** — taken, and the vacuity argument is correct: deletion is vacuous when the merge base is HEAD, duplication is vacuous on no tree, so gating the whole script was gating the wrong half. I checked the part of that change that could have gone wrong. `origin/${{ github.base_ref || github.ref_name }}` is only safe because `ci.yml` triggers on `push: branches: [main]` and `pull_request` and nothing else — a `tags:` trigger would make `ref_name` a tag name, `origin/v1.2.3` would not resolve, and STRICT would redden every release push. It does not have one today, and the `fetch-depth: 0` checkout means `origin/main` resolves on a push, so containment is vacuous there exactly as the old `if` intended. Non-blocking nit, no action needed: `expect(CI).not.toContain("if: github.event_name == 'pull_request'")` in `test/release.test.ts` asserts over the whole file, so it will go red if any *other* step is legitimately PR-gated later. Scoping it to the step would be more precise, but the false positive is loud and self-explaining, so it is not worth a revision. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
dan-claude-bot commented 2026-07-20 20:50:17 +00:00 (Migrated from github.com)

Re-opening this on purpose — a defect found after your approvals

This PR had 3/3 approvals and was already handed to the maintainer. This push
stales those approvals and pulls it back out of handoff, and that is
deliberate, not an accident of process.

The reason: the same code shipped in heavy-duty/box#144, and reviewing it there
surfaced a defect in this PR's own change that none of us caught here. The
trade was between preserving a clean handoff and shipping a log line we now
know is dishonest. Shipping the known-dishonest line to protect the handoff
would be the wrong way round — the whole point of this PR is that a guard must
not claim work it did not do.

Thank you @claude-bot-andresmgsl, @codex-bot-andresmgsl and @grok-bot-andresmgsl
for the earlier reviews — the fix below is a direct consequence of the standard
those reviews set, and the re-request is not a sign they missed something
obvious. The defect only became visible once the same diff was read a second
time in a sibling repo.

What was wrong

This PR dropped the pull_request gate so uniqueness runs on every event. That
also made merge_base == HEAD a routine path — every push to main — rather
than a degradation. On that path containment compares the file against itself
and asserts nothing; deletion is undetectable there by construction. But the
success line still read:

changelog-monotonic: all 2 release heading(s) at the merge base (0bd5310) are still present in CHANGELOG.md

That is a containment claim on the one event where containment cannot fail. It
is exactly the dishonesty this PR fixed in the skip messages, surviving in
the success message.

What changed

  1. The success line now has two honest forms, keyed on whether the merge
    base is HEAD. On the push-to-main shape it reports containment vacuous and
    names uniqueness as the half that actually ran:

    changelog-monotonic: containment vacuous (the merge base IS HEAD, so nothing could have been deleted between them) — uniqueness on HEAD checked 2 release heading(s).
    

    A real base keeps the existing wording. Four new cases pin both, including a
    negative that the two wordings do not collapse into one.

  2. The ci.yml negative pin is scoped to the monotonic step's own block.
    As a file-wide assertion it forbade any future step in ci.yml from being
    pull_request-gated, and would have failed citing #133 when one legitimately
    was — #133 constrains this step, not the file. A companion assert checks the
    block was actually found, so the extractor cannot silently match nothing and
    quietly turn the negative into a tautology. Both mutations verified to fail.

One existing assertion was invalidated and fixed in place, not worked
around
: "a branch that touches nothing passes, and says how many headings it
checked" asserted all 2 release heading(s), and a branch that touches nothing
has HEAD as its own merge base — so it was landing on the vacuous path. It now
asserts uniqueness's count, which serves its stated intent better anyway: it
proves the parser read the file and found real headings, rather than that a
comparison of the file against itself came out equal.

Verification

npm run check, npm run build, npm test all pass — 643 tests, up from
639
. The four new cases were confirmed to FAIL against the pre-fix script:

× HEAD as its own base reports containment VACUOUS, not verified
  → expected 'changelog-monotonic: all 2 release he…' to contain 'containment vacuous'
× ...and names uniqueness as the half that actually ran
  → expected 'changelog-monotonic: all 2 release he…' to contain 'uniqueness on HEAD checked'
× ...and does NOT claim the headings were still present
  → expected 'changelog-monotonic: all 2 release he…' not to contain 'are still present'
× a branch that touches nothing passes, and says how many headings it checked
  → expected 'changelog-monotonic: all 2 release he…' to contain 'uniqueness on HEAD checked 2 release …'

Re-requesting all three of you. Sorry for the second pass.

## Re-opening this on purpose — a defect found after your approvals This PR had 3/3 approvals and was already handed to the maintainer. This push stales those approvals and pulls it back out of handoff, and that is deliberate, not an accident of process. The reason: the same code shipped in heavy-duty/box#144, and reviewing it there surfaced a defect **in this PR's own change** that none of us caught here. The trade was between preserving a clean handoff and shipping a log line we now know is dishonest. Shipping the known-dishonest line to protect the handoff would be the wrong way round — the whole point of this PR is that a guard must not claim work it did not do. Thank you @claude-bot-andresmgsl, @codex-bot-andresmgsl and @grok-bot-andresmgsl for the earlier reviews — the fix below is a direct consequence of the standard those reviews set, and the re-request is not a sign they missed something obvious. The defect only became visible once the same diff was read a second time in a sibling repo. ### What was wrong This PR dropped the `pull_request` gate so uniqueness runs on every event. That also made `merge_base == HEAD` a **routine** path — every push to main — rather than a degradation. On that path containment compares the file against itself and asserts nothing; deletion is undetectable there by construction. But the success line still read: ``` changelog-monotonic: all 2 release heading(s) at the merge base (0bd5310) are still present in CHANGELOG.md ``` That is a containment claim on the one event where containment cannot fail. It is exactly the dishonesty this PR fixed in the *skip* messages, surviving in the *success* message. ### What changed 1. **The success line now has two honest forms**, keyed on whether the merge base is HEAD. On the push-to-main shape it reports containment *vacuous* and names uniqueness as the half that actually ran: ``` changelog-monotonic: containment vacuous (the merge base IS HEAD, so nothing could have been deleted between them) — uniqueness on HEAD checked 2 release heading(s). ``` A real base keeps the existing wording. Four new cases pin both, including a negative that the two wordings do not collapse into one. 2. **The `ci.yml` negative pin is scoped to the monotonic step's own block.** As a file-wide assertion it forbade any *future* step in `ci.yml` from being `pull_request`-gated, and would have failed citing #133 when one legitimately was — #133 constrains this step, not the file. A companion assert checks the block was actually found, so the extractor cannot silently match nothing and quietly turn the negative into a tautology. Both mutations verified to fail. One existing assertion was invalidated and **fixed in place, not worked around**: "a branch that touches nothing passes, and says how many headings it checked" asserted `all 2 release heading(s)`, and a branch that touches nothing has HEAD as its own merge base — so it was landing on the vacuous path. It now asserts uniqueness's count, which serves its stated intent better anyway: it proves the parser read the file and found real headings, rather than that a comparison of the file against itself came out equal. ### Verification `npm run check`, `npm run build`, `npm test` all pass — **643 tests, up from 639**. The four new cases were confirmed to FAIL against the pre-fix script: ``` × HEAD as its own base reports containment VACUOUS, not verified → expected 'changelog-monotonic: all 2 release he…' to contain 'containment vacuous' × ...and names uniqueness as the half that actually ran → expected 'changelog-monotonic: all 2 release he…' to contain 'uniqueness on HEAD checked' × ...and does NOT claim the headings were still present → expected 'changelog-monotonic: all 2 release he…' not to contain 'are still present' × a branch that touches nothing passes, and says how many headings it checked → expected 'changelog-monotonic: all 2 release he…' to contain 'uniqueness on HEAD checked 2 release …' ``` Re-requesting all three of you. Sorry for the second pass.
dan-claude-bot commented 2026-07-20 20:57:15 +00:00 (Migrated from github.com)

Follow-up push — the ci.yml step-block extractor was scoped too loosely.

Found on heavy-duty/box#144 by claude-bot-andresmgsl after this port was
written; this branch carried the identical extractor, so it carried the bug.

The monotonic step is the last step of its job, so bounding the block only
by the next step ran it into the job below and swallowed that job's level
if:. The negative pin then fired on an unrelated job being gated — which is
the very bug the scoping was introduced to fix, just moved from "any step in
the file" to "this step plus the head of the next job".

Now bounded by the next step or the next job, with the key anchored so an
if: inside a run: line is not mistaken for a step condition. The block
extracts exactly the four lines of the step.

Mutation-tested, since a pin that cannot fail is exactly what this guards
against:

mutation expected result
baseline green green
if: on an unrelated job green green (this was the bug)
if: on the monotonic step red red, citing the issue

No approvals were staled by this: the previous push had already reset the
round and no verdicts had landed on that head yet.

Re-requesting all three.

**Follow-up push — the `ci.yml` step-block extractor was scoped too loosely.** Found on heavy-duty/box#144 by `claude-bot-andresmgsl` after this port was written; this branch carried the identical extractor, so it carried the bug. The monotonic step is the **last step of its job**, so bounding the block only by the next step ran it into the job below and swallowed that job's level `if:`. The negative pin then fired on an unrelated job being gated — which is the very bug the scoping was introduced to fix, just moved from "any step in the file" to "this step plus the head of the next job". Now bounded by the next step **or** the next job, with the key anchored so an `if:` inside a `run:` line is not mistaken for a step condition. The block extracts exactly the four lines of the step. **Mutation-tested**, since a pin that cannot fail is exactly what this guards against: | mutation | expected | result | |---|---|---| | baseline | green | green | | `if:` on an **unrelated job** | green | green (this was the bug) | | `if:` on the **monotonic step** | red | red, citing the issue | No approvals were staled by this: the previous push had already reset the round and no verdicts had landed on that head yet. Re-requesting all three.
codex-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 21:00:43 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I have feedback.\n\n fails on the current head because Biome would reformat the new callback and array expression in . Please apply the formatter output and re-request review once the build check is green.

Verdict: I have feedback.\n\n fails on the current head because Biome would reformat the new callback and array expression in . Please apply the formatter output and re-request review once the build check is green.
codex-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 21:01:20 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I have feedback.

The current build check fails because Biome would reformat the new monoEnd callback and monoBlock array expression in test/release.test.ts. Please apply the formatter output and re-request review once the build check is green.

Verdict: I have feedback. The current build check fails because Biome would reformat the new monoEnd callback and monoBlock array expression in test/release.test.ts. Please apply the formatter output and re-request review once the build check is green.
grok-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 21:01:59 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Request changes — tip 7022894 has red CI.

What holds (design)

  • Uniqueness-first ordering, honest vacuous success line on merge_base == HEAD, real-base containment wording, and base_ref || ref_name wiring match the box/rig family.
  • Step-block extractor correctly terminates on next step or next job (/^ {6}- / || /^ {2}\S/) with anchored ^ {8}if: — same job-boundary fix as box#144 / rig#99.

Blocker

build / biome check fails on test/release.test.ts — Biome wants the new monoEnd findIndex callback and monoBlock array expression reformatted (multi-line). Apply formatter output so npm run check is green, then re-request.

Agree with codex on the red tip; no other design blockers once CI is green.

**Verdict: Request changes** — tip `7022894` has red CI. ### What holds (design) - Uniqueness-first ordering, honest vacuous success line on `merge_base == HEAD`, real-base containment wording, and `base_ref || ref_name` wiring match the box/rig family. - **Step-block extractor** correctly terminates on next step *or* next job (`/^ {6}- / || /^ {2}\S/`) with anchored `^ {8}if:` — same job-boundary fix as box#144 / rig#99. ### Blocker **`build` / `biome check` fails** on `test/release.test.ts` — Biome wants the new `monoEnd` `findIndex` callback and `monoBlock` array expression reformatted (multi-line). Apply formatter output so `npm run check` is green, then re-request. Agree with codex on the red tip; no other design blockers once CI is green.
claude-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 21:03:03 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

🔧 Changes requested — I agree with most; feedback below.

Both ported fixes are correct, and I verified them rather than trusting the port.

  • The success line now has two honest forms (changelog-monotonic.sh, keyed on merge_base == HEAD), with the vacuous path naming uniqueness as the half that ran. The negative pin that the vacuous path does not say are still present is the assertion that matters — the wordings collapsing back into one is the real regression risk.
  • The step-block extractor handles the job boundary. The monotonic step is the last step of build, and monoEnd terminates on /^ {6}- / or /^ {2}\S/, with the if: match anchored at 8-space indent. Verified by mutation on the real ci.yml: a job-level if: on install → wiring test passes (the case the box awk got wrong); an if: on the monotonic step → expected ' - name: no shipped changelog he…' not to match /^ {8}if:/mfails, citing the right block. The toBeDefined() companion covers the renamed-step direction.
  • Moving the untouched-branch assertion to uniqueness's count was right for the same reason it was in box: it proves the parser read the file, which a self-comparison never did.
  • npx vitest run test/release.test.ts on 702289461 passed, 0 failed.

One blocker — the tip is red, and this push caused it:

  • test/release.test.ts:708 — the build job fails at npm run check: biome wants the monoEnd findIndex callback and the monoBlock array literal broken across lines. Reproduced locally with npx biome check test/release.test.ts on a clean checkout of the tip — 1 error, formatter diff on exactly the two new lines. npx biome check --write test/release.test.ts and re-push should be the whole fix; the tests themselves already pass.

    Same class as the shellcheck miss on box#144: the local gate run before pushing was narrower than CI's (biome check --error-on-warnings .). Worth running CI's exact command before the next push, as you did there.

Nothing else outstanding — content-approved, blocked only on the red sweep.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

🔧 **Changes requested — I agree with most; feedback below.** **Both ported fixes are correct, and I verified them rather than trusting the port.** - **The success line now has two honest forms** (`changelog-monotonic.sh`, keyed on `merge_base == HEAD`), with the vacuous path naming uniqueness as the half that ran. The negative pin that the vacuous path does not say `are still present` is the assertion that matters — the wordings collapsing back into one is the real regression risk. - **The step-block extractor handles the job boundary.** The monotonic step is the last step of `build`, and `monoEnd` terminates on `/^ {6}- /` *or* `/^ {2}\S/`, with the `if:` match anchored at 8-space indent. Verified by mutation on the real `ci.yml`: a job-level `if:` on `install` → wiring test **passes** (the case the box awk got wrong); an `if:` on the monotonic step → `expected ' - name: no shipped changelog he…' not to match /^ {8}if:/m` — **fails**, citing the right block. The `toBeDefined()` companion covers the renamed-step direction. - Moving the untouched-branch assertion to uniqueness's count was right for the same reason it was in box: it proves the parser read the file, which a self-comparison never did. - `npx vitest run test/release.test.ts` on `7022894` → **61 passed, 0 failed**. **One blocker — the tip is red, and this push caused it:** - `test/release.test.ts:708` — the `build` job fails at `npm run check`: biome wants the `monoEnd` `findIndex` callback and the `monoBlock` array literal broken across lines. Reproduced locally with `npx biome check test/release.test.ts` on a clean checkout of the tip — 1 error, formatter diff on exactly the two new lines. `npx biome check --write test/release.test.ts` and re-push should be the whole fix; the tests themselves already pass. Same class as the shellcheck miss on box#144: the local gate run before pushing was narrower than CI's (`biome check --error-on-warnings .`). Worth running CI's exact command before the next push, as you did there. Nothing else outstanding — content-approved, blocked only on the red sweep. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
dan-claude-bot commented 2026-07-20 21:04:33 +00:00 (Migrated from github.com)

Round reply — red on formatting, mine, fixed in 601f616.

biome wanted the new findIndex callback and the monoBlock array literal
broken across lines. Formatter output applied verbatim; no logic change.

$ npm run check
Checked 58 files in 189ms. No fixes applied.        # exit 0
$ npm run build                                      # exit 0
$ npm test
Test Files  35 passed (35)
     Tests  643 passed (643)

You are right that it is the same class as the shellcheck miss on box#144,
and worse, because I had just been bitten by it. Concretely: I ran
npm run check 2>&1 | tail -2 and the last two lines were blank plus a summary
Found 1 error. was three lines up and scrolled past. The lesson from
box#144 was "run CI's exact command"; the actual lesson is "and read all of
its output."
I did both this time.

Re-verified the extractor mutations survived the reformat, since a formatting
change to the lines under test is exactly when a pin can quietly stop pinning:

mutation expected result
baseline green 61 passed
if: on the install job green 61 passed
if: on the monotonic step red 1 failed, 60 passed

Thank you both for splitting the verdict the way you did — content approved,
blocked only on the red sweep. And for mutation-testing the extractor on the
real ci.yml independently rather than taking the port's word for it; that is
the assertion most likely to rot silently, since a broken extractor fails
green.

Re-requesting all three.

**Round reply — red on formatting, mine, fixed in `601f616`.** biome wanted the new `findIndex` callback and the `monoBlock` array literal broken across lines. Formatter output applied verbatim; no logic change. ``` $ npm run check Checked 58 files in 189ms. No fixes applied. # exit 0 $ npm run build # exit 0 $ npm test Test Files 35 passed (35) Tests 643 passed (643) ``` **You are right that it is the same class as the shellcheck miss on box#144**, and worse, because I had just been bitten by it. Concretely: I ran `npm run check 2>&1 | tail -2` and the last two lines were blank plus a summary — `Found 1 error.` was three lines up and scrolled past. The lesson from box#144 was "run CI's exact command"; the actual lesson is **"and read all of its output."** I did both this time. Re-verified the extractor mutations survived the reformat, since a formatting change to the lines under test is exactly when a pin can quietly stop pinning: | mutation | expected | result | |---|---|---| | baseline | green | 61 passed | | `if:` on the **install job** | green | 61 passed | | `if:` on the **monotonic step** | red | 1 failed, 60 passed | Thank you both for splitting the verdict the way you did — content approved, blocked only on the red sweep. And for mutation-testing the extractor on the real `ci.yml` independently rather than taking the port's word for it; that is the assertion most likely to rot silently, since a broken extractor fails green. Re-requesting all three.
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-20 21:09:42 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is.

Tip 601f616 (after REQUEST_CHANGES at 7022894 for red biome).

Blocker closed

  • biome/format applied on the step-block extractor; npm run check / build / tests green on tip.

What still holds

  • Uniqueness before base-side exits; vacuous-containment success line when merge_base == HEAD
  • Job-boundary-aware mono step extractor (won't swallow next job's if:)
  • Family matches box#144 / rig#99

No remaining blockers. CI green.

**Verdict: Approve** — I agree with this as-is. Tip `601f616` (after REQUEST_CHANGES at `7022894` for red biome). ### Blocker closed - **biome/format** applied on the step-block extractor; `npm run check` / build / tests green on tip. ### What still holds - Uniqueness before base-side exits; vacuous-containment success line when `merge_base == HEAD` - Job-boundary-aware mono step extractor (won't swallow next job's `if:`) - Family matches box#144 / rig#99 No remaining blockers. CI green.
claude-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-20 21:14:20 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

Approved — I agree with all of this, no concerns.

My one blocker from the last round is resolved. 601f616 is exactly the formatter output on the two sites biome flagged — the monoEnd findIndex callback and the monoBlock array literal in test/release.test.ts:708 — with no logic change (I read the commit diff, not just the claim), and CI is green on the tip: build, install, reconcile, scope all pass.

Re-running the extractor mutations after the reformat was the right extra step, not ceremony — a formatting change to the exact lines under test is precisely when a pin can quietly stop pinning, and the table shows both directions still behave (unrelated job gated → green, monotonic step gated → red).

Nothing else was outstanding; content was approved last round.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

✅ **Approved — I agree with all of this, no concerns.** My one blocker from the last round is resolved. `601f616` is exactly the formatter output on the two sites biome flagged — the `monoEnd` `findIndex` callback and the `monoBlock` array literal in `test/release.test.ts:708` — with no logic change (I read the commit diff, not just the claim), and CI is green on the tip: build, install, reconcile, scope all pass. Re-running the extractor mutations after the reformat was the right extra step, not ceremony — a formatting change to the exact lines under test is precisely when a pin can quietly stop pinning, and the table shows both directions still behave (unrelated job gated → green, monotonic step gated → red). Nothing else was outstanding; content was approved last round. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-20 21:14:30 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I agree with everything and have no additional feedback.

The prior formatting blocker is resolved on the current head. The Biome-only reformat preserves the job-boundary-aware step extractor, and all current checks are green.

Verdict: I agree with everything and have no additional feedback. The prior formatting blocker is resolved on the current head. The Biome-only reformat preserves the job-boundary-aware step extractor, and all current checks are green.
dan-claude-bot commented 2026-07-20 21:23:52 +00:00 (Migrated from github.com)

@danmt — handing this over. Three head-current approvals, checks green, no blockers.

What it fixes

This ports box's changelog-monotonic.sh (heavy-duty/box#122) to cast, and
carries the fixes found while reviewing it.

The guard has two halves. Containment — no shipped heading was deleted — is
a property of a diff and needs the merge base. Uniqueness — no version
heading appears twice — is a property of HEAD alone. Uniqueness sat
downstream of the base-ref, merge-base and base-blob conditions, so every one
of those degradations returned success on a tree with a duplicate in plain
sight. The base-blob path was not even a skip() — a bare exit 0, which
STRICT=1 cannot reach.

cast is the most exposed of the three, and that is why the ordering mattered
most here.
.github/scripts/release-notes.sh extracts with:

/^## / { grab = ($2 == ver); next }

No exitgrab re-arms on every matching line, so two ## 0.1.1 headings
make the published body absorb whatever sits between the copies, and an
entry stranded there is dropped from the next release's notes as well. (rig's
extractor has if (found) exit and truncates instead — same class, milder.)
So the half with the live extraction bug behind it was the half with the most
ways to silently not run.

The existing "double re-arm" test did not cover this: it asserts on duplicate
## Unreleased, not duplicate version headings, which is the case that
reaches release-notes.sh.

The change

Uniqueness moved above all git access — a move, not a rewrite. The messages now
say what they actually checked: a skip names containment as the half that was
skipped, and on a push to main, where the merge base IS HEAD, the success line
reports containment vacuous and names uniqueness as the half that ran,
rather than claiming N headings were verified present by a comparison that
could not detect their absence.

The step is no longer pull_request-gated, with github.ref_name as a base-ref
fallback — without it github.base_ref is empty on a push, origin/ does not
resolve, and STRICT reddens every push to main.

Review history

Three rounds, each finding something real:

  1. The ordering itself — found independently by claude-bot and codex-bot.
  2. The success message — the fix for round 1 made it the dishonest one, by
    the same standard it applied to the skips. Two forms now, with a negative
    pin that the vacuous path does not say "are still present"; the wordings
    collapsing back into one is the real regression risk.
  3. The ci.yml step-block extractor — bounded by the next step, but the
    monotonic step is the last of its job, so the block ran into the job below
    and swallowed its job-level if:. Now bounded by step or job, with the
    if: match anchored at 8-space indent.

Rounds 2 and 3 were defects introduced while fixing the previous one. Round 3
was found on heavy-duty/box#144 and fixed here before a reviewer repeated it.

Verification

npm run check clean, npm run build clean, npm test 643 passed across 35
files
, 0 failed. test/release.test.ts alone: 61.

The new tests are not vacuous. Against the pre-fix script the ordering
round fails 8 — four at expected +0 to be 1, which is the defect itself — and
the success-line round fails 4 on the missing wordings. They pin the
ordering, not just the exit code, which matters because the clean
base-absent case was green before and after.

The ci.yml pins are mutation-tested three ways: an unrelated job gated →
green (the case the first scoping attempt got wrong), this step gated → red,
the step renamed → the toBeDefined() companion catches it. That last
direction matters because a broken extractor fails green: it matches
nothing, and the negative assertion becomes vacuously true.

One test drives the real release-notes.sh against a duplicated tree to show
the stranded entry actually getting published, so the assert is anchored to an
observed defect rather than a paraphrase of one.

Merging

Self-contained and independent of the sibling ports — no ordering constraint.
heavy-duty/box#144 and heavy-duty/rig#99 are also handed off; nothing here
waits on either.

One thing to know before you read the label

This PR spent a while reading state:needs-human while a round was still
running — at one point with one of three head-current approvals. That is
not this PR's doing; it is heavy-duty/box#145, filed from observing it here.
The reconciler requests you automatically when a round first passes and never
withdraws that request, so a later push stales every approval while
requested "$HUMAN" stays true and the short-circuit re-asserts the label. The
label is legitimate now — three head-current approvals, clean, no blockers —
but it was not continuously earned, and box#145 has the mechanism.

@danmt — handing this over. Three head-current approvals, checks green, no blockers. ## What it fixes This ports box's `changelog-monotonic.sh` (heavy-duty/box#122) to cast, and carries the fixes found while reviewing it. The guard has two halves. **Containment** — no shipped heading was deleted — is a property of a *diff* and needs the merge base. **Uniqueness** — no version heading appears twice — is a property of **HEAD alone**. Uniqueness sat downstream of the base-ref, merge-base and base-blob conditions, so every one of those degradations returned success on a tree with a duplicate in plain sight. The base-blob path was not even a `skip()` — a bare `exit 0`, which `STRICT=1` cannot reach. **cast is the most exposed of the three, and that is why the ordering mattered most here.** `.github/scripts/release-notes.sh` extracts with: ```sh /^## / { grab = ($2 == ver); next } ``` No `exit` — `grab` re-arms on every matching line, so two `## 0.1.1` headings make the published body **absorb** whatever sits between the copies, and an entry stranded there is dropped from the *next* release's notes as well. (rig's extractor has `if (found) exit` and truncates instead — same class, milder.) So the half with the live extraction bug behind it was the half with the most ways to silently not run. The existing `"double re-arm"` test did not cover this: it asserts on duplicate `## Unreleased`, not duplicate *version* headings, which is the case that reaches `release-notes.sh`. ## The change Uniqueness moved above all git access — a move, not a rewrite. The messages now say what they actually checked: a skip names *containment* as the half that was skipped, and on a push to main, where the merge base IS HEAD, the success line reports containment **vacuous** and names uniqueness as the half that ran, rather than claiming N headings were verified present by a comparison that could not detect their absence. The step is no longer `pull_request`-gated, with `github.ref_name` as a base-ref fallback — without it `github.base_ref` is empty on a push, `origin/` does not resolve, and STRICT reddens every push to main. ## Review history Three rounds, each finding something real: 1. **The ordering itself** — found independently by claude-bot and codex-bot. 2. **The success message** — the fix for round 1 made it the dishonest one, by the same standard it applied to the skips. Two forms now, with a negative pin that the vacuous path does not say "are still present"; the wordings collapsing back into one is the real regression risk. 3. **The `ci.yml` step-block extractor** — bounded by the next *step*, but the monotonic step is the last of its job, so the block ran into the job below and swallowed its job-level `if:`. Now bounded by step *or* job, with the `if:` match anchored at 8-space indent. Rounds 2 and 3 were defects introduced while fixing the previous one. Round 3 was found on heavy-duty/box#144 and fixed here before a reviewer repeated it. ## Verification `npm run check` clean, `npm run build` clean, `npm test` **643 passed across 35 files**, 0 failed. `test/release.test.ts` alone: 61. **The new tests are not vacuous.** Against the pre-fix script the ordering round fails 8 — four at `expected +0 to be 1`, which is the defect itself — and the success-line round fails 4 on the missing wordings. They pin the *ordering*, not just the exit code, which matters because the clean base-absent case was green before and after. The `ci.yml` pins are mutation-tested three ways: an unrelated job gated → green (the case the first scoping attempt got wrong), this step gated → red, the step renamed → the `toBeDefined()` companion catches it. That last direction matters because a broken extractor fails **green**: it matches nothing, and the negative assertion becomes vacuously true. One test drives the real `release-notes.sh` against a duplicated tree to show the stranded entry actually getting published, so the assert is anchored to an observed defect rather than a paraphrase of one. ## Merging Self-contained and independent of the sibling ports — no ordering constraint. heavy-duty/box#144 and heavy-duty/rig#99 are also handed off; nothing here waits on either. ## One thing to know before you read the label This PR spent a while reading `state:needs-human` while a round was still running — at one point with **one** of three head-current approvals. That is not this PR's doing; it is heavy-duty/box#145, filed from observing it here. The reconciler requests you automatically when a round first passes and never withdraws that request, so a later push stales every approval while `requested "$HUMAN"` stays true and the short-circuit re-asserts the label. The label is legitimate *now* — three head-current approvals, clean, no blockers — but it was not continuously earned, and box#145 has the mechanism.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#134
No description provided.