Skip to content

chore(changelog): land entries as per-change fragments in changelog.d/ - #128

Merged
Anarchid merged 5 commits into
mainfrom
chore/changelog-fragments
Aug 24, 2026
Merged

chore(changelog): land entries as per-change fragments in changelog.d/#128
Anarchid merged 5 commits into
mainfrom
chore/changelog-fragments

Conversation

@Anarchid

@Anarchid Anarchid commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

The changelog policy has a structural conflict generator: every PR inserts prose at the same textual location (the top of ## Unreleased in CHANGELOG.md), so any PR that outlives another merge conflicts there. Concretely: #115's only textual conflict against main is CHANGELOG.mdsrc/framework.ts and everything else merge cleanly — because #117, #121 and #126 each landed Unreleased entries while it was in review. With the current review cadence this recurs on essentially every long-lived PR. This is the same problem that pushed towncrier, Changesets, and GitLab to per-change fragment files.

Changes

Entries now land as one uniquely-named file per changechangelog.d/<slug>.<breaking|added|changed|fixed>.md, containing the entry's markdown bullets verbatim (slug = PR number or branch name). Distinct files never conflict in git.

  • scripts/release-changelog.mjs (the npm version hook) folds pending fragments plus anything filed directly under ## Unreleased into the new ## X.Y.Z — date section (subsections emitted in Breaking/Added/Changed/Fixed order; fragments join an existing matching subsection, including audience-qualified ### Breaking (…) headings), deletes the consumed fragments, and keeps all existing refusals: no/multiple Unreleased headings, duplicate version section, nothing to release. New refusals: unrecognized category suffix and non-bullet fragment content — loud failure instead of silently stranding an entry.
  • changelog.yml soft check now passes on a well-formed new fragment or a CHANGELOG.md edit (transition-friendly: in-flight PRs like feat: tune-out — subconscious summaries instead of unsubscribing (#77) #115 that already edited CHANGELOG.md keep passing; no-changelog label escape unchanged). changelog.d/README.md and badly-named fragments do not satisfy it.
  • publish.yml is untouched — the tag-time section guard and the github-release mirror job both read the assembled versioned section, which exists at tag time exactly as before because assembly runs pre-tag.
  • Migration needs no flag day: direct ## Unreleased edits remain a supported input, merged at release time. The three entries currently sitting under Unreleased on main stay where they are and release normally.
  • CONTRIBUTING.md ## Changelog section rewritten, PR-template checkbox updated, changelog.d/README.md documents the format in-place, and this PR dogfoods the scheme with its own fragment (changelog-fragments.changed.md), so the next release exercises the pipeline.
  • One deliberate style shift: breaking entries name their audience at the start of the bullet (- **Module authors:** …) instead of in a subsection-heading qualifier; the heading-qualifier form still works via a direct Unreleased edit.

Companion PRs land the same scheme across the ecosystem (same shape as the July policy rollout): antra-tess/membrane#49, anima-research/context-manager#72, anima-research/chronicle#16, anima-research/connectome-host#102. Safe to merge in any order — each repo's scheme is self-contained.

Tests

Release script exercised on a copy through all legs (script output pasted from the runs):

  1. Clean cut — 4 fragments (breaking/added/fixed/dogfood-changed) + the 3 real Unreleased entries on main: correct merge into existing subsections, canonical subsection order, continuation lines preserved, fragments deleted, README.md kept. Exit 0.
  2. Refuse-duplicatea '## 0.11.0' section already exists. Exit 1.
  3. Refuse-empty — fresh Unreleased, no fragments: nothing to release as 0.12.0 — no fragments in changelog.d/ and no entries under '## Unreleased'. Exit 1.
  4. Fragments-only cut (empty Unreleased) — clean section, exit 0.
  5. Refuse bad category suffix (oops.md) — exit 1 with naming guidance.
  6. Refuse non-bullet fragment — exit 1.
  7. Tag guard + github-release awk simulated on the assembled output: guard passes, awk extracts the 32-line section, non-empty.
  8. changelog.yml grep logic simulated on six changed-file sets: src+fragment PASS, src+CHANGELOG PASS (legacy), src-alone FAIL, src+changelog.d/README.md-only FAIL, src+badly-named-fragment FAIL, docs-only PASS.

Not verified

A real npm version run end-to-end (next actual release is the live exercise — same status as the existing tag-time guards, which are also still unexercised since the policy landed). The changelog check will run live on this PR itself.


  • Changelog fragment added — changelog.d/changelog-fragments.changed.md.

🤖 Generated with Claude Code

Concurrent PRs editing the shared '## Unreleased' section of CHANGELOG.md
conflict whenever one PR outlives another merge (e.g. #115, whose only
textual conflict against main is CHANGELOG.md). Entries now land as
uniquely-named fragment files (changelog.d/<slug>.<category>.md), which
git merges without conflict; the npm-version hook folds them into the
release section and deletes them. Direct '## Unreleased' edits remain
supported and are merged at the same point, so in-flight PRs need no
rework. Tag-time publish guard and github-release job are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟠 NEEDS ATTENTION

Reviewer: Codex (GPT-5.6 Sol)

Reviewed head: 018547507c7b7d42c12743195cc492af0c607d3f

Two release-policy defects should be fixed and pinned with automated tests before merge.

  1. .github/workflows/changelog.yml:25-30 — deleting somebody else’s fragment satisfies the check.

    changed=$(git diff --name-only "$base...$head")
    # any matching changelog.d path is accepted

    --name-only does not distinguish additions/modifications from deletions. I reproduced a two-commit PR that modified src/example.ts and deleted changelog.d/old.fixed.md; the exact workflow condition printed OK/passed, while git diff --name-status showed D changelog.d/old.fixed.md. That weakens the new policy in precisely the path meant to enforce it and can also remove an unrelated pending entry.

    Filter out deletions (for example with an appropriate --diff-filter) and verify that the accepted fragment exists in the PR head. Preserve rename support by validating the destination path/status rather than falling back to name-only matching.

  2. scripts/release-changelog.mjs:50-56 — only the fragment’s first line is validated as a bullet.

    if (!/^[-*] /.test(body)) fail(...)

    The documented format requires one or more bullets with indented continuation lines, but this regex only anchors the beginning of the entire file. A fragment containing - Valid first bullet. followed by unindented top-level prose was accepted, consumed, and emitted into the version section. This disproves the stated “refuse non-bullet fragment” behavior beyond the simplest first-line case.

    Validate every nonblank top-level line: it must begin a bullet, while continuation/nested lines must be indented. Add fixtures for multiple bullets, valid continuations, an invalid first line, and invalid later top-level prose.

Tooling results

  • git diff --check origin/main...HEAD: passed.
  • npx tsc --noEmit: passed.
  • node --import tsx --test test/framework.test.ts: passed.
  • Isolated release fold with current Unreleased entries plus the dogfood fragment: passed; category order and fragment deletion were correct.
  • Deleted-fragment workflow probe: failed policy expectation; workflow passed a D fragment plus src/ modification.
  • Later non-bullet-line release probe: failed policy expectation; malformed fragment was released successfully.
  • GitHub Ubuntu, macOS, and changelog checks: passed.

Verdict: the assembler’s normal path is sound, but the check and refusal contract each have a demonstrated hole. Fix those two cases and add a small committed regression harness; no runtime-framework blocker was found.

Anarchid added a commit to Anarchid/zulip_mcp that referenced this pull request Aug 24, 2026
Brings zulip_mcp onto the ecosystem changelog policy directly in its
fragment form (no shared-Unreleased era to transition from): entries
land as uniquely-named files changelog.d/<slug>.<category>.md, which
git merges without conflict; the npm-version hook folds them into the
release section and deletes them. Direct '## Unreleased' edits are also
accepted and merged at the same point. Since this repo has no publish
workflow, the scheme is the pre-tag core only (CHANGELOG.md, fragments,
release script, version hook, soft PR check); format doc lives in
changelog.d/README.md as there is no CONTRIBUTING.md. Reference
implementation: anima-research/agent-framework#128.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Anarchid added a commit to Anarchid/zulip_mcp that referenced this pull request Aug 24, 2026
Brings zulip_mcp onto the ecosystem changelog policy directly in its
fragment form (no shared-Unreleased era to transition from): entries
land as uniquely-named files changelog.d/<slug>.<category>.md, which
git merges without conflict; the npm-version hook folds them into the
release section and deletes them. Direct '## Unreleased' edits are also
accepted and merged at the same point. Since this repo has no publish
workflow, the scheme is the pre-tag core only (CHANGELOG.md, fragments,
release script, version hook, soft PR check); format doc lives in
changelog.d/README.md as there is no CONTRIBUTING.md. Reference
implementation: anima-research/agent-framework#128.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nts in CI

Two findings from Greptile's review of the membrane companion PR
(antra-tess/membrane#49), applicable wave-wide since the code is shared:
a fragment whose first line was a bullet could smuggle top-level prose
or a rogue '## ' heading (a fake section boundary) into the released
changelog, and the CI check's path-only grep let a deleted or renamed
fragment satisfy the entry requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Anarchid

Copy link
Copy Markdown
Collaborator Author

Greptile's review of the membrane companion (antra-tess/membrane#49) found two issues in the shared code, fixed here in 7fc6a14: fragment validation now checks every line (top-level prose or a rogue ## heading refuses the release), and the CI check counts only added fragments (--diff-filter=A), so deleting/renaming one no longer satisfies it.

…egression tests

Codex QA pass on the membrane companion (antra-tess/membrane#49) found
that a slug taken verbatim from a 'fix/foo' branch name — which the docs
invited — created changelog.d/fix/foo.fixed.md, and the scanner skipped
the 'fix' directory without a word: a silent release-note data-loss path
that PR CI catches but direct pushes bypass. The scanner now aborts on
any entry that is not README.md or a well-formed fragment file, and the
docs prescribe filesystem-safe slugs ('/' -> '-').

The continuation-line check also treated any whitespace-prefixed line as
valid, so an indented '## ' heading still passed. Continuations must now
be indented two or more spaces, and headings/rules are refused at any
indentation.

test/release-changelog.test.ts runs the real script in a throwaway
directory: fragment-only and mixed assembly, canonical ordering,
audience-qualified headings, deletion + README preservation, and
fourteen refusal cases asserting nothing is modified on failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Anarchid

Copy link
Copy Markdown
Collaborator Author

A Codex QA pass on the membrane companion (antra-tess/membrane#49, blocking) found two more shared-code issues, fixed here in d94b4d1: a fragment slug taken verbatim from a fix/foo branch name produced a subdirectory the scanner silently skipped (now fail-closed — anything that isn't README.md or a well-formed fragment aborts the release; docs prescribe /- slugs), and indented ## headings still slipped past the line check (now a strict grammar: two-space continuations, headings/rules refused at any indent). It also asked for automated coverage: test/release-changelog.test.ts runs the real script in a throwaway directory across 18 cases, including every refusal asserting nothing is modified.

… and spaced rules

Second Codex QA round on the membrane companion (antra-tess/membrane#49,
head f72e00a):

- Refusals used console.error + process.exit(1). Where stderr is a pipe
  (asynchronous on macOS), the exit can pre-empt the write, so callers —
  including the new tests — saw exit 1 with empty stderr. Refusals now
  throw a ReleaseError caught at the entry point, which prints and sets
  process.exitCode = 1 so the process drains stdio on its own.
- The block-construct check missed thematic breaks with interior
  whitespace ('- - -') and headings used as item content ('- ## x').
  Both are now refused; content after the bullet marker is inspected.
  Six regression cases added, plus one guarding against over-refusal of
  bullets that merely resemble rules or headings.
- CI runs the suite on both OSes and on Node 20 and 24 (was: ubuntu only,
  Node 20), so platform-specific stdio behaviour is actually exercised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Anarchid

Copy link
Copy Markdown
Collaborator Author

Round 2 of the Codex QA pass on membrane (antra-tess/membrane#49) — fixed here in c2fa58a: refusals reported via console.error + process.exit(1) lose their stderr when stderr is a pipe on macOS (async pipes; Linux pipes are sync), so refusals now throw and the entry point sets process.exitCode = 1 and returns normally; and the grammar missed spaced thematic breaks (- - -) and headings as bullet content (- ## x), both now refused (24 regression cases). CI now also runs the suite on both OSes × Node 20/24 (it was ubuntu-only on Node 20, which is why this never surfaced).

Widening CI to macOS ran these for the first time there: three
root-liveness re-attach cases fail on macOS with Node 24 (and pass with
Node 20, and on Linux). They were written around Linux inode-reuse
semantics; the file already keeps a sibling case Linux-only for a macOS
watcher reason. Skipped on non-Linux with the same pattern; the
underlying behaviour difference is tracked in #130.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Anarchid

Copy link
Copy Markdown
Collaborator Author

The macOS × Node 24 leg failed on the first full-suite run on macOS — not the changelog code: 624/628 passed, and the 3 failures are MountWatcher lifecycle re-attach cases in workspace-watcher.test.ts (pass on macOS × Node 20 and on Linux). Filed as #130 with the diagnostics; they look like a genuine Node 24/macOS watcher behaviour difference rather than timing flake (one assertion is that a read-write root gets recreated, and it doesn't). c754ec0 keeps those three Linux-only using the skip pattern the file already uses for a sibling case, so the widened matrix stays meaningful. membrane #49 and context-manager #72 are green on all four legs, which is the macOS verification for the stderr-flush fix.

@Anarchid
Anarchid merged commit ff0b4f1 into main Aug 24, 2026
5 checks passed
ajaniramon pushed a commit to ajaniramon/context-manager that referenced this pull request Aug 24, 2026
Concurrent PRs editing the shared '## Unreleased' section of CHANGELOG.md
conflict whenever one PR outlives another merge (e.g. anima-research#54, which edits
CHANGELOG.md directly and is exposed to every entry that lands while it
is in review). Entries now land as uniquely-named fragment files
(changelog.d/<slug>.<category>.md), which git merges without conflict;
the npm-version hook folds them into the release section and deletes
them. Direct '## Unreleased' edits remain supported and are merged at
the same point, so in-flight PRs need no rework. Tag-time publish guard
and github-release job are unchanged.

Part of the ecosystem-wide rollout of this scheme; reference
implementation and full rationale in anima-research/agent-framework#128.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ian-de-marcellus pushed a commit to ian-de-marcellus/connectome-host that referenced this pull request Aug 28, 2026
Concurrent PRs editing the shared '## Unreleased' section of CHANGELOG.md
conflict whenever one PR outlives another merge — the same structural
conflict generator the whole ecosystem's changelog policy carries, being
fixed ecosystem-wide (see anima-research/agent-framework#128). Entries now
land as uniquely-named fragment files (changelog.d/<slug>.<category>.md),
which git merges without conflict; the npm-version hook folds them into the
release section and deletes them. Direct '## Unreleased' edits remain
supported and are merged at the same point, so in-flight PRs need no
rework. Tag-time publish guard and github-release job are unchanged.

The fragment-assembly logic is ported by hand from agent-framework's
release-changelog.mjs into this repo's TypeScript hook script; the port
also picks up two hardening pieces conhost's older script predated —
refusing multiple '## Unreleased' headings, and splicing the version
heading by match index rather than first-substring replace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant