Skip to content

Refuse a pull request that files a new entry inside a shipped release - #528

Merged
blooop merged 1 commit into
mainfrom
fix/527-changelog-frozen-sections
Aug 29, 2026
Merged

Refuse a pull request that files a new entry inside a shipped release#528
blooop merged 1 commit into
mainfrom
fix/527-changelog-frozen-sections

Conversation

@blooop

@blooop blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes #527.

What this catches

## [Unreleased] is a stable heading that a release cut renames. What was [Unreleased] becomes ## [0.25.0] - 2026-08-28, and a fresh empty [Unreleased] is inserted above it. A branch cut before that release carries its entry anchored, by context, under the old heading, which is now the release.

Git sees lines added below a heading that still exists. It resolves that with no conflict, the pull request reports MERGEABLE, every check is green, and a shipped version quietly grows bullets describing fixes it never contained.

Why a CI job and not a note in AGENTS.md

Over one afternoon it happened four times independently, to three agents who did not know of each other (wayfinder/devlaunch-{305,308,349,354}), plus twice inside a single build on #346, plus on #519 and #515 in the parallel session. Every instance was caught by a human or an agent reading the diff. Nothing automated caught any, because there was nothing that could.

The trap is that the signal a reviewer reaches for, MERGEABLE, is exactly the one that says nothing about this failure.

The rule, and why it is phrased this way

A version section already present on the base branch must be byte identical on the branch.

Phrased that way it permits the one commit that legitimately rewrites this file: a release cut adds a heading that was not there and modifies none that was, so it passes by construction rather than by exemption.

The tempting phrasing, "the released portion of the file is unchanged", is wrong on its own terms rather than merely inconvenient. A release cut necessarily rewrites that region, so the first thing that guard would ever fail is the one commit that is definitionally correct, and a guard whose opening act is a false positive on the project's own release ritual gets switched off before it catches anything. test_the_release_cut_that_creates_the_hazard_passes_the_guard pins that.

The fixture is a real merge

test_a_clean_merge_across_a_release_cut_files_the_entry_inside_the_release builds a repository, branches, cuts a release on main, and performs the merge. It asserts git returns 0 (the premise is that git has no signal here, so a conflict would mean the fixture was not reproducing the bug), asserts the entry really did land under ## [0.2.0], and only then asserts the guard rejects it.

A hand-written bad changelog would prove the parser rejects input somebody already knew was wrong. What needed proving is that the default outcome of the ordinary operation is wrong.

It fails closed

Unreadable file, no ## [version] headings at all, or a version heading appearing twice: all exit 1 with a reason, never 0. A check that has not checked anything must not report success, which is the same species of defect as #517.

Scope and cost

  • Runs inside the existing ci job on pull_request only, so the required check set is unchanged and gate already covers it. On a push there is no base to be frozen against.
  • The base commit is fetched explicitly at --depth=1 rather than switching the job's checkout to fetch-depth: 0, which would pull the whole history into every run to serve one git show.
  • Scope is CHANGELOG.md alone. Deleting a section is not policed: that is loud in a diff and has never been the silent failure. What is silent is a section growing.

Verification

  • 7 new tests pass; the guard also passes against this PR's own entry.
  • Full Python suite: 597 passed. The 23 failures in this worktree are all there is no dl to test (no release binary built here) and none are in files this touches.
  • prek clean.

🤖 Generated with Claude Code

Summary by Sourcery

Protect shipped changelog releases from accidental entries introduced by clean merges across release cuts.

New Features:

  • Add CI validation that prevents pull requests from adding or modifying entries in release sections already present on the base branch.

Bug Fixes:

  • Prevent changelog entries from being silently merged into shipped releases after a release cut.

Enhancements:

  • Make the changelog guard fail closed when files are unreadable, malformed, or contain duplicate version headings.
  • Compare released changelog sections by version while allowing legitimate release cuts and normal Unreleased edits.

CI:

  • Run the changelog validation in the existing pull-request CI job using the base commit's changelog.

Documentation:

  • Document the new changelog protection in CHANGELOG.md.

Tests:

  • Add coverage for clean merges across release cuts, valid release cuts and Unreleased entries, released-section edits, malformed changelogs, duplicate headings, and CI wiring.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @blooop, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 6 days and 1 hour by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR adds a fail-closed pull-request CI check that compares versioned CHANGELOG.md sections with the base commit, preventing clean rebases or merges from silently adding entries to shipped releases while preserving legitimate release cuts and Unreleased edits.

Sequence diagram for the frozen changelog pull request check

sequenceDiagram
    participant GitHub
    participant CI
    participant Git
    participant Guard as changelog_frozen.py
    participant Changelog as CHANGELOG.md
    GitHub->>CI: pull_request
    CI->>Git: git fetch --depth=1 origin BASE_SHA
    CI->>Git: git show BASE_SHA:CHANGELOG.md
    Git-->>CI: base CHANGELOG.md
    CI->>Guard: compare base and head paths
    Guard->>Changelog: sections(base, head)
    alt released section differs
        Guard-->>CI: exit 1 with section diff
    else all shared released sections identical
        Guard-->>CI: exit 0
    end
Loading

File-Level Changes

Change Details Files
Add a fail-closed changelog guard that compares released sections between the pull request base and head.
  • Parse version headings and compare matching sections byte-for-byte, including heading metadata such as release dates.
  • Allow new version sections and changes under Unreleased while rejecting modifications to existing released sections.
  • Emit actionable unified diffs and reject unreadable, malformed, duplicate-heading, or headingless changelogs.
scripts/changelog_frozen.py
Run the changelog guard as part of pull-request CI without expanding the checkout history.
  • Fetch the pull request base commit at depth 1 and extract its CHANGELOG.md with git show.
  • Invoke the guard only for pull_request events within the existing ci job.
.github/workflows/ci.yml
Document the new protection and comprehensively test the clean-merge failure mode and accepted release workflows.
  • Add a real Git branch/release/merge fixture proving a clean merge can place an entry inside a shipped section.
  • Cover release cuts, ordinary Unreleased entries, date edits, malformed input, duplicate headings, and CI wiring.
CHANGELOG.md
test/test_changelog_frozen.py

Assessment against linked issues

Issue Objective Addressed Explanation
#527 Add a pull-request CI guard that compares each non-Unreleased changelog section present on both the base branch and PR head, rejecting changes to already-shipped sections while permitting legitimate release cuts that add a new version heading.
#527 Ensure the guard detects the clean-merge failure mode where a branch entry is silently placed inside a shipped release, and reports the affected version with a useful diff.
#527 Provide automated tests covering the clean merge across a release cut, permitted release cuts, ordinary Unreleased edits, frozen-section edits, and fail-closed parsing behavior.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.67%. Comparing base (80acb76) to head (a18f48c).
⚠️ Report is 24 commits behind head on main.

Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.97% <ø> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.97% <ø> (+0.02%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

`## [Unreleased]` is a stable heading that a release cut renames. What was
`[Unreleased]` becomes `## [0.25.0] - 2026-08-28`, and a fresh empty
`[Unreleased]` is inserted above it. A branch cut before that release carries its
entry anchored by context under the old heading, which is now the release. Git
sees lines added below a heading that still exists, resolves the merge with no
conflict, and the pull request reports MERGEABLE with every check green while a
shipped version quietly grows bullets describing fixes it never contained.

That is not a lapse of attention, which is why it gets a job rather than a note.
Over one afternoon it happened four times independently to three agents who did
not know of each other, on `wayfinder/devlaunch-{305,308,349,354}`, and twice
inside a single build on #346. Every instance was caught by somebody reading the
diff, because until now there was nothing else that could catch it. The signal a
reviewer reaches for is precisely the one that says nothing about this failure.

The rule is that a version section already present on the base branch must be
byte identical on the branch. Phrased that way it permits the one commit that
legitimately rewrites this file: a release cut adds a heading that was not there
and modifies none that was. The tempting phrasing -- that the released portion of
the file is unchanged -- is wrong on its own terms rather than merely
inconvenient, since it fails every release cut, and a guard whose first firing is
a false positive on the project's own release ritual is switched off before it
ever catches anything.

The fixture performs the actual merge rather than writing a bad changelog by
hand. A hand-written one would prove only that the parser rejects input somebody
already knew was wrong; what has to be proved is that the default outcome of the
ordinary operation is wrong, so the test asserts git reports no conflict and the
entry still lands in the wrong section.

The guard fails rather than passes when it cannot read or parse either side. A
check that has not checked anything must not report success, which is the same
species of defect as #517.

Closes #527.
@blooop
blooop force-pushed the fix/527-changelog-frozen-sections branch from 03ff5d5 to a18f48c Compare August 29, 2026 20:21
@blooop
blooop merged commit adab656 into main Aug 29, 2026
15 checks passed
@blooop
blooop deleted the fix/527-changelog-frozen-sections branch August 29, 2026 20:29
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.

A fix rebased across a release cut is filed inside the shipped section, and the merge is clean

1 participant