Skip to content

fix(ci): block pull requests that delete released CHANGELOG history - #3941

Merged
Yeachan-Heo merged 1 commit into
Yeachan-Heo:devfrom
yazzang-homelab:fix/changelog-history-guard
Aug 6, 2026
Merged

fix(ci): block pull requests that delete released CHANGELOG history#3941
Yeachan-Heo merged 1 commit into
Yeachan-Heo:devfrom
yazzang-homelab:fix/changelog-history-guard

Conversation

@yazzang-homelab

Copy link
Copy Markdown
Contributor

What

Adds scripts/changelog-history-guard.ts and runs it on every pull request: a PR fails if it removes any ## [X.Y.Z] heading from a packages/*/CHANGELOG.md.

Why — this is live damage right now

#3932 removed packages/*/CHANGELOG.md merge=union. That removal was correct: union never conflicts, it concatenates both sides of an overlapping hunk, which silently filed entries into already-shipped versions (35 audited instances on dev, #3929).

What it did not account for is the transition. These files now produce a real conflict on rebase for the first time, and a bad resolution empties them.

time (UTC) event
11:25:32 #3932 merged — union driver removed
11:29:29 – 11:35:02 #3920 #3697 #3870 #3908 #3887 #3864 #3729 #3869 #3866 #3873 force-pushed

Ten open PRs. Six authors. Six minutes. Every one of those heads now has a one-byte changelog:

$ git cat-file -s <head>:packages/coding-agent/CHANGELOG.md
1
# dev: 312259

#3920 had already been approved by @Yeachan-Heo at the previous head. Nothing in CI noticed — the files still parse, no test reads them, and the loss looks like a large deletion inside an otherwise legitimate diff. I have commented the recovery command on all ten.

This is fallout from a change I authored and did not think through, so the guard is mine to add.

The check

Every released heading at the merge base must still be present at the head. That is the whole rule.

  • Adding entries under ## [Unreleased] — passes.
  • Rewording an existing entry — passes.
  • A release commit consuming ## [Unreleased] into ## [0.12.13] — passes (headings are only ever added).
  • Dropping a released section, or emptying the file — fails, and the error names the lost versions and the recovery command.

Deliberately not a byte-size or deleted-line threshold: a threshold fires on large legitimate edits and still misses a small changelog emptied completely. The heading set is the actual invariant.

Runs in affected-plan, which already checks out full history and carries the immutable event base sha (GITHUB_BASE_SHA), so this adds one bun invocation and no new job. Gated on github.event_name == 'pull_request'.

Testing

bun test scripts/changelog-history-guard.test.ts        # 11 pass, 0 fail
bun run check:tools                                      # exit 0

Verified against the real broken heads rather than only synthetic fixtures:

$ bun scripts/changelog-history-guard.ts --base <dev> --head <#3873 head>
::error file=packages/coding-agent/CHANGELOG.md::... removes 80 released section(s):
0.12.12, 0.12.11, 0.12.10, 0.12.8, ... (+72 more). Base had 80 released headings,
this head has 0. ...
exit 1

#3873 -> exit 1     #3920 -> exit 1     #3869 -> exit 1
clean range (dev..dev) -> exit 0

CONTRIBUTING.md gains the pre-push self-check and the recovery command in the existing "Rebasing onto dev" section.

GJC verdict

Self-approval is BLOCK.

gajae.pr-review-verdict.v1 needs-human sha256:96e12160e9cdbdfdcb6e960beb0060738d690a12 reviewer:human evidence:bun test (11 pass) + check:tools exit 0 + guard exits 1 on three real broken heads and 0 on a clean range

  • Target branch is dev
  • bun check passes (check:tools)
  • Tested locally
  • CHANGELOG updated (if user-facing) — N/A, CI/contributor tooling only
  • Verdict above matches the exact PR head

Removing `packages/*/CHANGELOG.md merge=union` in Yeachan-Heo#3932 was correct --
union never conflicts, it concatenates both sides of an overlapping hunk,
which silently filed entries into versions that had already shipped (35
such entries audited on dev, Yeachan-Heo#3929). What it did not account for is the
transition: these files now conflict on rebase for the first time, and a
bad resolution drops the whole history with no marker.

That is not hypothetical. Yeachan-Heo#3932 merged at 11:25:32Z. Between 11:29:29Z
and 11:35:02Z, ten open pull requests across six authors force-pushed
heads whose CHANGELOG was a single newline -- every released section
gone. Yeachan-Heo#3920 Yeachan-Heo#3697 Yeachan-Heo#3870 Yeachan-Heo#3908 Yeachan-Heo#3887 Yeachan-Heo#3864 Yeachan-Heo#3729 Yeachan-Heo#3869 Yeachan-Heo#3866 Yeachan-Heo#3873.
Nothing caught it: the files still parse, no test reads them, and the
loss looks like a large deletion inside an otherwise legitimate diff.

The guard asserts the one property that matters and nothing more: every
`## [X.Y.Z]` heading present at the merge base must still be present at
the head. Additions pass, rewording passes, and a release commit that
consumes `## [Unreleased]` into a new version passes. Only losing a
released section fails, and the message names the recovery command.

Runs in `affected-plan`, which already checks out full history and
carries the immutable event base sha, so it costs one bun invocation and
needs no new job.

Constraint: a release bump must still be able to add a version heading
Constraint: must not depend on byte-size heuristics -- a legitimately
  small changelog is not a violation
Rejected: threshold on deleted line count | fires on large legitimate
  edits and misses a small changelog emptied completely
Rejected: restore merge=union | reinstates the silent misfiling this
  replaced, and GitHub ignores the driver anyway
Confidence: high
Scope-risk: narrow
Reversibility: trivial
Tested: bun test scripts/changelog-history-guard.test.ts (11 pass);
  guard run against the three real broken heads (Yeachan-Heo#3873 Yeachan-Heo#3920 Yeachan-Heo#3869)
  exits 1 and names the lost sections; clean range exits 0;
  bun run check:tools exit 0
Not-tested: a real release-bump PR end to end
@yazzang-homelab

Copy link
Copy Markdown
Contributor Author

head 96e12160e 기준 MERGEABLE / CLEAN, 23개 체크 초록, 6 skip, 실패 0.

지금 이 가드가 잡아낼 열린 PR이 12건이다(#3942 갱신본): #3920 #3908 #3887 #3873 #3870 #3869 #3868 #3866 #3864 #3846 #3729 #3697.

그중 #3920과 #3864는 reviewDecision이 여전히 APPROVED다 — 승인 이후의 push가 승인을 해제하지 않고, 이 저장소엔 브랜치 보호가 없어 "Dismiss stale approvals"도 없다. 즉 지금 두 PR은 머지 버튼이 열려 있는 상태에서 CHANGELOG가 1바이트다. 내가 승인했던 4건(#3920 #3870 #3908 #3729)은 CHANGES_REQUESTED로 뒤집어 두었지만, 오너 승인은 내 권한으로 해제할 수 없다.

가드가 들어가면 이 상태가 CI에서 자동으로 막힌다. 그 전까지는 수동 주의가 유일한 방어다.

@Yeachan-Heo
Yeachan-Heo merged commit d1eb845 into Yeachan-Heo:dev Aug 6, 2026
29 checks passed
@Yeachan-Heo

Copy link
Copy Markdown
Owner

This item is being moved to the Tier 3 review lane. Further review is deferred until after the current emergency stabilization period.


[repo owner's gaebal-gajae (clawdbot) 🦞]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants