Skip to content

fix(scripts): reject absorbed changelog release headings (#2264) - #2290

Merged
kyle-sexton merged 3 commits into
mainfrom
cursor/fix-changelog-preserved-2264-63e1
Aug 12, 2026
Merged

fix(scripts): reject absorbed changelog release headings (#2264)#2290
kyle-sexton merged 3 commits into
mainfrom
cursor/fix-changelog-preserved-2264-63e1

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #2264. --check-bump now fails when a bumped plugin's CHANGELOG.md loses any ## [x.y.z] heading that existed at the merge base (fork point). This catches merge-forward resolutions that fuse two releases into one section — the reproduction from the issue where ## [0.51.8] vanishes while 0.51.9 documents the bump.

Compares against the fork point, not main tip, so a branch that bumps past an advanced base without yet carrying another branch's release heading is not a false positive.

Verification

bash scripts/check-changelog-parity.test.sh — PASS=57 FAIL=0

Related

…dings

--check-bump now requires every bracketed release heading present at the
fork point to still exist at head, catching absorbed predecessor sections
that previously passed all three modes.

Fixes #2264

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@cursor

cursor Bot commented Aug 12, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 38s —— View job


Security review

  • Fetch PR diff (origin/main...HEAD) and read changed files
  • Analyze scripts/check-changelog-parity.sh changes for injection / trust-boundary issues
  • Analyze scripts/check-changelog-parity.test.sh changes
  • Report findings

Scope: Only the 2 files changed in this PR — no GitHub Actions workflow files are touched, so the pull_request_target/workflow_run/permissions:-widening checks don't apply here.

Findings: none.

Walked the new code paths specifically for shell/command injection and trust-boundary issues:

  • changelog_bracket_headings() (scripts/check-changelog-parity.sh:206-208) is a pure grep -E '^## \[' over rendered_lines output — no new attack surface.
  • The new absorbed-heading check (scripts/check-changelog-parity.sh:436-448) does git show "$merge_base:$changelog"$merge_base is the SHA output of git merge-base (script-computed, not attacker-supplied), and $changelog is built from plugins/*/.claude-plugin/plugin.json glob results, not external input. Both are quoted throughout, no eval/sh -c, so there's no injection path even though $base (the CLI-supplied ref, used earlier at line 320) flows into git merge-base/git diff — it's passed as a normal argument, never interpolated into a string that gets re-parsed as shell.
  • comm -23 <(...) <(...) process substitution is used correctly (both sides sorted per comm's requirement); failure mode is fail-closed (non-empty missing_headings blocks the merge), consistent with the rest of the script's fail-closed design.
  • The test additions (scripts/check-changelog-parity.test.sh:220-235) only build fixtures in a throwaway repo under the test's own temp dir — no injection surface, no privilege implications.

No secrets, tokens, or credentials are handled by this diff, and it doesn't touch CI trigger config or permissions. Nothing to flag.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 116b724746

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/check-changelog-parity.sh Outdated
changelog_bracket_headings must not treat ## [Unreleased] as a release
heading; only bracketed SemVer versions count.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Warning

Automated security review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

The check is green on purpose, and it is not evidence. It certifies that a security pass ran, and this one did not complete — but the cause is outside this PR's control, so merging is deliberately left unblocked rather than locking every merge for the length of the outage. Nothing was reviewed at this head. Where this check is required, it is satisfied without that evidence; a human should review security-sensitive changes here before merging.

Re-run the job to retry the review; a new push also retries it only if the caller's pull_request triggers include synchronize (the canonical security caller keeps it). An automatic retry may already have run — it is skipped when a partial review could duplicate comments, or when the failure class needs an operator.

Re-running does NOT help for every class:

  • rate-limit that persists across re-runs, or auth — the credential or usage budget needs an operator; retrying will not clear it.
  • a run that exhausted its turn budget ("subtype":"error_max_turns" above) will exhaust it again. As the PR author, split the change into smaller PRs; raising --max-turns is a change to the caller workflow, not something you can set on this PR.

…preserved-2264-63e1

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@kyle-sexton
kyle-sexton merged commit 93b5f69 into main Aug 12, 2026
35 checks passed
@kyle-sexton
kyle-sexton deleted the cursor/fix-changelog-preserved-2264-63e1 branch August 12, 2026 02:54
@github-actions

Copy link
Copy Markdown

Warning

Automated review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-run the job to retry the review. A new push re-triggers this lane only if the caller's pull_request triggers include synchronize (the canonical caller omits it).
An automatic retry may already have run — it is skipped when a partial review could duplicate comments, or when the failure class needs an operator (auth).

kyle-sexton added a commit that referenced this pull request Aug 12, 2026
…line (#2327) (#2341)

## Summary

#2290's absorbed-heading check diffs whole heading **lines**:
`changelog_bracket_headings()` is `grep -E` without `-o`, so it emits
the entire line, and `comm -23` compares those lines. Any edit to an
existing release heading therefore reads as a deletion — including both
annotations [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
itself prescribes, which **preserve** the heading they annotate.

Measured against the shipped script on `main`, copied unmodified into a
throwaway repo (#2327 carries the full reproduction):

| change | heading at head | `--check-bump` on `main` |
|---|---|---|
| mark a pulled release | `## [1.0.0] - 2026-01-01 [YANKED]` |
**EXIT=1** `ABSORBED CHANGELOG HEADING … ## [1.0.0]` |
| date an existing release | `## [1.0.0] - 2026-01-01` | **EXIT=1**
`ABSORBED CHANGELOG HEADING … ## [1.0.0]` |

`1.0.0` is documented in both. Nothing was absorbed. This is a
**required** merge check, so a false positive here blocks every
in-flight PR that touches a heading line, not just its author's.

## Fix

Key the comparison on the **version** each heading names, through the
shared `changelog_versions` extractor, instead of on the rendered line.
`## [1.0.0]` and `## [1.0.0] - 2014-12-13 [YANKED]` are one release, so
annotating passes; renaming `## [0.51.8]` to `## [0.51.9]` still deletes
the version `0.51.8`, so a relabel is still caught.

`changelog_bracket_headings()` is dropped. It was a second reader of the
same file, which the header's own `rendered_lines` note argues against —
*"One tracker, not three, so the modes cannot drift."* The failure
message still prints `## [<version>]`, so nothing downstream of it
changes.

**Two behaviour changes worth naming**, both consequences of keying on
the version:

- **Reformatting a heading between the bracketed and unbracketed forms
is no longer a deletion.** The format of a release entry is `CHANGELOG
FORMAT`'s concern, not preservation's.
- **An unbracketed heading is now protected.** A bracketed-only matcher
could not see `## 0.9.0 — 2025-12-01` at all, so absorbing that section
was invisible. Both directions have a test.

## Deliberately out of scope

All pre-existing from #2290 and tracked in #2327 — a false positive on a
required gate is worth a surgical fix, not a redesign in the same change
set:

- The check lives inside `--check-bump`'s loop, so an absorption in a
change set that does **not** bump the manifest is unpoliced (verified
EXIT=0).
- `docs/conventions/*/CHANGELOG.md` is outside `--check-bump`'s scope
entirely, so an absorption there is unpoliced (verified EXIT=0).
- The `2>/dev/null` process substitution on `git show` is fail-open: a
git read that genuinely fails yields an empty fork-point set and reads
as "nothing to preserve".

#2327 sketches the fuller alternative (preservation as its own diff mode
over every changed changelog under both roots), with a worked
implementation on `fix/2264-changelog-preservation`.

## Test plan

**Suite: `PASS=62 FAIL=0`** (57 pre-existing + 5 new), `bash
scripts/check-changelog-parity.test.sh`.

New cases:

- `dating an existing heading is not an absorbed heading` — the FP
above.
- `marking a release '[YANKED]' preserves its heading and passes
--check-bump` — the FP above.
- `a relabelled predecessor heading still fails --check-bump` — the
check must not go soft.
- `reformatting a heading between the two accepted forms is not an
absorbed heading`.
- `absorbing an unbracketed release section is caught` — coverage a
bracketed-only matcher could not have.

`#2290`'s own absorption test is untouched and still passes, asserting
on `## [0.51.8]` in the output.

**The new tests are load-bearing** — established before the fix existed,
by running `main`'s shipped script unmodified against the same two
fixtures the new tests use (full transcript in #2327):

| fixture | `main`'s script | this branch |
|---|---|---|
| `## [1.0.0]` → `## [1.0.0] - 2026-01-01 [YANKED]` | **EXIT=1**
`ABSORBED CHANGELOG HEADING … ## [1.0.0]` | EXIT=0 |
| `## [1.0.0]` → `## [1.0.0] - 2026-01-01` | **EXIT=1** `ABSORBED
CHANGELOG HEADING … ## [1.0.0]` | EXIT=0 |

The symmetric in-tree check — restoring only
`scripts/check-changelog-parity.sh` from `origin/main` and re-running
the suite — was **not** run: the command was refused by a local
permission classifier, and it was not reshaped to get around it. The
table above is the same evidence by a different route.

**Zero false positives on real history.** `main` is squash-merged, so
each commit on it *is* a merged PR. Every one of the 60 most recent
commits touching `plugins/*/CHANGELOG.md` was replayed as its own PR
(tree at `C`, base `C^1`) in a throwaway local clone, running this
branch's `--check-bump`:

| | |
|---|---|
| commits replayed | **59** (of 60; `cf743d61` could not be checked out)
|
| clean | **58** |
| fired | **1** |
| of which `ABSORBED CHANGELOG HEADING` | **0** |

The single fire is `78dbb10e`, reporting `UNDOCUMENTED BUMP` and three
`VERSION REGRESSION`s — none from the check this PR touches.

**The zero is honest, and it is itself a finding.** The one commit in
recent history that really does delete a release heading — `04822fc4`,
which folded `docs-hygiene`'s never-released `## [0.9.7]` into `##
[0.10.0]` — is in the replay window (verified) and is *not* caught,
because it changed **no manifest at all** (`git diff --name-only
04822fc^1 04822fc` lists only `ci.yml`, that changelog, and the two
gate files). Living inside `--check-bump`'s loop, the check never
reaches a plugin whose version did not change. That is #2327 item 3, now
demonstrated on real history rather than a synthetic fixture. This PR
does not change that scoping — it fixes the false positives — so the
sweep's role here is to show the fix **introduces no new fires** across
59 real commits, not to demonstrate detection. Detection is demonstrated
by the unit cases.

Replaying a squash commit against its parent is not identical to what CI
saw at merge time — CI resolved the fork point of the original branch,
which for a stale branch sat further back — so fires from
`--check-bump`'s *other* checks (`VERSION REGRESSION`, `UNDOCUMENTED
BUMP`) are artifacts of comparing against a parent the branch never saw,
plus checks that did not exist when those commits merged. The
**absorbed-heading** check is unaffected: `C^1` is an ancestor of `C`,
so the fork point and the parent coincide and the comparison reads
exactly the diff that commit introduced.

**Other gates, locally:** `shellcheck --rcfile=.shellcheckrc -x` on both
files → clean; `scripts/check-shell-portability.sh --paths` on both
files → `No unexcused GNU-only constructs in 2 shell file(s)`.

**Conflict-marker sweep**, both files: `<<<<<<<` 0, `|||||||` 0,
`>>>>>>>` 0, `=======` 4 — all four pre-existing `# ==== <section> ====`
banner comments in the test file, none introduced here (0 marker-shaped
lines among this diff's additions).

## Related

Closes #2327 — the live false positives it reports. Its remaining items
(the scoping gaps and the fail-open fork-point read, all pre-existing
from #2290 and untouched here) were split into **#2342** so this close
is honest rather than partial.

#2290 introduced the check being repaired; #2264 is the defect it
closed. #2154 / #2159 are the SIGPIPE regression in this same gate — the
reading discipline is untouched here, since neither `changelog_versions`
nor `mapfile` exits before EOF.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 12, 2026
…2370)

Fixes #2342

## Summary

- Add `--check-preserved <ref>` mode that checks every **touched**
changelog (plugin and convention) for dropped version headings vs the
fork point.
- Replace the in-loop absorbed-heading check inside `--check-bump`
(which only ran for bumped plugins) with this dedicated mode.
- Wire `--check-preserved` into the CI `changelog-parity-gate` job as an
additional PR-only step.
- Uses `git ls-tree` + command-substitution `git show` (fail-loud on git
errors).

## Test plan

- [x] `bash scripts/check-changelog-parity.test.sh` (76/0)
- Covers: changelog-only absorption, convention changelogs, relabelling,
[YANKED], heading reformat, stale-branch false-positive guard,
whole-file deletion, SIGPIPE/gawk fixture

## Related

- #2264 / #2290 — motivating absorption defect
- #2327 / #2341 — false-positive fixes for heading annotations
- #2324 — git fail-open hardening (overlapping fix, landed separately in
PR #2369)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@cursor
cursor Bot restored the cursor/fix-changelog-preserved-2264-63e1 branch August 12, 2026 18:09
@kyle-sexton
kyle-sexton deleted the cursor/fix-changelog-preserved-2264-63e1 branch August 14, 2026 20:41
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.

check-changelog-parity: a merge-forward that absorbs the predecessor release section passes all three modes — deleted headings are unpoliced

2 participants