Skip to content

The changelog bot writes its entries twice, and the check that would notice always says OK #149

Description

@macanderson

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".

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Next cycle

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions