Skip to content

Release notes: one changelog.d file per change, moved into the dated section by the changelog step [PROTECTED: owner merges] - #870

Merged
jayden1711 merged 3 commits into
mainfrom
changelog-fragments
Oct 20, 2026
Merged

jayden1711 merged 3 commits into
mainfrom
changelog-fragments

Conversation

@jayden1711

@jayden1711 jayden1711 commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Intent: every PR that added a release note to the one shared ## v0.5.3 (unreleased) section re-conflicted every other open PR on the same lines. Today that cost a full re-merge round on 7 PRs. This PR moves the next release's hand-written notes into per-change fragment files, changelog.d/<issue or PR number>-<short-slug>.md. Two PRs never edit the same file, so the conflict can't happen. release.mjs changelog X.Y.Z builds the dated section from the fragments and deletes them in the release PR. It writes nothing if a fragment line would be dropped or moved.

Risk class: protected. It changes scripts/lib/changelog.mjs, scripts/release.mjs (release tooling, in the owner's critical set) and docs/releasing.md (engine-guards).

No issue: the owner asked for fragments directly (orchestrator brief, 2026-10-20). It was stacked on #831, which has merged. This PR now targets main and has origin/main (efc1fd1) merged in. #831's tagNotes and revert reading are not touched.

What changes

  • The design that makes the conflict impossible: changelog.d/, one file per change. A fragment holds ### <Heading> blocks, and the bullet text under them is free markdown. Only the fixed headings the unreleased section uses are allowed, in this order:

    The generated headings (Features, Fixes, …, Guardrail changes) stay the tool's own.

  • release.mjs changelog X.Y.Z (withFragments in changelog.mjs) writes ## vX.Y.Z (date), then each fixed heading in order, then each fragment's lines under it, verbatim. Fragments go in number order, then by name. The generated lists come after, as Release: an (unreleased) CHANGELOG section survives the changelog step whole, by design [PROTECTED: owner merges] #829 writes them. It then deletes the fragments it used, so the release PR commits the section and the deletions together.

  • Release: an (unreleased) CHANGELOG section survives the changelog step whole, by design [PROTECTED: owner merges] #829's invariant, extended to fragments. The step throws and writes nothing when:

    • a fragment has an unknown heading, or any other line starting with # (up to 3 spaces in), such as ## or #;
    • a fragment is empty, or has text before its first heading;
    • a heading has nothing under it, or appears twice in one file;
    • a fragment has a CR, or holds any line the tag step would strip (the end-of-notes comment, also with trailing spaces or tabs). The test is isNotesEndLine, built from the tag step's own NOTES_END_LINE pattern; there is no second copy of the rule, and tagNotes is unchanged;
    • a file in the folder is misnamed, a directory, a symlink or not UTF-8;
    • fragments use two spellings of Upgrading;
    • CHANGELOG.md already has a section for the version.
  • The output is then checked two ways (fragmentProblems), and the step throws if either fails:

    • every non-blank line of each fragment's raw text is in the section's notes, byte for byte and counted;
    • the notes equal, byte for byte, what expectedNotes works out on its own. It shares no code with the builder's grouping (grouped), sorting (byNumber) or block reader (blocksOf): it takes FRAGMENT_HEADINGS in order, the files by the number in their name and then the name (its own comparison), and each file's raw lines (its own walk and trim). Round 2 made this independent; before, the check reused grouped/byNumber, so a mistake in them was caught only by the tests (the Release notes: one changelog.d file per change, moved into the dated section by the changelog step [PROTECTED: owner merges] #870 review measured three such mutants built).

    The denylist scan now runs before anything is written. With no fragments the step is withSection, byte for byte.

  • The tag step (Release: a GitHub-button revert is read from its body, and the tag step refuses an (unreleased) section [PROTECTED: owner merges] #831) is unchanged. It still reads only the dated section from HEAD:CHANGELOG.md.

  • The move. ## v0.5.3 (unreleased) moves into changelog.d/<this PR>-notes-before-changelog-d.md, verbatim, as one fragment. One fragment keeps the order within each heading exactly. CHANGELOG.md now holds only dated sections.

  • Backstop test (named as such): backstop (changelog.d) in test/changelog.test.ts. It fails when:

    • CHANGELOG.md has an (unreleased)/Unreleased heading, or any ## heading that isn't ## vX.Y.Z (YYYY-MM-DD);
    • a changelog.d fragment doesn't parse;
    • a release cut now would not carry every fragment line;
    • the README's example isn't a valid fragment.

    Its message says: "add your release notes as changelog.d/-.md instead (the format: changelog.d/README.md)".

  • Docs:

    • changelog.d/README.md: the format, the headings, what is refused, and one example.
    • docs/releasing.md step 1, and its v0.5.3 pointer.
    • scripts/release.mjs's header.
    • A grep found no PR template, CONTRIBUTING or agent instruction in the repo that tells authors to add a CHANGELOG bullet (measured: git grep -i changelog).

The line move (measured)

node check-move.mjs <worktree> origin/main (scratch script: multiset of non-blank lines removed from main's CHANGELOG.md against the lines in the new fragment, plus two byte-for-byte checks). It was run after merging main at efc1fd1:

non-blank lines removed from CHANGELOG.md: 186
non-blank lines added to CHANGELOG.md: 0
non-blank lines in the new fragments: 184
removed but not moved: 2
  - "## v0.5.3 (unreleased)"
  - "### Guardrail changes"
moved but not removed: 0
new CHANGELOG.md === old one with the unreleased section cut out, byte for byte: true
rebuilt notes === old body without it, byte for byte: true (66207 vs 66207 chars)

How it was verified

  • Fail-without (measured): method (b). In a fresh worktree at the base (ddcb32a), the new test files were written with Write (sha256 0556c180… is identical to the head copy, and cmp matched test/changelog.test.ts), then built and run: 11 of 37 fail, 26 pass. Causes:
    • TypeError: c.withFragments is not a function (and parseFragment, fragmentNotes, readFragments);
    • the release.mjs changelog run exits 0 and leaves the fragments in place;
    • the backstop fails with CHANGELOG.md has "## v0.5.3 (unreleased)": add your release notes as changelog.d/<issue or PR number>-<short-slug>.md instead ….
  • After (measured):
    • changelog-fragments, changelog, release and comment-policy-cli: 54/54 pass.
    • owner-terminal-removed, the one test changed there (--test-name-pattern=placeholder): 1/1. The file's other tests start bash with /usr/bin on PATH, so CI runs them.
    • All runs used a temp HOME, state dir and TMPDIR under the scratchpad.
  • Checks (measured):
    • npm run typecheck: clean.
    • npm run check:denylist: 1322 files clean.
    • check-dco: all signed off.
    • check-comments --base HEAD^1: off in config.
    • push-gate-hook --range origin/issue-712..HEAD: clean.
  • Round 2 (head a879a00), fail-without (measured, method (b)): I wrote test/changelog-fragments-selfcheck.test.ts with Write into a fresh worktree at d2e549b (sha256 7e3d1f72… matches the head copy), then built and ran it: 2 of 2 fail.
    • Missing expected exception: parseFragment " ": the comment with two trailing spaces is not refused.
    • Missing expected exception: the headings in reverse order: refused: the mutant, imported from memory, is built.
  • Round 2, after (measured, a879a00, temp HOME):
    • changelog-fragments-selfcheck, changelog-fragments, changelog (with the backstop), release and comment-policy-cli: 56/56 pass.
    • owner-terminal-removed's "placeholder" test: 1/1.
    • npm run typecheck: clean.
    • check:denylist: 1320 files clean.
    • The push gate: clean.
    • The adversarial script, rerun: every attack is still refused, or built with 0 lines lost.
  • After merging main (measured, head d2e549b, temp HOME):
    • changelog-fragments, changelog (with the backstop), release and comment-policy-cli: 54/54 pass.
    • The changed owner-terminal test: 1/1.
    • npm run typecheck: clean.
    • check:denylist: 1319 files clean.
    • The push gate passed on push.
  • CI on the first head (measured, head dfa298d): every check passed: test shards 1-4 on Node 22 and 24, dco, denylist, push-gate, scope, new-deps, agentshield, scan and desktop. The real-systemd jobs and full-ci were skipped, as on every PR.
  • Local broad suite (measured, fullsuite.sh, 425 files, mcp- and plugin-fetch excluded):* 3881 pass, 2 fail, 57 skipped. The two failures are "event cache: every view read incrementally…" and "the GPU lock: a flock another process holds…". Neither file touches the changelog code, and both pass in CI on this head (measured), so I take them as local load flakes (inferred).

Existing tests changed

  • test/owner-terminal-removed.test.ts ("one placeholder version…"): it read v0.5.3's Upgrading and After install text from CHANGELOG.md's ## v0.5.3 section. Now it reads CHANGELOG.md's v0.5.3 section if there is one, else the notes the release step builds from changelog.d. That is the same text, byte for byte (measured above). The assertions are unchanged.
    • Narrows anything? No (inferred). The same bullets are checked for the same command. It also fails if the fragment would lose them.
  • test/changelog.test.ts: new type entries and one new test only. No assertion was changed or removed.
    • Two existing tests loop over the repo's (unreleased) sections, and there are now none, so those loops run zero times on repo data. Their fixture halves still run. What they protected (the repo's unreleased notes survive a release) is now covered by the backstop's "a release cut now would carry every fragment line" (inferred: equivalent for fragments).

Package size (measured)

Against main after the merge: 0 B (inferred). The diff against main touches 9 paths (CHANGELOG.md, changelog.d/*, docs/releasing.md, scripts/lib/changelog.mjs, scripts/release.mjs and 3 test files), and none is in the package's files list.

Measured on the first head, npm pack --dry-run --json --ignore-scripts, base worktree (issue-712) against head dfa298d: 477 files, unpacked 4,712,762 B, tarball 1,403,800 B in both. Delta: 0 B. Neither changelog.d/ nor CHANGELOG.md is in the package (files in package.json is an allowlist, and there is no .npmignore). No budget change.

New dependencies

None.

What this can never do

  • It never drops or moves a fragment line into a release silently. The step throws first and writes nothing (measured by the tests, and by mutants below).
  • It never writes or deletes anything when it refuses. That covers a bad fragment, a denylist hit and a section already there (measured, end to end).
  • It never puts fragments into an existing section, dated or unreleased, or into an older version (measured).
  • It never changes a released section. With no fragments its output is withSection's, byte for byte, for every dated section in CHANGELOG.md (measured).
  • It never changes what gets tagged. The tag step is Release: a GitHub-button revert is read from its body, and the tag step refuses an (unreleased) section [PROTECTED: owner merges] #831's, untouched, and still reads only the dated section at HEAD (inferred: diff of the tag path is empty).
  • It never follows a symlink in changelog.d, or decodes a non-UTF-8 fragment into replacement characters (measured).

Compromised component

  • An agent or contributor with a PR: can add or edit a fragment. That is what they could already do with CHANGELOG.md. The owner reviews release notes in the release PR as before. A fragment that would be dropped or misfiled fails the backstop in CI, and fails the release step.
  • Someone with push access: could change changelog.mjs to drop lines. This PR is protected (owner merges), and the release PR shows the whole dated section and the deletions in one diff (inferred).
  • The release step itself (a bug): a mistake in the builder, its grouping or sorting, or the block reader makes the step throw at run time (measured with the mutants below). Dropped or changed lines are caught by the raw-text count. A wrong order or heading placement is caught by the independent expectedNotes comparison. A mistake in expectedNotes itself fails closed: the step refuses correct notes rather than write wrong ones (inferred).
  • Coordinator, dashboard, other instances, hub viewer: no change. Nothing here runs outside scripts/release.mjs and tests, and nothing ships in the package (measured: 0 B delta).

Adversarial pass (measured, adversarial.mjs in the scratchpad)

Mutants were made by editing the module's source in memory and importing it from a data: URL. No repo file was edited.

Attack Result
Mutant: builder drops the last line of each block refused: "- two" is missing
Mutant: builder writes headings in reverse order refused
Mutant (round 2, from the #870 review): grouped writes the headings in reverse order built on d2e549b. Fixed: refused now, by the independent expectedNotes comparison. Permanent test in changelog-fragments-selfcheck.test.ts
Mutant (round 2): grouped reverses the fragments under a heading built on d2e549b (measured by the review). Fixed: refused now (same test)
Mutant (round 2): byNumber reversed built on d2e549b (measured by the review). Fixed: refused now (same test)
(Round 2, from the #870 review) the end-of-notes comment with trailing spaces or a tab built on d2e549b: CHANGELOG kept it, the tag's notes dropped it. Fixed: refused, by the tag step's own pattern. Permanent test for exact, two spaces, a tab, and space-tab-space
Mutant: builder puts every block under the first heading refused
Mutant: block reader trims each line (so parse and check agree) built, on the first version. Fixed: the count now reads each fragment's raw text, not the parsed blocks, so it doesn't depend on the reader it checks. Now refused (" - nested" is missing). A permanent test was added ("an indent trimmed").
## v9.9.7 (…) line inside a fragment refused
## + no-break space + Unreleased refused. Fixed: the first version only refused # + space/tab. Such a line would have passed, then failed the CHANGELOG backstop after release. Any #-led line (up to 3 spaces in) that isn't a fixed heading is now refused. Tests added for NBSP, ##v9, 3-space indent, #unreleased, ###<tab>
### Guardrail changes (the generated heading) to hide notes among the lists refused
The end-of-notes line, so a rerun reads only notes above it refused
Heading with nothing under it; text before the first heading; CRLF; BOM; empty file refused
Misnamed file; same name twice; directory; symlink; Latin-1 bytes refused
Two spellings of Upgrading refused
Lines shaped like the tool's own list lines (- none in this release, - no merged PRs …, - x (#77)) built. An end-of-notes line is written. 0 lines lost from the tag's notes or from a rerun, and the rerun's notes are identical
The intro line and an older section's line as notes built, 0 lost
A code fence left open across fragments built, 0 lines lost (rendering only; not checked)
Fragments beside an (unreleased) section, a dated one, or for an older version refused

In-flight PRs: the one-time conversion

Each open PR that touches CHANGELOG.md does this once, after this PR merges (or after it merges main with this in it):

  1. Drop its CHANGELOG.md hunk.
  2. Add its fragment with the same lines under the same headings.

Measured on 2026-10-20 from each PR's diff at the head shown, against that head's CHANGELOG.md (pr-convert.mjs). "Lines" counts non-blank lines.

PR (head, base) Add Lines
#863 (62b371e, main) changelog.d/748-bench-pinned-downloads.md ### Guardrail changes: what each one does: its 5 lines ("The benchmark (scripts/bench/, not installed) downloads only what … pins" and 4 sub-bullets). Its 6th line is #851's bullet, which goes in #851's fragment, not this one
#851 (1280e63, main) changelog.d/748-bench-preflight.md ### Guardrail changes: what each one does: 1 ("The benchmark's own scripts … never run as root")
#854 (581b5f6, main) changelog.d/739-first-hour.md ### Upgrading (from v0.5.2): 1 ("A server's first hour (#739, part 1 of 2)")
#844 (70d4c89, on #839) changelog.d/786-env-own-variables.md ### Guardrail changes: what each one does: 10 (the bullet, its 8 sub-bullets and its closing paragraph)
#839 (812d206, on #788) changelog.d/785-guard-modules.md ### Guardrail changes: what each one does: 1 ("The agents' tool guard loads 56 modules")
#834 (e9ca4fe, main; being rebuilt on #788) changelog.d/776-resume-waits-green.md ### Upgrading (from v0.5.2): 5 ("Resuming auto-merge … waits for green main" and 4 sub-bullets). The rebuild on #788 doesn't change this: #788's lines stay in #788's own fragment
#788 (987e718, main) changelog.d/727-required-check-pins.md ### Upgrading (from v0.5.2): 1; ### Guardrail changes: what each one does: 1
#803 (5f05593, on #788) no new file Its +1/−1 rewrites #788's Upgrading line, so it edits that line in 727-required-check-pins.md
#789 (d0fafae, main) changelog.d/727-updater-check-pins.md ### After install: 1; ### Guardrail changes: what each one does: 1
#769 (88e9142, main) changelog.d/726-policy-root.md ### Upgrading (from v0.5.2): 1; ### Guardrail changes: what each one does: 1
#770 (cec5a1b, on #769) see note It rewrites #769's two lines in 726-policy-root.md, and adds its 2 new Upgrading lines and 1 After install line (in that file or its own 726-policy-helper.md). Its After install line replaces an existing note ("No sudoers rule is added, so no machine-helper rerun …"). That line now lives in changelog.d/870-notes-before-changelog-d.md, so #770 edits that one line there. It is the only PR that edits the shared fragment (owner: fine)
#746 (3a6d833, main) changelog.d/744-remove-desktop.md ### Upgrading (from v0.5.2): 2. Its third line sits under the generated ### Guardrail changes in its head, which a fragment can't use, so it goes under ### Guardrail changes: what each one does. Its removal of - none in this release has no counterpart (no such line now)
#775 (a75fa32), #794, #867 nothing No CHANGELOG.md change
#831 nothing Merged. Its bullet is in the moved notes

Owner decisions (given 2026-10-20)

Still open: the empty trailing ### Guardrail changes is the one non-blank line of the section not moved (above). Say if you want it kept another way.

Refusals: none.

…ves them into the dated section

Every PR that added a note to the one `## v0.5.3 (unreleased)` section re-conflicted every other open PR on the
same lines (a full re-merge round on 7 PRs). Now each change's notes live in their own file,
changelog.d/<issue or PR number>-<short-slug>.md, holding `### <Heading>` blocks under a fixed set of headings
(Upgrading, After install, Guardrail changes: what each one does, Release tooling, Removals). Two PRs never edit
the same file, so the conflict can't happen.

`release.mjs changelog X.Y.Z` (changelog.mjs withFragments) builds the dated section: the fragments' lines,
verbatim, under the fixed headings in their fixed order, fragments by number, then the generated lists; it deletes
the fragments it used, for the release PR to commit with the section. It writes and deletes nothing when a fragment
line could be dropped or moved: an unknown heading, any other line starting with `#` (a `##` line would start a
section), an empty fragment, text before the first heading, a heading with nothing under it or given twice, a CR,
the end-of-notes line, a misnamed file or anything else in the folder, two spellings of Upgrading, or a CHANGELOG.md
that already has a section for the version. The result is checked two ways after it is built (every raw fragment
line counted, and the notes read back as blocks), and the denylist scan now runs before anything is written. With no
fragments it is withSection, byte for byte. The tag step is unchanged.

The v0.5.3 section moves into one changelog.d fragment, verbatim: 151 non-blank lines, and the release step rebuilds
the old section's body from it byte for byte. Not moved: the `## v0.5.3 (unreleased)` heading and the section's
empty trailing `### Guardrail changes`, which the generated lists write.

test/changelog.test.ts gains the backstop: it fails on any (unreleased) or undated `##` section in CHANGELOG.md, or a
fragment the release step would refuse, and tells the author to add changelog.d/<n>-<slug>.md. The owner-terminal
test reads v0.5.3's notes from changelog.d until the release moves them.

Test (fails without): test/changelog-fragments.test.ts (10 tests) and the backstop in test/changelog.test.ts; 11 of
37 fail on the base (TypeError: c.withFragments is not a function; the step exits 0 and leaves the fragments; the
backstop names "## v0.5.3 (unreleased)").

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…3 notes move into the fragment

CHANGELOG.md conflicted only inside the `## v0.5.3 (unreleased)` section this branch removes. It is resolved to
main's CHANGELOG.md with that section cut out, byte for byte. Everything main's section gained since ddcb32a is in
changelog.d/870-notes-before-changelog-d.md, verbatim, under the same headings and in the same order: #738, #736
(which also rewrote the #749 bullet, taken as main has it), #832, #708, deploy/**, #783, #707, #821, #705, and the
new `### Removals` (#743).

Move check against origin/main: 186 non-blank lines removed from CHANGELOG.md, 184 in the fragment. The two not
moved are the `## v0.5.3 (unreleased)` heading and the empty trailing `### Guardrail changes`. The notes the release
step builds from the fragment equal main's section body byte for byte (66207 chars).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED

Reviewed head d2e549b (the merge of main efc1fd1 after #831 into the branch). I also checked dfa298d before the merge, so this one comment covers both. The code files are byte-identical between the two heads. Only CHANGELOG.md, the moved-notes fragment and docs/releasing.md (main's own edits, auto-merged) differ (measured: git diff --stat dfa298d0 d2e549bd -- scripts/ test/changelog*.ts test/owner-terminal-removed.test.ts changelog.d/README.md).

The design holds. There are two small required changes: one input the step should refuse but builds, and one claim to correct.

Acceptance criteria

Criterion Result Evidence
Each change's notes live in their own changelog.d/<n>-<slug>.md, so two PRs never edit the same lines met readFragments / FRAGMENT_NAME. The README gives the format, and the backstop forbids any (unreleased) or undated ## section (measured, below)
The current unreleased notes moved verbatim met Measured with my own script, against main efc1fd1: 186 non-blank lines removed, 0 added, 184 in the fragment. Removed but not moved: exactly ## v0.5.3 (unreleased) and ### Guardrail changes. Head CHANGELOG.md === main's with [## v0.5.3 (unreleased), ## v0.5.2) cut out, byte for byte. The fragment .trim() === main's section body minus its heading and the empty trailing heading, byte for byte (same order). The same check on dfa298d against ddcb32a gave 153 / 151, same two lines, also byte-identical
Dropping the empty ### Guardrail changes loses nothing met Measured: the base tool on main's CHANGELOG and the head tool on the head's CHANGELOG + fragment, with the same generated lists. The base output has exactly two lines more, the empty ### Guardrail changes and the NOTES_END comment it forced. The head output has nothing the base lacks. The rest of the file is identical. tagNotes accepts both, because the generated lists always write ### Guardrail changes
release.mjs changelog X.Y.Z builds the dated section under fixed headings and deletes the fragments met E2E below
#829's invariant (no line lost, or throw and write nothing) holds for fragments partly Drops are caught by the raw-line count, independent of the parser (measured, mutant "drops the last block" refused). One input is built whose line the tag notes then drop: required change 1
Released sections regenerate byte-identically met Measured: rest of file identical in the E2E, rerun with own section is a no-op: true, and the test "the repo's released sections regenerate byte for byte" passes
The tag step (#831) is unchanged met Measured: git diff efc1fd18 d2e549bd -- scripts/release.mjs has hunks only at lines 5 (header comment), 20 (imports) and 78 (the changelogOnly branch, which exits before the tag path). changelog.mjs: git diff -U0 shows only the header and additions after tagNotes. tagNotes, withSection and sectionFor are untouched
The backstop fails on an (unreleased) section or an unparseable fragment met Measured in a scratch worktree: with ## v0.5.4 (unreleased) re-added, it fails with CHANGELOG.md has "## v0.5.4 (unreleased)": add your release notes as changelog.d/.... In the post-release state (no fragments) all 37 changelog tests pass. An unparseable fragment fails it through fragmentNotes (inferred from code; the same refusals are measured in changelog-fragments.test.ts)

Merge commit (measured)

git merge-tree --write-tree dfa298d0 efc1fd18 gives tree 825aac44, with a conflict only in CHANGELOG.md. docs/releasing.md auto-merged. git diff --stat 825aac44 d2e549bd lists only CHANGELOG.md and changelog.d/870-notes-before-changelog-d.md. d2e549b's parents are dfa298d and efc1fd1. So the merge equals a clean merge except for those two files, and those two are the verbatim move above.

Release path end to end (measured, d2e549b)

I ran this in a scratch worktree, .worktrees/eval-870-e2e2: node scripts/release.mjs changelog 0.5.3 --repo jayden1711/worklane, with temp HOME and TMPDIR and a stand-in gh first on PATH (it returns [], so nothing reached GitHub). Nothing was tagged, pushed or published.

  • Exit 0: CHANGELOG.md: v0.5.3, 107 PR(s) since v0.5.2, 1 changelog.d fragment(s) moved into it and deleted.
  • git status: M CHANGELOG.md, D changelog.d/870-notes-before-changelog-d.md. The folder now holds only README.md.
  • The ## v0.5.3 (2026-10-20) section has all 184 fragment lines (0 missing, counted). notesOf(section) === fragment.trim(). The fixed headings come in order: Upgrading (from v0.5.2), After install, Guardrail changes: what each one does, Release tooling, Removals. The generated ### Features and ### Guardrail changes follow. No NOTES_END line was needed. Older sections and the intro are byte-identical. tagNotes accepts it.

Fail-without (measured, method (b))

I made a fresh worktree at ddcb32a. I wrote test/changelog-fragments.test.ts with Write and applied the test/changelog.test.ts hunks with Edit. Both sha256 match the head copies (0556c180…, 2d3cebee…). Built with npx tsc -p tsconfig.json and ran node --import ./dist/test/isolate.js --test dist/test/changelog-fragments.test.js dist/test/changelog.test.js with temp HOME, state and TMPDIR: 37 tests, 26 pass, 11 fail.

  • Tests 1-8 fail with TypeError: c.withFragments is not a function.
  • Both release.mjs changelog tests fail because the step exits 0 and ignores the fragments (the section has only - no merged PRs …).
  • The backstop fails with CHANGELOG.md has "## v0.5.3 (unreleased)": add your release notes as changelog.d/….

On the head, changelog-fragments, changelog and release give 49/49 pass. owner-terminal-removed --test-name-pattern=placeholder gives 1/1, on the head and again on the post-release tree. Its other tests start bash with /usr/bin on PATH, so I didn't run them; CI does. npm run typecheck is clean and npm run check:denylist reports 1319 files clean (measured).

Drop attacks beyond the builder's list (measured: in memory, data: URL mutants, and the scratch worktree)

  • Built, a line lost from the tag notes: a fragment line that is the end-of-notes comment with trailing whitespace. blocksOf refuses only line === NOTES_END, but NOTES_END_LINE and tagNotes' stripper allow [ \t]* after it. I ran it end to end in the scratch worktree: a fragment 900-trailing-notes-end.md (### Removals, - before, the comment plus 2 spaces, - after), then release.mjs changelog 0.5.4. It exits 0 and deletes the fragment. The CHANGELOG section keeps the line (count 1), but tagNotes(…, '0.5.4').notes has it 0 times. Handling is also inconsistent: with a trailing tab and one fragment, it is refused by the read-back; with a trailing tab and two fragments, it is built. The impact is small, since the line is an HTML comment and doesn't render. Still, the step deletes a fragment whose line the tag notes then drop, and the PR body says "The end-of-notes line … refused". → required change 1.

  • Built, nothing lost, rendering only (inferred harmless; FYI):

    • an unclosed <!--, which hides everything after it when rendered, including the PR lists in the release body;
    • a setext underline (---/=== under a line);
    • a fragment whose first line is indented, which renders as a continuation of the previous fragment's last bullet under the same heading.

    These are the same class as the open code fence the builder disclosed.

  • Refused:

    • U+2028 inside a line;
    • ### Guardrail changes after a lazy continuation;
    • the earlier refusals. Tab-led ## and 4-space-indented ## v… / ### Guardrail changes are built as code text with 0 lines lost, and no section boundary moves.
  • Mutants in the helpers the self-check shares:

    • These are built by the step: grouped writing headings in reverse order, grouped reversing fragments within a heading, and byNumber reversed. The step's own fragmentProblems uses the same grouped/byNumber, so it agrees with the mutated output. The real fragmentProblems flags all three. A mutant that drops lines is still refused, because the raw count is independent.
    • So at run time, drops are caught independently of the code they check, but order and heading placement are checked against the same grouping code. The tests' literal expected notes would catch these (inferred: test 1 asserts the exact text and the reversed input order).
    • Correct ordering is not in doubt: measured on the real fragment above. But the PR body's "a mistake in the builder or in the block reader is caught by the raw-text line count and the read-back" overstates it. → required change 2.

Narrowing (item 4)

  • The two loops that now run zero times on repo data. These are the (unreleased) loop in "an unreleased section is all hand-written…" and the unreleased branch of "the repo's CHANGELOG.md: … an unreleased one can't be tagged". They narrow nothing (inferred).
    • Their fixture halves still run.
    • The backstop now forbids an (unreleased) section outright, which is stricter.
    • It calls withFragments on the repo's real fragments, and that throws unless every raw non-blank fragment line is in the built notes. That is the fragment equivalent of "the release would drop these lines".
    • "every section … keeps its hand-written text" still covers every released section.
  • owner-terminal-removed's "placeholder" test. It reads sectionFor(CHANGELOG, '0.5.3'), or else fragmentNotes of every fragment. The assertions are unchanged, and it passes both before and after the release step (measured). Not narrowed (inferred).
  • The backstop is a real replacement and is named as a backstop. The by-design protection is the per-file layout plus the run-time refusal in withFragments (owner durability rule satisfied).
  • One gap, FYI, not required: a bullet added under an already released section passes the backstop (measured in the scratch worktree). It's outside the brief. A PR still on the old layout can't add one silently, because its hunk conflicts with the removed section (inferred).

Shared moved-notes fragment and the conversion steps (item 6, measured)

Script over gh pr diff for the 12 open PRs that touch CHANGELOG.md:

Protection, scope, hygiene

  • Protection: correctly marked. The title says [PROTECTED: owner merges], the body has **Risk class:** protected, the PR has the needs-owner label, and the "What this can never do" and "Compromised component" sections are real. The "can never do" claims hold, except the end-of-notes item above.
  • Package: neither changelog.d nor scripts/lib is in the files allowlist (measured), so nothing ships.
  • Scope: as briefed.
  • Commits: both are signed off. There are no Co-Authored-By or "Generated with" lines (measured). No dependency changes (measured: no package.json or package-lock.json diff).
  • CI (d2e549b, at time of writing): dco, denylist, push-gate, scope, new-deps, agentshield, scan, desktop and test shard 2/4 (Node 24) pass. The other test shards are still pending.

Required changes

  1. Refuse the end-of-notes comment with trailing whitespace. In blocksOf, refuse every line the tag step would strip: test against NOTES_END_LINE (or line.trimEnd() === NOTES_END), not ===. Add a test case with trailing spaces and one with a trailing tab, through parseFragment, fragmentNotes and withFragments. Principles 1 and 5: the body says this line is refused.
  2. Correct the PR body's claims.
    • The run-time check catches dropped lines independently. Heading order and fragment order are checked against the same grouped/byNumber code, so a mistake there is caught by the tests, not at run time. Alternatively, make the read-back independent of grouped, for example by checking each fragment block's text as a contiguous run under its own heading, in fixed-heading order.
    • Update the stale parts for d2e549b: base is main, the move counts are 186/184, and the "merging main" owner item is now done.

For the owner (genuinely new trade-offs, from the builder's list; none blocks)

…he built notes apart from the builder's order code

Two gaps the #870 review measured.

1. A fragment line that was the end-of-notes comment with trailing spaces or a tab was built: CHANGELOG.md kept it,
   and the tag's notes dropped it, after the step had deleted the fragment. The fragment reader now refuses every
   line NOTES_END_LINE matches, through isNotesEndLine, made from that same pattern (what notesOf and tagNotes
   match). There is no second copy of the rule, and tagNotes is unchanged.
2. The check on the built notes read them back through the builder's own grouping and sorting (grouped, byNumber),
   so a mistake there (headings reversed, fragments reversed under a heading, the number sort reversed) was built,
   not refused. The check now compares the notes, byte for byte, with expectedNotes. It works out the order on its
   own: FRAGMENT_HEADINGS in order, the files by the number in their name and then the name (its own comparison),
   each file's raw lines (its own walk and trim). The raw-line count stays.

Test (fails without): test/changelog-fragments-selfcheck.test.ts, 2 tests, both fail on d2e549b. The end-of-notes
comment with two trailing spaces is not refused; the reversed-heading mutant (imported from memory) is built.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

jayden1711 commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner Author

Round 2 is pushed as a879a00 (one commit on top of d2e549b, signed off).

  1. A fragment line the tag step would strip is refused. The reader now refuses every line NOTES_END_LINE matches, through isNotesEndLine. That predicate is built from the tag step's own pattern, so there is no second copy of the rule, and tagNotes is unchanged (measured: git diff d2e549bd a879a004 -- scripts/lib/changelog.mjs touches only four things: the new export, the reader's line, fragmentProblems and its doc comment, and the new expectedNotes function).
    • Tests cover the exact comment, two trailing spaces, a tab and space-tab-space, each through parseFragment, fragmentNotes and withFragments.
  2. The check on the built notes no longer reuses the builder's grouping or sorting. fragmentProblems now compares the notes byte for byte with expectedNotes, which works the order out on its own: FRAGMENT_HEADINGS in order, the files by the number in their name and then the name (its own comparison), and each file's raw lines (its own walk and trim). The raw-line count stays.
    • A new test imports three mutants from memory (data: URL, no file written): grouped reversing the headings, grouped reversing the fragments under a heading, and byNumber reversed. It asserts that each one builds different notes and that the step refuses it.
  3. The PR body is corrected to match. It is also updated for d2e549b (base main, move counts 186/184, merge done) and for the owner's decisions.

Fail-without (measured, method (b)). I wrote test/changelog-fragments-selfcheck.test.ts with Write into a fresh worktree at d2e549b (sha256 7e3d1f72… matches the head copy): 2/2 fail.

  • Missing expected exception: parseFragment " "
  • Missing expected exception: the headings in reverse order: refused

After (measured, a879a00, temp HOME):

  • selfcheck, changelog-fragments, changelog, release and comment-policy-cli: 56/56 pass.
  • owner-terminal "placeholder": 1/1.
  • typecheck: clean.
  • denylist: 1320 files clean.
  • The push gate: clean.

@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict (round 2): PASS

Delta check of head a879a00, one commit on d2e549b. Both round-1 required changes are done, and nothing else changed.

The delta (measured)

  • git log d2e549bd..a879a004: one commit, with d2e549b as its parent. It is signed off and has no attribution trailers.

  • git diff --stat d2e549bd a879a004 touches only two files: scripts/lib/changelog.mjs (+52/−15) and the new test/changelog-fragments-selfcheck.test.ts (+65).

  • The hunks in changelog.mjs are exactly three:

    1. the isNotesEndLine export;
    2. the refusal in blocksOf, now isNotesEndLine(line) instead of line === NOTES_END;
    3. fragmentProblems: the read-back through blocksOf + grouped is replaced by a byte-for-byte comparison with the new expectedNotes, plus its doc comment.

    Nothing else changed. tagNotes, withSection, notesOf, sectionFor, grouped, byNumber and blocksOf' other checks have no hunk. scripts/release.mjs is untouched.

isNotesEndLine is what the tag step strips (inferred from code, measured by attack)

  • isNotesEndLine = (line) => new RegExp(NOTES_END_LINE.source).test(line). That is the same source tagNotes uses to build its stripper: \n\n${NOTES_END_LINE.source minus ^}(?=\n), under gm. notesOf uses that source too.
  • There is no second copy of the pattern, and tagNotes is not narrowed: it has no hunk.
  • The new check is stricter than the old one, ===, so this is no loosening.

Round-1 attacks, rerun on a879a00 (measured, in memory from data: URLs; no file edited)

  • End-of-notes comment, all refused:

    • with 2 trailing spaces;
    • with a trailing tab;
    • with a trailing tab across two fragments.

    Each fails with line 5 is the end-of-notes line the release step writes itself (the tag's notes would drop it).

  • Order mutants, all refused with the release notes aren't the fragments' lines in the fixed order: line N is …, where … belongs:

    • grouped with the headings reversed;
    • grouped with the fragments reversed under a heading;
    • byNumber reversed.
  • Extra mutants on the parser side, both refused:

    • keyOf filing After install under the Release tooling key (by the order check);
    • blocksOf trimming each line (by the raw count: " - nested" is missing).
  • Still built: only a reordered FRAGMENT_HEADINGS constant. That constant is the spec itself, shared by design. Test 1 of changelog-fragments.test.ts pins it with deepEqual, which I measured in round 1.

  • Rendering-only cases: unchanged. They are built with 0 lines lost, as reported in round 1.

expectedNotes is independent (inferred from code)

  • expectedNotes calls none of grouped, byNumber, blocksOf, keyOf, UPGRADING, parsedFragments or parseFragment. It has its own versions of each:
    • its own file sort: numberIn from the name, then the name;
    • its own walk of each file's raw lines, by startsWith('### ');
    • its own heading match: isUnder, exact, or Upgrading (from v for Upgrading;
    • its own trimming of blank lines at the ends.
  • It shares only the FRAGMENT_HEADINGS constant.
  • Its isUnder is looser than UPGRADING. That is safe, because fragmentProblems runs parsedFragments first, and that throws on any bad heading or a second Upgrading spelling.
  • Byte equality is stricter than the old read-back, so this is not a weakening.
  • The mutants above that expectedNotes caught are the measured proof that it is independent.

Fail-without (measured, method (b))

  • Setup: a fresh worktree at d2e549b, with test/changelog-fragments-selfcheck.test.ts written with Write (sha256 7e3d1f72…, the same as the head copy). Installed and built through npmw.sh (ci, run build).
  • Command: node --import ./dist/test/isolate.js --test dist/test/changelog-fragments-selfcheck.test.js, with temp HOME, state, held-out dir and TMPDIR.
  • Result: 2 of 2 fail.
    • Missing expected exception: parseFragment " "
    • Missing expected exception: the headings in reverse order: refused

Tests and checks on a879a00 (measured)

  • Tests, with temp HOME, state, held-out dir and TMPDIR:
    • changelog-fragments-selfcheck, changelog-fragments, changelog (with the backstop) and release: 51/51 pass.
    • owner-terminal-removed with --test-name-pattern=placeholder: 1/1.
  • Checks: npm run typecheck is clean, and npm run check:denylist reports 1320 files clean.
  • CI: every check on a879a00 passes, including test shards 1-4 on Node 22 and 24, dco, denylist, push-gate, scope, new-deps, agentshield, scan and desktop. The real-systemd and bootstrap jobs are skipped, as on every PR.

End to end on a879a00 (measured)

Run in the scratch worktree: node scripts/release.mjs changelog 0.5.3 --repo jayden1711/worklane, with temp HOME and TMPDIR and a stand-in gh that returns []. Nothing was tagged, pushed or published, and nothing reached GitHub.

  • It exits 0: 1 changelog.d fragment(s) moved into it and deleted.
  • git status: M CHANGELOG.md, D changelog.d/870-notes-before-changelog-d.md. Only README.md is left in the folder.
  • The fragment at a879a00 is byte-identical to the one at d2e549b (cmp).
  • 184 of 184 fragment lines are in ## v0.5.3 (2026-10-20), counted, with 0 missing. notesOf(section) === fragment.trim().
  • The five fixed headings come in order, then the generated ### Features and ### Guardrail changes.
  • Released sections and the intro are byte-identical. tagNotes accepts the result, and a rerun changes nothing.

PR body claims

  • The body's round-2 statements match what I measured:
    • fail-without 2/2 with the same two messages;
    • isNotesEndLine built from NOTES_END_LINE, with tagNotes unchanged;
    • expectedNotes sharing no code with grouped, byNumber or blocksOf;
    • the three mutants and the trailing-whitespace rows now refused;
    • typecheck clean, denylist 1320 files clean.
  • The "Compromised component" line now correctly separates drops (raw count) from order and placement (expectedNotes). It labels "a mistake in expectedNotes fails closed" as inferred, which I agree with.
  • Minor, non-blocking: the "CI" bullet still cites only the first head, dfa298d. CI on a879a00 is all green (measured above).

Protection and hygiene

  • Still correctly marked protected, with the needs-owner label. This is the owner's merge.
  • No new dependencies, and no package paths: changelog.d/ and scripts/lib/ are outside the files allowlist.
  • Commits are signed off, with no attribution lines.
  • Round-1 items still hold, since the code is unchanged outside the three hunks: the verbatim move (186/184 against main), the merge-commit equivalence, the untouched tag path and the backstop.

No required changes.

@jayden1711
jayden1711 merged commit ec3fd42 into main Oct 20, 2026
27 checks passed
@jayden1711
jayden1711 deleted the changelog-fragments branch October 20, 2026 18:00
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…in-required-checks.md

Main's CHANGELOG.md holds released sections only (#870). The two bullets this PR added are the fragment's, byte for byte, under the same headings; CHANGELOG.md is main's exactly.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
This PR's only note was its edit of #788's Upgrading bullet (the paste-ready block). With notes in fragments (#870), that edit is made in changelog.d/788-pin-required-checks.md, byte for byte as before; CHANGELOG.md is #788's exactly, and this PR adds no fragment of its own (it adds no note line of its own).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…854-server-first-hour.md

CHANGELOG.md is main's, byte for byte (#870); the fragment holds this PR's one bullet, unchanged, under Upgrading (from v0.5.2).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…ins.md

Main's CHANGELOG.md holds released sections only (#870). The two bullets this PR added are the fragment's, byte for byte, under the same headings; CHANGELOG.md is main's exactly.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…loads 57 modules

Since #870, release notes live in changelog.d/, one file per PR, and CHANGELOG.md holds only released sections.
This PR's one note, under "Guardrail changes: what each one does", moves to changelog.d/839-guard-loads-only-its-modules.md
word for word, with one change the stack-delta review asked for: the guard loads 57 modules, not 56. Stacked on #788,
whose src/check-source.ts is in the guard's tree through src/backlog/types.ts, the pinned tree is 57 modules.

Test (fails without): test/changelog-fragments.test.ts and test/changelog.test.ts read every fragment; the note itself
changes no behaviour.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…helper

#870 moved release notes to changelog.d/; CHANGELOG.md is now #769's, byte
for byte. #770's notes, as before the conversion:
- its two own Upgrading bullets: changelog.d/770-dashboard-only-tightens.md;
- its two rewrites of #769's bullets (the existing-install step gains the
  machine-helper rerun sentence; the guardrail note gains the dashboard
  and tighten-only part): in #769's fragment,
  changelog.d/769-policy-yaml-root.md;
- its rewrite of the old "No sudoers rule is added, so no machine-helper
  rerun" After install line, which is false once set-policy ships: that one
  line in the shared changelog.d/870-notes-before-changelog-d.md, replaced
  with the set-policy sudoers text.

docs/releasing.md step 9 conflicted: #770's "0.5.3: yes (set-policy,
#726)" line is kept, ending with main's new pointer text ("Its other root
steps are in v0.5.3's "After install" notes (changelog.d until the
changelog step moves them into CHANGELOG.md)").

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…rc/ack-reason.ts

Main's CHANGELOG.md holds released sections only (#870): this PR's notes are changelog.d/834-resume-waits-for-green-main.md, rewritten for the redesign (the coordinator answers only a human owner's request; a stop file with no merge.stopped is answered too; the measurement shown is the stop in force's). src/ack-reason.ts is reached by the guard through src/events/types.ts, so it is listed and locked as the guard (test/review.test.ts).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…part 2 of 2)

The release note for this PR's change, as its own fragment (#870): Upgrading (from v0.5.2).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
… guard's locked tree (58)

Since #870, release notes are fragments in changelog.d/ and CHANGELOG.md holds released sections only. The #786
bullet this PR had in CHANGELOG.md moves, byte for byte, into changelog.d/844-env-reads-ignore-prototype.md under the
same heading, with one change the stack-delta review required: it says that src/os/own-env.ts joins the agents' tool
guard's locked tree, which takes it to 58 modules.

Test: test/changelog.test.ts and test/changelog-fragments*.test.ts pass with the fragment (measured).
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
The owner's option (a) for #746. An updater from v0.5.2 or older has no retired-check rule: it requires a
successful "desktop" run on every commit it installs, and the release channel reads the checks on a release's
base commit the same way. Removing the job with the app would leave every v0.5.2 install waiting forever
("required check "desktop" hasn't run on this commit"). So the job stays, same id and check name, with one
`true` step and nothing that can skip, fail or rename it, and the desktop entry stays in .worklane/config.yaml
and UPDATE_DEFAULT_CHECKS. #877 removes the job and the check together a release later.

This also puts back what the branch had changed only because the job was gone: ci-slow's --add-check desktop
step, update-checks' list of PR checks, and the desktop entries in review.yaml's engine globs,
package-files' forbidden prefixes and chrome-keychain's walked folders (each a narrowing; the owner's call,
with #877).

Release notes: a changelog.d fragment (Upgrading: the check stays one more release, and v0.5.2 installs rerun
updates.sh once; Removals: the window). #870's fragment said the release that removes the window also removes
the job; it now says the job stays one more release.

Test (fails without): test/desktop-removed.test.ts. On origin/main ec3fd42: 3 of 7 fail (the desktop job
isn't a no-op, the app is still there, no note). On this branch before this commit (285434a, the job
removed): 4 of 7 fail, among them "UPDATE_DEFAULT_CHECKS ... desktop" with no job and "no such job".

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant