The problem
.github/workflows/changelog.yml drafts changelog entries for merges that
skipped CHANGELOG.md and opens a bot pull request with them. It inserts them
under ## [Unreleased] with a perl -pi -e one-liner:
perl -pi -e '
if (/^## \[Unreleased\]$/) {
open my $fh, "<", $ENV{ENTRIES_FILE} or next;
my $e = do { local $/; <$fh> };
$e =~ s/\s+\z//;
$_ .= "\n" . $e . "\n";
}' CHANGELOG.md
perl -p runs that body once per line of the file, and the file handle is
reopened on every match. So the drafted block is appended after every line
matching ^## \[Unreleased\]$.
CHANGELOG.md has two of them:
$ rg -n '^## \[Unreleased\]' CHANGELOG.md
20:## [Unreleased]
151:## [Unreleased]
Line 20 is the live section at the top. Line 151 is an empty orphan heading
sitting between the ## [0.1.2] notes and ## [1.0.0] — 2026-08-11, deep in
released history.
Every bot pull request therefore writes the same bullets twice: once where they
belong, and once buried inside the 1.0.0 release notes, dated to nothing.
The step's own comment asserts the opposite:
the gap's commits by definition wrote none of the existing entries, so nothing
can be duplicated.
That reasoning is about the entries not already existing. It does not cover
the heading existing twice, which is what actually happens.
The guard that would notice cannot fail
Immediately after the insertion:
if git diff --quiet -- CHANGELOG.md; then
echo "insertion was a no-op ([Unreleased] heading missing?); done."
exit 0
fi
This branch names the exact structural failure it is testing for — the
## [Unreleased] heading having been renamed, wrapped in a link, or removed —
and then reports success. Combined with the preceding step's
./.github/scripts/changelog-ai.sh … || true and the
steps.draft.outputs.entries != '' gate, there is no input to this workflow
for which the changelog mechanism being wholly broken produces anything but a
green check.
Rename the heading to ## [Unreleased] (main) — a plausible edit, and the kind
of thing a Keep-a-Changelog link-reference style would do — and the workflow
runs green forever while silently drafting nothing.
The file header does argue for degrading open:
degrade-open … never a red check
but that argument is about the AI drafting call failing (rate limit, no API key,
a bad response). It does not cover the repository's own file no longer having the
shape the workflow depends on. Those are different failures: one is an outside
service being unavailable, the other is this repo having quietly broken its own
tool.
What I verified vs. inferred
Verified by reading, on origin/main at a01ca64: the perl -pi -e block
and the git diff --quiet … exit 0 guard in .github/workflows/changelog.yml;
the two ## [Unreleased] headings at lines 20 and 151 of CHANGELOG.md, and
that the second is an empty heading directly above ## [1.0.0] — 2026-08-11.
Verified by language semantics, not by executing the workflow: perl -p
wraps the body in a while (<>) { … } continue { print } loop, so the body runs
per line and the open re-executes on each match. I did not run the workflow
against the current CHANGELOG.md to watch two copies land, because doing so
would require pushing to main. Anyone wanting the direct observation can run
the perl line against a copy of the file locally — see below.
How to reproduce
cp CHANGELOG.md /tmp/c.md
printf -- "- test entry\n" > /tmp/entries.txt
ENTRIES_FILE=/tmp/entries.txt perl -pi -e '
if (/^## \[Unreleased\]$/) {
open my $fh, "<", $ENV{ENTRIES_FILE} or next;
my $e = do { local $/; <$fh> };
$e =~ s/\s+\z//;
$_ .= "\n" . $e . "\n";
}' /tmp/c.md
rg -n "test entry" /tmp/c.md # two hits
For the guard: rename both headings in a scratch copy and confirm the workflow's
if git diff --quiet branch exits 0.
What "done" looks like
- The insertion targets the first
## [Unreleased] heading only. In perl
that is a one-flag change — a state variable, or $done++ guarding the block.
- The orphan
## [Unreleased] heading in the middle of CHANGELOG.md is
removed. It carries no content and serves no purpose; leaving it and only
fixing the perl leaves a trap for the next tool that reads the file.
- The no-op branch fails instead of exiting 0. A drafted set of entries that
could not be inserted means the workflow's assumption about the file is wrong,
and that is worth a red check on a bot PR job that blocks nothing. Keep the
degrade-open behaviour for the AI call itself, where it is correct.
- Witness: rename the heading in a branch, run the workflow (it is
workflow_dispatch-able), and confirm it now goes red naming the missing
heading. Restore and confirm green.
Constraints
- The workflow pushes with
git push -f origin HEAD:refs/heads/bot/changelog,
so a wrong insertion is force-overwritten on the next run rather than
accumulating. That limits the blast radius to whatever a reviewer merges — it
does not make the double insertion harmless, because the second copy is
visually far from the first in the diff and easy to approve past.
- Do not solve this by tightening the regex to something like
^## \[Unreleased\]\s*$. Both headings match either way; the problem is
"every match" rather than "which match".
The problem
.github/workflows/changelog.ymldrafts changelog entries for merges thatskipped
CHANGELOG.mdand opens a bot pull request with them. It inserts themunder
## [Unreleased]with aperl -pi -eone-liner:perl -pruns that body once per line of the file, and the file handle isreopened on every match. So the drafted block is appended after every line
matching
^## \[Unreleased\]$.CHANGELOG.mdhas two of them:Line 20 is the live section at the top. Line 151 is an empty orphan heading
sitting between the
## [0.1.2]notes and## [1.0.0] — 2026-08-11, deep inreleased history.
Every bot pull request therefore writes the same bullets twice: once where they
belong, and once buried inside the 1.0.0 release notes, dated to nothing.
The step's own comment asserts the opposite:
That reasoning is about the entries not already existing. It does not cover
the heading existing twice, which is what actually happens.
The guard that would notice cannot fail
Immediately after the insertion:
This branch names the exact structural failure it is testing for — the
## [Unreleased]heading having been renamed, wrapped in a link, or removed —and then reports success. Combined with the preceding step's
./.github/scripts/changelog-ai.sh … || trueand thesteps.draft.outputs.entries != ''gate, there is no input to this workflowfor which the changelog mechanism being wholly broken produces anything but a
green check.
Rename the heading to
## [Unreleased] (main)— a plausible edit, and the kindof thing a Keep-a-Changelog link-reference style would do — and the workflow
runs green forever while silently drafting nothing.
The file header does argue for degrading open:
but that argument is about the AI drafting call failing (rate limit, no API key,
a bad response). It does not cover the repository's own file no longer having the
shape the workflow depends on. Those are different failures: one is an outside
service being unavailable, the other is this repo having quietly broken its own
tool.
What I verified vs. inferred
Verified by reading, on
origin/mainat a01ca64: theperl -pi -eblockand the
git diff --quiet … exit 0guard in.github/workflows/changelog.yml;the two
## [Unreleased]headings at lines 20 and 151 ofCHANGELOG.md, andthat the second is an empty heading directly above
## [1.0.0] — 2026-08-11.Verified by language semantics, not by executing the workflow:
perl -pwraps the body in a
while (<>) { … } continue { print }loop, so the body runsper line and the
openre-executes on each match. I did not run the workflowagainst the current
CHANGELOG.mdto watch two copies land, because doing sowould require pushing to
main. Anyone wanting the direct observation can runthe perl line against a copy of the file locally — see below.
How to reproduce
For the guard: rename both headings in a scratch copy and confirm the workflow's
if git diff --quietbranch exits 0.What "done" looks like
## [Unreleased]heading only. In perlthat is a one-flag change — a state variable, or
$done++guarding the block.## [Unreleased]heading in the middle ofCHANGELOG.mdisremoved. It carries no content and serves no purpose; leaving it and only
fixing the perl leaves a trap for the next tool that reads the file.
could not be inserted means the workflow's assumption about the file is wrong,
and that is worth a red check on a bot PR job that blocks nothing. Keep the
degrade-open behaviour for the AI call itself, where it is correct.
workflow_dispatch-able), and confirm it now goes red naming the missingheading. Restore and confirm green.
Constraints
git push -f origin HEAD:refs/heads/bot/changelog,so a wrong insertion is force-overwritten on the next run rather than
accumulating. That limits the blast radius to whatever a reviewer merges — it
does not make the double insertion harmless, because the second copy is
visually far from the first in the diff and easy to approve past.
^## \[Unreleased\]\s*$. Both headings match either way; the problem is"every match" rather than "which match".