changelog: nothing notices a DELETED release heading — and release-notes.sh re-arms on duplicates #133

Closed
opened 2026-07-20 19:49:34 +00:00 by dan-claude-bot · 0 comments
dan-claude-bot commented 2026-07-20 19:49:34 +00:00 (Migrated from github.com)

Nothing here notices a deleted release heading. box grew a guard for
this (heavy-duty/box#122,
caught in review of box#118); cast never got it — and cast's
release-notes.sh carries the exact awk shape that made box#118 dangerous.

What cast already has

To be clear about what is not missing: the arming rule is covered
thoroughly, in test/release.test.ts under "the changelog is armed for the
next entry (rig#66)"
— the real tree, the ceremony trees, the disarmed--dev
red case, and a double-re-arm case. That is the rig#66 fix and it works.

Arming is just narrow by design. It asks one question — does the top section
agree with package.json's version?
— about one heading, the one a PR is
about to write under. It says nothing about the rest of the file.

The gap

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

The failure is an author adding an entry under ## Unreleased who replaces
the heading below it instead of inserting above it:

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

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. 0.1.1's
body is now sitting under ## Unreleased, and 0.1.1 has no section at all.

Why cast is the more exposed of the two

box's guard also asserts version headings are unique on HEAD, because
containment cannot catch a duplicate — a duplicate is head-side surplus, and
base-minus-head is blind to it.

cast's .github/scripts/release-notes.sh is
the vulnerable shape, byte for byte:

awk -v ver="$ver" '
  /^## / { grab = ($2 == ver); next }

There is 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. That is precisely the box#118 outcome, and box's guard is the
thing that now stops it. (rig's extractor has if (found) exit, so it
truncates instead of absorbing — same class, milder symptom. cast has the
absorbing one.)

Note the existing "goes RED on a double re-arm — two Unreleased headings"
test does not cover this: it asserts on duplicate ## Unreleased, not on
duplicate version headings, which is the case that reaches
release-notes.sh.

Proposal

Port .github/scripts/changelog-monotonic.sh from box, and wire it in CI the
way box does:

- name: no shipped changelog heading was deleted
  if: github.event_name == 'pull_request'
  env:
    CHANGELOG_MONOTONIC_STRICT: '1'
  run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref }}"

Three details from box's version that should survive the port:

  • Its own file, not a clause inside the arming test. Its input is a git
    history, not two files, and its degradation is different (no base ref is a
    skip, not a failure). cast's arming assertions run against constructed
    in-memory changelog strings that are not git repos at all, so a
    git-dependent assert folded in there would either skip or lie. A shell
    script here keeps it identical to box and rig rather than reimplementing the
    invariant a third time in TypeScript — though driving it from
    release.test.ts (as cast already drives release-notes.sh) is the natural
    way to test it.
  • Pull requests only. On a push to main the merge base is HEAD, so the
    assert is vacuous.
  • STRICT=1 in CI. A checkout that cannot reach the base ref must fail
    rather than skip quietly forever — needs fetch-depth: 0 on the PR
    checkout.

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

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

Nothing here notices a **deleted** release heading. `box` grew a guard for this ([heavy-duty/box#122](https://github.com/heavy-duty/box/issues/122), caught in review of box#118); cast never got it — and cast's `release-notes.sh` carries the *exact* awk shape that made box#118 dangerous. ## What cast already has To be clear about what is *not* missing: the **arming** rule is covered thoroughly, in `test/release.test.ts` under *"the changelog is armed for the next entry (rig#66)"* — the real tree, the ceremony trees, the disarmed-`-dev` red case, and a double-re-arm case. That is the rig#66 fix and it works. Arming is just narrow by design. It asks one question — *does the top section agree with `package.json`'s version?* — about **one heading**, the one a PR is about to write under. It says nothing about the rest of the file. ## The gap Release headings are append-only: the ceremony adds one and nothing in CONTRIBUTING's release flow ever removes one. Nothing asserts that. The failure is an author adding an entry under `## Unreleased` who **replaces** the heading below it instead of inserting above it: ```diff -## 0.1.1 — 2026-07-19 +## Unreleased + +### Fixed + +- **An entry** ``` 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. `0.1.1`'s body is now sitting under `## Unreleased`, and `0.1.1` has no section at all. ## Why cast is the more exposed of the two box's guard also asserts version headings are **unique** on HEAD, because containment cannot catch a duplicate — a duplicate is head-side surplus, and base-minus-head is blind to it. cast's [.github/scripts/release-notes.sh](.github/scripts/release-notes.sh) is the vulnerable shape, byte for byte: ```sh awk -v ver="$ver" ' /^## / { grab = ($2 == ver); next } ``` There is **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. That is precisely the box#118 outcome, and box's guard is the thing that now stops it. (rig's extractor has `if (found) exit`, so it truncates instead of absorbing — same class, milder symptom. cast has the absorbing one.) Note the existing `"goes RED on a double re-arm — two Unreleased headings"` test does **not** cover this: it asserts on duplicate `## Unreleased`, not on duplicate *version* headings, which is the case that reaches `release-notes.sh`. ## Proposal Port `.github/scripts/changelog-monotonic.sh` from box, and wire it in CI the way box does: ```yaml - name: no shipped changelog heading was deleted if: github.event_name == 'pull_request' env: CHANGELOG_MONOTONIC_STRICT: '1' run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref }}" ``` Three details from box's version that should survive the port: - **Its own file, not a clause inside the arming test.** Its input is a git history, not two files, and its degradation is different (no base ref is a skip, not a failure). cast's arming assertions run against constructed in-memory changelog strings that are not git repos at all, so a git-dependent assert folded in there would either skip or lie. A shell script here keeps it identical to box and rig rather than reimplementing the invariant a third time in TypeScript — though driving it from `release.test.ts` (as cast already drives `release-notes.sh`) is the natural way to test it. - **Pull requests only.** On a push to main the merge base *is* HEAD, so the assert is vacuous. - **`STRICT=1` in CI.** A checkout that cannot reach the base ref must fail rather than skip quietly forever — needs `fetch-depth: 0` on the PR checkout. `## Unreleased` is deliberately outside the guarded set — the arming check owns that heading, and the ceremony legitimately consumes it. Sibling issue for the same gap in rig: heavy-duty/rig#98.
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#133
No description provided.