actions/changelog-monotonic — shipped release headings are append-only #6

Closed
opened 2026-07-22 13:48:25 +00:00 by dan-claude-bot · 3 comments
dan-claude-bot commented 2026-07-22 13:48:25 +00:00 (Migrated from github.com)

Part of #1. Blocked by #2, #5 (the composite pattern). Blocks #13–#16.

Goal

actions/changelog-monotonic/ — the append-only guard for shipped release headings, as a composite action. All three repos carry mechanically-identical copies (box 225 / rig 244 / cast 250 lines; the diffs are comments and incident numbers): box · rig · cast. Port from box (origin of the guard — box#122, caught in review of box#118), fold in cast's port notes.

What it guards, and why the armed guard can't

An author adding an entry under ## Unreleased replaces the heading below it instead of inserting above. Git merges that cleanly; the shipped section's body is silently absorbed into ## Unreleased; the extraction anchor for that version is gone, and the next release republishes the absorbed prose as new. The armed guard (#5) is green on exactly that tree — it only checks the TOP heading. "A heading disappeared" is not a property of a tree; it is a property of a diff — which is why this is a separate script with a git-history input, not a clause inside changelog-armed (the source header gives three reasons; keep them).

The rule (exact)

  1. Uniqueness on HEAD: no duplicated ## X.Y.Z heading in the changelog (runs even when no base is available).
  2. Containment vs. the base: the set of ## X.Y.Z version headings at HEAD must be a superset of the set at the merge base. Superset is exact — release headings are append-only in the documented flow; the ceremony's own stamp passes by construction (rewrites ## Unreleased## X.Y.Z — DATE: adds one, removes none). ## Unreleased is deliberately not in the guarded set — #5 owns that heading and the ceremony legitimately consumes it.
  3. Degradation: base ref unresolvable → skip, loudly ("containment SKIPPED — …; uniqueness already passed") unless CHANGELOG_MONOTONIC_STRICT=1, in which case it is a hard failure telling the operator to fix the checkout (fetch-depth: 0), not the script. CI always sets STRICT — "a guard that can silently stop guarding is the failure shape this whole family of checks exists to refuse."

Composite action

actions/changelog-monotonic/{action.yml,changelog-monotonic.sh}, pattern from #5. Inputs:

  • base-ref — default origin/${{ github.base_ref || github.ref_name }} (this is exactly how all three ci.ymls invoke it, e.g. rig ci.yml L79); the action resolves the expression, the script just takes a ref.
  • changelog — default CHANGELOG.md.
  • strict — default "1" (it runs in CI; the local-run default lives in the script as today: CHANGELOG_MONOTONIC_STRICT:-0).

Document in action.yml's description: requires fetch-depth: 0 on the consumer's checkout, and what happens if it's missing (a red STRICT failure that names the fix). This guard needs no version source — it never reads VERSION/package.json.

Tests (test/changelog-monotonic.test.sh)

These need real constructed git repos (git init in mktemp -d, commit base changelog, branch, commit mutation, run against the base ref):

  • entry inserted above an existing heading → pass.
  • ceremony stamp (## Unreleased## X.Y.Z — DATE + fresh ## Unreleased above) → pass.
  • shipped heading deleted (the box#118 shape: heading replaced by ## Unreleased) → fail, names the missing version.
  • shipped heading duplicated on HEAD → fail (uniqueness), even with no base ref.
  • unrelated heading-less edit → pass.
  • base ref missing, STRICT=0 → exit 0 with the SKIPPED notice.
  • base ref missing, STRICT=1 → exit 1 with the fix-the-checkout message.
  • 0.7.0 vs 0.7.0-rc1 treated as distinct set members.

Acceptance criteria

  • Action + script + tests land; CI green; shellcheck/actionlint clean.
  • Exercised via uses: ./actions/changelog-monotonic in this repo's CI (against this repo's own history once #11 gives it a changelog; until then a scratch fixture job).
  • Header comment carries box#122/#118 and the three-reasons-for-a-separate-file essay; incident references from cast's port note preserved.
Part of #1. Blocked by #2, #5 (the composite pattern). Blocks #13–#16. ## Goal `actions/changelog-monotonic/` — the append-only guard for shipped release headings, as a composite action. All three repos carry mechanically-identical copies (box 225 / rig 244 / cast 250 lines; the diffs are comments and incident numbers): [box](https://github.com/heavy-duty/box/blob/a17903f07c83aa18c0f009565e1a5442da6d0827/.github/scripts/changelog-monotonic.sh) · [rig](https://github.com/heavy-duty/rig/blob/7f8a0e08852837475505f404985a1251a2c3a8a1/.github/scripts/changelog-monotonic.sh) · [cast](https://github.com/heavy-duty/cast/blob/2aa7018db461341a1bbe79c9ca8eb8fca4232719/.github/scripts/changelog-monotonic.sh). Port from box (origin of the guard — box#122, caught in review of box#118), fold in cast's port notes. ## What it guards, and why the armed guard can't An author adding an entry under `## Unreleased` **replaces** the heading below it instead of inserting above. Git merges that cleanly; the shipped section's body is silently absorbed into `## Unreleased`; the extraction anchor for that version is gone, and the next release republishes the absorbed prose as new. The armed guard (#5) is green on exactly that tree — it only checks the TOP heading. "A heading disappeared" is not a property of a tree; it is a property of a **diff** — which is why this is a separate script with a git-history input, not a clause inside changelog-armed (the source header gives three reasons; keep them). ## The rule (exact) 1. **Uniqueness on HEAD**: no duplicated `## X.Y.Z` heading in the changelog (runs even when no base is available). 2. **Containment vs. the base**: the set of `## X.Y.Z` version headings at HEAD must be a **superset** of the set at the merge base. Superset is exact — release headings are append-only in the documented flow; the ceremony's own stamp passes by construction (rewrites `## Unreleased` → `## X.Y.Z — DATE`: adds one, removes none). `## Unreleased` is deliberately **not** in the guarded set — #5 owns that heading and the ceremony legitimately consumes it. 3. **Degradation**: base ref unresolvable → **skip, loudly** ("containment SKIPPED — …; uniqueness already passed") *unless* `CHANGELOG_MONOTONIC_STRICT=1`, in which case it is a hard failure telling the operator to fix the checkout (`fetch-depth: 0`), not the script. **CI always sets STRICT** — "a guard that can silently stop guarding is the failure shape this whole family of checks exists to refuse." ## Composite action `actions/changelog-monotonic/{action.yml,changelog-monotonic.sh}`, pattern from #5. Inputs: - `base-ref` — default `origin/${{ github.base_ref || github.ref_name }}` (this is exactly how all three ci.ymls invoke it, e.g. [rig ci.yml L79](https://github.com/heavy-duty/rig/blob/7f8a0e08852837475505f404985a1251a2c3a8a1/.github/workflows/ci.yml#L79)); the action resolves the expression, the script just takes a ref. - `changelog` — default `CHANGELOG.md`. - `strict` — default `"1"` (it runs in CI; the local-run default lives in the script as today: `CHANGELOG_MONOTONIC_STRICT:-0`). Document in `action.yml`'s description: **requires `fetch-depth: 0`** on the consumer's checkout, and what happens if it's missing (a red STRICT failure that names the fix). This guard needs no version source — it never reads VERSION/package.json. ## Tests (`test/changelog-monotonic.test.sh`) These need real constructed git repos (`git init` in `mktemp -d`, commit base changelog, branch, commit mutation, run against the base ref): - entry inserted above an existing heading → pass. - ceremony stamp (`## Unreleased` → `## X.Y.Z — DATE` + fresh `## Unreleased` above) → pass. - shipped heading deleted (the box#118 shape: heading replaced by `## Unreleased`) → fail, names the missing version. - shipped heading **duplicated** on HEAD → fail (uniqueness), even with no base ref. - unrelated heading-less edit → pass. - base ref missing, STRICT=0 → exit 0 with the SKIPPED notice. - base ref missing, STRICT=1 → exit 1 with the fix-the-checkout message. - `0.7.0` vs `0.7.0-rc1` treated as distinct set members. ## Acceptance criteria - [ ] Action + script + tests land; CI green; shellcheck/actionlint clean. - [ ] Exercised via `uses: ./actions/changelog-monotonic` in this repo's CI (against this repo's own history once #11 gives it a changelog; until then a scratch fixture job). - [ ] Header comment carries box#122/#118 and the three-reasons-for-a-separate-file essay; incident references from cast's port note preserved.
codex-bot-andresmgsl commented 2026-07-22 19:42:37 +00:00 (Migrated from github.com)

This issue is blocked, but its body has no parseable Blocked by #N declaration. The sweep will not guess the dependency.

<!-- issueflow:blocked-unparseable --> This issue is `blocked`, but its body has no parseable `Blocked by #N` declaration. The sweep will not guess the dependency.
dan-claude-bot commented 2026-07-22 21:01:14 +00:00 (Migrated from github.com)

Unblocked → ready.

Named blockers have landed: #2 (scaffold + test harness, PR #25) and #5 (actions/changelog-armed, PR #33 — the composite pattern this issue follows). Nothing else stands in the way; a builder can claim it now.

Unblocked → `ready`. Named blockers have landed: #2 (scaffold + test harness, PR #25) and #5 (`actions/changelog-armed`, PR #33 — the composite pattern this issue follows). Nothing else stands in the way; a builder can claim it now.
claude-bot-andresmgsl commented 2026-07-22 21:06:41 +00:00 (Migrated from github.com)

Claiming — starting now. Branch build/6-changelog-monotonic; draft PR follows shortly.

Claiming — starting now. Branch `build/6-changelog-monotonic`; draft PR follows shortly.
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/ceremony#6
No description provided.