changelog: nothing notices a DELETED release heading — arming is green on a tree that erased a shipped section #98

Closed
opened 2026-07-20 19:49:32 +00:00 by dan-claude-bot · 0 comments
dan-claude-bot commented 2026-07-20 19:49:32 +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); rig never got it, and the invariant it protects
is the same one rig's release flow depends on.

What rig already has

To be clear about what is not missing: the arming rule is covered.
test/release.sh defines changelog_armed() and asserts it against the real
tree (CHANGELOG.md: armed for the VERSION it carries (#66)). 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 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.2.0 — 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. changelog_armed() stays green
and is not wrong to: the top section is still the right one for VERSION.
0.2.0's body is now sitting under ## Unreleased, and 0.2.0 has no
section at all.

It surfaces at the next release, in changelog_section()
(.github/scripts/release-lib.sh), which
anchors on the heading:

awk -v ver="$2" '
  /^## / { if (found) exit; found = ($2 == ver); next }
  ...

No heading, no section, and the release body is empty or wrong.

The duplicate half — rig fails differently from box, and it matters

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

Worth noting rig's symptom here is not box's. box's release-notes.sh
re-arms its grab on every matching ## line, so a duplicated heading makes
it absorb whatever sits between the copies. rig's changelog_section has
if (found) exit, so it stops at the first section instead — a duplicate
truncates, silently dropping everything after the second copy.

Different symptom, same class: silent, no conflict, no red run, discovered
only by a human reading the published notes. Both halves are worth having.

Proposal

Port .github/scripts/changelog-monotonic.sh from box, and wire it in
ci.yml 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 check. Its input is a git
    history, not two files; its degradation is different (no base ref is a skip,
    not a failure); and rig's arming logic is driven from test/release.sh
    against constructed non-git trees, so folding a git-dependent assert into it
    would make those cases either skip or lie.
  • 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, which ci.yml does not currently set.

## 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 cast: heavy-duty/cast#133.

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); rig never got it, and the invariant it protects is the same one rig's release flow depends on. ## What rig already has To be clear about what is *not* missing: the **arming** rule is covered. `test/release.sh` defines `changelog_armed()` and asserts it against the real tree (`CHANGELOG.md: armed for the VERSION it carries (#66)`). 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 `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.2.0 — 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. `changelog_armed()` stays green and is not wrong to: the top section is still the right one for `VERSION`. `0.2.0`'s body is now sitting under `## Unreleased`, and `0.2.0` has no section at all. It surfaces at the next release, in `changelog_section()` ([.github/scripts/release-lib.sh](.github/scripts/release-lib.sh)), which anchors on the heading: ```sh awk -v ver="$2" ' /^## / { if (found) exit; found = ($2 == ver); next } ... ``` No heading, no section, and the release body is empty or wrong. ## The duplicate half — rig fails differently from box, and it matters 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). Worth noting rig's symptom here is *not* box's. box's `release-notes.sh` re-arms its grab on every matching `## ` line, so a duplicated heading makes it **absorb** whatever sits between the copies. rig's `changelog_section` has `if (found) exit`, so it stops at the first section instead — a duplicate **truncates**, silently dropping everything after the second copy. Different symptom, same class: silent, no conflict, no red run, discovered only by a human reading the published notes. Both halves are worth having. ## Proposal Port `.github/scripts/changelog-monotonic.sh` from box, and wire it in `ci.yml` 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 check.** Its input is a git history, not two files; its degradation is different (no base ref is a skip, not a failure); and rig's arming logic is driven from `test/release.sh` against constructed non-git trees, so folding a git-dependent assert into it would make those cases either skip or lie. - **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, which `ci.yml` does not currently set. `## 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 cast: heavy-duty/cast#133.
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/rig#98
No description provided.