A PR can delete a shipped release heading and CI stays green — port box#122's monotonicity guard #123

Closed
opened 2026-07-20 10:30:41 +00:00 by dan-claude-bot · 1 comment
dan-claude-bot commented 2026-07-20 10:30:41 +00:00 (Migrated from github.com)

Port of heavy-duty/box#122, filed per that issue's closing note ("Worth
checking whether the siblings want the same guard") and #126's port verdict.
cast is the yes-with-a-caveat of the two siblings — read the caveat
section before treating this as a copy job.

The class

A changelog edit that deletes a shipped ## X.Y.Z heading — absorbing its
body into ## Unreleased — merges cleanly, produces no conflict, and no
check objects. The damage surfaces only at the next release that asks for
that section, long after the merge, with no obvious link back to the PR that
caused it.

box caught a live near-miss (box#118 replaced ## 0.8.0 — 2026-07-19
instead of inserting above it — the entire shipped 0.8.0 record silently
absorbed into ## Unreleased). Caught in review; nothing shipped;
nothing in CI objected.

Why cast is exposed

.github/scripts/release-notes.sh extracts the release body by heading, and
.github/workflows/release.yml:164 makes it the published release notes. A
deleted heading fails exactly the way it would in box: the extraction finds
nothing at release time, and a release cut from that state republishes the
wrong section.

This is not a straight copy, for the same structural reason as rig: box
extends a family that already includes .github/scripts/changelog-armed.sh.
cast has no CI-side changelog guard — arming is enforced from the test
suite (test/release.test.ts, keyed to the version, per CONTRIBUTING). So
the port decides where the guard lives rather than which script it joins.

The caveat — settle this before importing the premise

The rule box uses is: the set of ^## X.Y.Z headings on a PR head must be a
superset of the set at the merge base. Its "no false positives to tune"
claim rests entirely on release headings being append-only.

box#126 flags that cast is where that premise most needs checking: cast#108
was one of the two reverted unconditional ## Unreleased guards.
That
history is the reason to confirm cast's ceremony shape matches box's before
porting the append-only rule. Concretely — if cast ever legitimately rewrites
or removes a released heading, the premise does not transfer, and the port
needs a different design, not a copy. The failure mode of getting this
wrong is well documented in this family: a guard that fires on the ceremony's
own tree makes the release unshippable, which is precisely why cast#108 and
rig#44 were reverted.

So step one of this issue is a finding, not an implementation: does cast's
release ceremony only ever add ## X.Y.Z headings?

What the port costs, if the premise holds

  • Checkout depth. .github/workflows/ci.yml:12 is a bare
    actions/checkout@v4 — depth 1, no base history, so there is no merge base
    to compare against today. box chose fetch-depth: 0 over a narrow
    git fetch origin <base> because getting that subtly wrong on fork PRs
    degrades to a silent skip — the exact failure mode the guard prevents.
  • A strict flag, so an unresolvable base skips loudly locally and is red
    in CI (box uses CHANGELOG_MONOTONIC_STRICT=1).
  • Fixtures in cast's own suite. Note box put its assertions in
    test/release.sh rather than folding the check into changelog-armed.sh,
    because its existing cases run against constructed trees that are not git
    repos
    — worth checking whether cast's release.test.ts has the same
    property before choosing where the new tests live.
  • pull_request-only: on a push to main the merge base is HEAD and the
    assert is vacuous.

Blocked

On heavy-duty/box#126 landing — so this ports the reviewed design rather
than the proposed one.

Refs

heavy-duty/box#122 (the defect), heavy-duty/box#126 (implementation + port
verdict), heavy-duty/box#118 (the near-miss), #108 (cast's reverted
unconditional guard — the caveat), heavy-duty/rig#66 (the sibling incident)

Port of heavy-duty/box#122, filed per that issue's closing note ("Worth checking whether the siblings want the same guard") and #126's port verdict. cast is the **yes-with-a-caveat** of the two siblings — read the caveat section before treating this as a copy job. ## The class A changelog edit that deletes a shipped `## X.Y.Z` heading — absorbing its body into `## Unreleased` — merges **cleanly**, produces no conflict, and no check objects. The damage surfaces only at the next release that asks for that section, long after the merge, with no obvious link back to the PR that caused it. box caught a live near-miss (box#118 replaced `## 0.8.0 — 2026-07-19` instead of inserting above it — the entire shipped 0.8.0 record silently absorbed into `## Unreleased`). Caught in review; nothing shipped; **nothing in CI objected.** ## Why cast is exposed `.github/scripts/release-notes.sh` extracts the release body by heading, and `.github/workflows/release.yml:164` makes it the published release notes. A deleted heading fails exactly the way it would in box: the extraction finds nothing at release time, and a release cut from that state republishes the wrong section. **This is not a straight copy, for the same structural reason as rig:** box extends a family that already includes `.github/scripts/changelog-armed.sh`. cast has **no CI-side changelog guard** — arming is enforced from the test suite (`test/release.test.ts`, keyed to the version, per CONTRIBUTING). So the port decides where the guard lives rather than which script it joins. ## The caveat — settle this before importing the premise The rule box uses is: the set of `^## X.Y.Z` headings on a PR head must be a **superset** of the set at the merge base. Its "no false positives to tune" claim rests entirely on release headings being **append-only**. box#126 flags that cast is where that premise most needs checking: **cast#108 was one of the two reverted unconditional `## Unreleased` guards.** That history is the reason to confirm cast's ceremony shape matches box's before porting the append-only rule. Concretely — if cast ever legitimately rewrites or removes a released heading, the premise does not transfer, and the port needs a **different design, not a copy**. The failure mode of getting this wrong is well documented in this family: a guard that fires on the ceremony's own tree makes the release unshippable, which is precisely why cast#108 and rig#44 were reverted. So step one of this issue is a finding, not an implementation: does cast's release ceremony only ever *add* `## X.Y.Z` headings? ## What the port costs, if the premise holds - **Checkout depth.** `.github/workflows/ci.yml:12` is a bare `actions/checkout@v4` — depth 1, no base history, so there is no merge base to compare against today. box chose `fetch-depth: 0` over a narrow `git fetch origin <base>` because getting that subtly wrong on **fork** PRs degrades to a *silent skip* — the exact failure mode the guard prevents. - **A strict flag**, so an unresolvable base skips loudly locally and is red in CI (box uses `CHANGELOG_MONOTONIC_STRICT=1`). - **Fixtures** in cast's own suite. Note box put its assertions in `test/release.sh` rather than folding the check into `changelog-armed.sh`, because its existing cases run against constructed trees that are *not git repos* — worth checking whether cast's `release.test.ts` has the same property before choosing where the new tests live. - `pull_request`-only: on a push to main the merge base is HEAD and the assert is vacuous. ## Blocked On heavy-duty/box#126 landing — so this ports the *reviewed* design rather than the proposed one. ## Refs heavy-duty/box#122 (the defect), heavy-duty/box#126 (implementation + port verdict), heavy-duty/box#118 (the near-miss), #108 (cast's reverted unconditional guard — the caveat), heavy-duty/rig#66 (the sibling incident)

Closing — satisfied by the ceremony conversion (#143).

The guard this issue asked to port now runs on every PR: .github/workflows/ci.yml invokes heavy-duty/ceremony/actions/changelog-monotonic@0.1.0, and states the covered class in as many words — "no shipped heading deleted or duplicated", compared against the merge base. That is box#122's rule, arrived by a different door: the named blocker (heavy-duty/box#126) landed the reviewed design, ceremony absorbed it into the shared machinery, and cast now consumes it by pin instead of carrying a local port.

The caveat this issue insisted on — whether cast's release ceremony only ever adds ## X.Y.Z headings — is settled the same way: the append-only premise is ceremony's now, exercised on its own releases, rather than a local re-derivation in the cast#108 shape whose revert prompted the caveat.

No cast-side work survives this, so nothing is re-minted. If the guard ever misfires on a legitimate release tree here, that is a bug against heavy-duty/ceremony, filed there.

Closing — satisfied by the ceremony conversion (#143). The guard this issue asked to port now runs on every PR: `.github/workflows/ci.yml` invokes `heavy-duty/ceremony/actions/changelog-monotonic@0.1.0`, and states the covered class in as many words — *"no shipped heading deleted or duplicated"*, compared against the merge base. That is box#122's rule, arrived by a different door: the named blocker (heavy-duty/box#126) landed the reviewed design, ceremony absorbed it into the shared machinery, and cast now consumes it by pin instead of carrying a local port. The caveat this issue insisted on — whether cast's release ceremony only ever *adds* `## X.Y.Z` headings — is settled the same way: the append-only premise is ceremony's now, exercised on its own releases, rather than a local re-derivation in the cast#108 shape whose revert prompted the caveat. No cast-side work survives this, so nothing is re-minted. If the guard ever misfires on a legitimate release tree here, that is a bug against heavy-duty/ceremony, filed there.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#123
No description provided.