Skip to content

doc-gate: accept changelog.d fragments so concurrent PRs stop conflicting - #2290

Merged
jaylfc merged 1 commit into
devfrom
feat/changelog-fragments
Aug 4, 2026
Merged

doc-gate: accept changelog.d fragments so concurrent PRs stop conflicting#2290
jaylfc merged 1 commit into
devfrom
feat/changelog-fragments

Conversation

@jaylfc

@jaylfc jaylfc commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Closes the changelog rebase treadmill. Implements tsk-ziomck.

THE PROBLEM, measured rather than assumed: every PR that edits CHANGELOG.md writes at the same [Unreleased] anchor, so any two concurrent PRs conflict there by construction and each merge re-conflicts the next. With N PRs open that is O(N^2) rebases which touch no real code. On 2026-08-04 I rebased #2275, #2281, #2282 and #2283 for changelog-only conflicts - seven rebases in one session, #2283 three times.

THE CHANGE:

  • docs/doc-gate.toml: the user-visible-changelog rule now accepts EITHER a CHANGELOG.md edit OR a new changelog.d/*.md fragment. The gate is already data-driven, so this is a config line, not script surgery.
  • scripts/collate_changelog.py: folds every fragment into a new '## [version] - date' section beneath [Unreleased], groups by section in Keep-a-Changelog order, deletes the fragments. --dry-run prints without touching anything. Run it from the release bump.
  • docs/changelog-fragments.md: the convention.
  • Editing CHANGELOG.md directly STILL WORKS. This is additive, not a migration.

PROVEN BOTH DIRECTIONS AGAINST THE REAL GATE, not just unit fixtures:

  • A route change carrying a fragment -> doc-gate clean (exit 0).
  • The SAME route change with no fragment, no CHANGELOG edit and no trailer -> DOC-GATE FAIL, exit 1. The rule is not inert.

A MISTAKE WORTH RECORDING, because it is exactly the failure this repo keeps hitting: my first version put the convention doc at changelog.d/README.md. That file MATCHES changelog.d/*.md, so the readme satisfied the changelog rule - the probe above went GREEN on a change with no changelog at all. The gate would have shipped looking correct while being permanently satisfiable by touching a readme. The doc now lives at docs/changelog-fragments.md and changelog.d/ holds nothing but fragments and a .gitkeep. Both the config comment and the test name record why.

TESTS: 33 passing across tests/test_doc_gate.py (5 new, including the not-inert case and two glob-precision cases) and tests/test_collate_changelog.py (7, covering fold+delete, section grouping and ordering, wrapped-bullet continuation, dry-run, the no-fragments no-op, and a missing [Unreleased] anchor failing loudly WITHOUT eating the fragments).

Dogfooded: this PR's own changelog entry is a fragment.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@jaylfc, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 35 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b7c792f1-e6c3-46fc-878c-b62d1ef9a138

📥 Commits

Reviewing files that changed from the base of the PR and between 56b86e3 and 44c5f7a.

📒 Files selected for processing (7)
  • changelog.d/.gitkeep
  • changelog.d/2290-changelog-fragments.md
  • docs/changelog-fragments.md
  • docs/doc-gate.toml
  • scripts/collate_changelog.py
  • tests/test_collate_changelog.py
  • tests/test_doc_gate.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jaylfc
jaylfc enabled auto-merge (squash) August 4, 2026 11:23
@gitar-bot

gitar-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

Important

You are using the Gitar free plan. Upgrade to unlock code review, CI analysis, auto-apply, custom automations, and more.

Gitar

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Doc-gate: allow changelog.d fragments + collator to reduce changelog conflicts

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Allow doc-gate changelog compliance via CHANGELOG.md edits or changelog.d fragments.
• Add a collator script to fold fragments into a release section and delete them.
• Document the fragment convention and add regression tests to keep the gate non-inert.
Diagram

graph TD
  A["PR author"] --> B["changelog.d/*.md"] --> C["collate_changelog.py"] --> D["CHANGELOG.md"]
  E["docs/doc-gate.toml"] --> F["doc-gate evaluator"] --> G{"Requires\nchangelog proof"}
  G --> D
  G --> B
  subgraph Legend
    direction LR
    _doc["Doc/config"] ~~~ _dir["Directory"] ~~~ _proc["Script"]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Adopt Towncrier (or similar) for changelog fragments
  • ➕ Battle-tested fragment parsing, rendering, and validation
  • ➕ Supports richer templating and fragment types out of the box
  • ➕ Less custom maintenance over time
  • ➖ Adds a dependency and workflow/tooling surface area
  • ➖ May require aligning existing changelog format with the tool’s expectations
  • ➖ Heavier change management for contributors
2. Generate release notes from merged PR labels (GitHub Release Drafter)
  • ➕ Avoids changelog merge conflicts entirely
  • ➕ Uses existing metadata (labels) for categorization
  • ➖ Release notes become less intentional unless label hygiene is strong
  • ➖ Harder to capture nuanced user-facing notes in PR text alone
  • ➖ Doesn’t keep CHANGELOG.md as the single canonical artifact
3. Keep single CHANGELOG.md but automate conflict resolution at merge time
  • ➕ No new contributor convention
  • ➕ No extra files to manage/clean up
  • ➖ Still churns on the [Unreleased] anchor and creates noise commits
  • ➖ Automation around semantic conflict resolution is fragile
  • ➖ Does not eliminate O(N^2) rebase pressure in practice

Recommendation: Proceed with the current lightweight fragment approach: it is additive (CHANGELOG.md edits still work), reduces conflict pressure structurally (unique filenames), and includes a deterministic collator plus tests for ordering, deletion, and failure safety. Consider a dedicated tool like Towncrier only if fragment formatting/validation needs outgrow this simple, repo-native script.

Files changed (7) +374 / -2 · 1 not counted

Enhancement (1) +122 / -0
collate_changelog.pyAdd release-time collator to fold fragments into CHANGELOG.md +122/-0

Add release-time collator to fold fragments into CHANGELOG.md

• Implements a script to parse changelog.d/*.md fragments, group entries by section (default Added), and insert a new version/date section directly under the [Unreleased] anchor. Supports deterministic ordering, --dry-run output, deletes consumed fragments, and fails loudly if [Unreleased] is missing.

scripts/collate_changelog.py

Tests (2) +182 / -0
test_collate_changelog.pyAdd test suite for changelog fragment collator +116/-0

Add test suite for changelog fragment collator

• Adds coverage for folding + deletion, Keep-a-Changelog section grouping/order, wrapped bullet continuation lines, dry-run behavior, no-fragment no-op, and safe failure when CHANGELOG.md lacks the [Unreleased] anchor.

tests/test_collate_changelog.py

test_doc_gate.pyTest doc-gate acceptance of changelog.d fragments without weakening the gate +66/-0

Test doc-gate acceptance of changelog.d fragments without weakening the gate

• Adds a focused rule config and test cases proving that fragments satisfy the changelog rule, direct CHANGELOG.md edits still satisfy it, and the rule still fails when neither fragment/changelog nor trailer exists. Includes glob-precision tests to ensure subdirectory markdown and non-.md files do not count.

tests/test_doc_gate.py

Documentation (2) +54 / -0
2290-changelog-fragments.mdAdd changelog fragment for this PR +6/-0

Add changelog fragment for this PR

• Introduces a fragment under the new convention describing the switch to changelog.d entries and the release-time collator. Serves as dogfooding for the workflow.

changelog.d/2290-changelog-fragments.md

changelog-fragments.mdDocument the changelog fragment convention +48/-0

Document the changelog fragment convention

• Adds contributor-facing guidance for naming and writing fragments, including section headings and Keep-a-Changelog grouping behavior. Explicitly documents why the convention doc must live outside changelog.d/ to avoid satisfying the doc gate via a README.

docs/changelog-fragments.md

Other (2) +16 / -2
.gitkeepKeep changelog.d/ directory present in repo not counted

Keep changelog.d/ directory present in repo

• Adds a .gitkeep so changelog.d/ exists even when empty. This supports the fragment workflow without requiring an initial fragment to create the directory.

changelog.d/.gitkeep

doc-gate.tomlAllow changelog fragments to satisfy user-visible changelog rule +16/-2

Allow changelog fragments to satisfy user-visible changelog rule

• Updates the user-visible-changelog rule to accept either a CHANGELOG.md edit or a new changelog.d/*.md fragment. Adds explanatory comments capturing the conflict motivation and the glob-safety rationale (no README.md inside changelog.d/).

docs/doc-gate.toml

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 35 rules

Grey Divider


Action required

1. Fragments deleted without insertion 🐞 Bug ≡ Correctness
Description
scripts/collate_changelog.py only verifies that "## [Unreleased]" appears somewhere, but inserts by
replacing the exact substring "## [Unreleased]\n"; if the Unreleased header line lacks a trailing
newline, the replace is a no-op and the script still deletes all fragment files, losing release
notes.
Code

scripts/collate_changelog.py[R111-113]

+    anchor = UNRELEASED + "\n"
+    text = text.replace(anchor, anchor + "\n" + section, 1)
+    CHANGELOG.write_text(text, encoding="utf-8")
Relevance

●●● Strong

They routinely accept changes preventing silent no-ops/data loss in scripts; should confirm
insertion before unlinking fragments.

PR-#275
PR-#398

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code constructs an insertion anchor that requires a trailing newline, performs a single replace,
then unconditionally deletes all consumed fragment files; if the anchor-with-newline isn’t present,
insertion won’t happen but deletion still will.

scripts/collate_changelog.py[105-116]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The collator checks `UNRELEASED in text` but performs insertion via `text.replace(UNRELEASED + "\n", ...)`. If the header is present without a trailing newline, insertion does not occur but fragments are still deleted.

### Issue Context
This is release-time, destructive behavior: a no-op insertion followed by fragment deletion silently drops changelog entries.

### Fix Focus Areas
- scripts/collate_changelog.py[105-116]

### What to change
- Perform line-aware insertion (e.g., find the `## [Unreleased]` line boundary and insert immediately after it), rather than relying on an exact `"...\n"` substring.
- After attempting insertion, assert it succeeded (e.g., compare `text_before`/`text_after` or check a sentinel) and if not, print an error to stderr and `return 1` **before** unlinking any fragments.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Malformed fragments crash collator 🐞 Bug ☼ Reliability
Description
parse_fragment() raises ValueError for an invalid/empty fragment, but main() does not catch it, so
the script exits via an uncaught exception/traceback instead of returning the documented exit code 1
for malformed fragments.
Code

scripts/collate_changelog.py[R93-96]

+    merged, consumed = collect(FRAGMENT_DIR)
+    if not consumed:
+        print("collate-changelog: no fragments, nothing to do")
+        return 0
Relevance

●●● Strong

Repo precedent favors explicit error handling over uncaught failures/hidden errors; returning exit
code 1 is consistent.

PR-#1551
PR-#316

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The parser explicitly raises on empty content, and main calls into collect/parse_fragment without a
try/except, so the exception will propagate out of main().

scripts/collate_changelog.py[41-57]
scripts/collate_changelog.py[83-99]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`parse_fragment()` can raise `ValueError`, but `main()` doesn’t handle it, so a malformed fragment produces a traceback rather than a clean error + exit code 1.

### Issue Context
This is a release bump tool; malformed contributor input should fail loudly but cleanly, with a stable exit code and no partial side effects.

### Fix Focus Areas
- scripts/collate_changelog.py[55-57]
- scripts/collate_changelog.py[90-99]

### What to change
- Wrap `collect(FRAGMENT_DIR)` (or the body of `collect`) in `try/except ValueError`.
- On error: print a concise message to stderr (include fragment filename), return 1, and ensure fragments are not deleted.
- Optionally add a test asserting malformed fragments return 1 without a traceback.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Deletes unexpected markdown files 🐞 Bug ☼ Reliability
Description
collect() consumes every top-level changelog.d/*.md file and main() deletes all consumed paths, so
any accidental non-fragment markdown file (e.g., a future template/README) would be folded into
CHANGELOG.md and then removed.
Code

scripts/collate_changelog.py[R64-67]

+    for path in sorted(fragment_dir.glob("*.md")):
+        for section, lines in parse_fragment(path).items():
+            merged.setdefault(section, []).extend(lines)
+        consumed.append(path)
Relevance

●●● Strong

Team often accepts defensive hardening in scripts to avoid unintended side effects; validating
fragment filenames prevents accidental deletion.

PR-#1551
PR-#1542

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The script’s globbing and deletion are unconditional for all *.md files, while the documented
convention expects a specific PR-number-based filename format; without validation, unrelated
markdown is at risk of being consumed and deleted.

scripts/collate_changelog.py[60-68]
scripts/collate_changelog.py[115-116]
docs/changelog-fragments.md[6-15]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The collator globs `changelog.d/*.md` and deletes everything it consumes. If a non-fragment `.md` ends up in that directory, it will be incorporated into the changelog and then deleted.

### Issue Context
The convention doc specifies a `<pr-number>-<short-slug>.md` naming scheme, but the script currently doesn’t enforce it.

### Fix Focus Areas
- scripts/collate_changelog.py[60-68]
- scripts/collate_changelog.py[115-116]
- docs/changelog-fragments.md[6-15]

### What to change
- Filter/validate filenames in `collect()` (e.g., only `^\d+-.*\.md$`).
- If an unexpected `.md` is present: either skip it with a warning, or fail with exit code 1 without deleting anything.
- Consider adding a test covering the presence of an unexpected `.md` in `changelog.d/`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment on lines +111 to +113
anchor = UNRELEASED + "\n"
text = text.replace(anchor, anchor + "\n" + section, 1)
CHANGELOG.write_text(text, encoding="utf-8")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Fragments deleted without insertion 🐞 Bug ≡ Correctness

scripts/collate_changelog.py only verifies that "## [Unreleased]" appears somewhere, but inserts by
replacing the exact substring "## [Unreleased]\n"; if the Unreleased header line lacks a trailing
newline, the replace is a no-op and the script still deletes all fragment files, losing release
notes.
Agent Prompt
### Issue description
The collator checks `UNRELEASED in text` but performs insertion via `text.replace(UNRELEASED + "\n", ...)`. If the header is present without a trailing newline, insertion does not occur but fragments are still deleted.

### Issue Context
This is release-time, destructive behavior: a no-op insertion followed by fragment deletion silently drops changelog entries.

### Fix Focus Areas
- scripts/collate_changelog.py[105-116]

### What to change
- Perform line-aware insertion (e.g., find the `## [Unreleased]` line boundary and insert immediately after it), rather than relying on an exact `"...\n"` substring.
- After attempting insertion, assert it succeeded (e.g., compare `text_before`/`text_after` or check a sentinel) and if not, print an error to stderr and `return 1` **before** unlinking any fragments.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +93 to +96
merged, consumed = collect(FRAGMENT_DIR)
if not consumed:
print("collate-changelog: no fragments, nothing to do")
return 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. Malformed fragments crash collator 🐞 Bug ☼ Reliability

parse_fragment() raises ValueError for an invalid/empty fragment, but main() does not catch it, so
the script exits via an uncaught exception/traceback instead of returning the documented exit code 1
for malformed fragments.
Agent Prompt
### Issue description
`parse_fragment()` can raise `ValueError`, but `main()` doesn’t handle it, so a malformed fragment produces a traceback rather than a clean error + exit code 1.

### Issue Context
This is a release bump tool; malformed contributor input should fail loudly but cleanly, with a stable exit code and no partial side effects.

### Fix Focus Areas
- scripts/collate_changelog.py[55-57]
- scripts/collate_changelog.py[90-99]

### What to change
- Wrap `collect(FRAGMENT_DIR)` (or the body of `collect`) in `try/except ValueError`.
- On error: print a concise message to stderr (include fragment filename), return 1, and ensure fragments are not deleted.
- Optionally add a test asserting malformed fragments return 1 without a traceback.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +64 to +67
for path in sorted(fragment_dir.glob("*.md")):
for section, lines in parse_fragment(path).items():
merged.setdefault(section, []).extend(lines)
consumed.append(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

3. Deletes unexpected markdown files 🐞 Bug ☼ Reliability

collect() consumes every top-level changelog.d/*.md file and main() deletes all consumed paths, so
any accidental non-fragment markdown file (e.g., a future template/README) would be folded into
CHANGELOG.md and then removed.
Agent Prompt
### Issue description
The collator globs `changelog.d/*.md` and deletes everything it consumes. If a non-fragment `.md` ends up in that directory, it will be incorporated into the changelog and then deleted.

### Issue Context
The convention doc specifies a `<pr-number>-<short-slug>.md` naming scheme, but the script currently doesn’t enforce it.

### Fix Focus Areas
- scripts/collate_changelog.py[60-68]
- scripts/collate_changelog.py[115-116]
- docs/changelog-fragments.md[6-15]

### What to change
- Filter/validate filenames in `collect()` (e.g., only `^\d+-.*\.md$`).
- If an unexpected `.md` is present: either skip it with a warning, or fail with exit code 1 without deleting anything.
- Consider adding a test covering the presence of an unexpected `.md` in `changelog.d/`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

# Insert directly BELOW [Unreleased] so Unreleased stays empty and on top,
# which is what the release train expects on the next cycle.
anchor = UNRELEASED + "\n"
text = text.replace(anchor, anchor + "\n" + section, 1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: If [Unreleased] contains bullets, they end up under the new release section

text.replace(anchor, ...) inserts the new release section immediately after the ## [Unreleased] header. Any bullets already under [Unreleased] will then fall under the new ## [version] header in markdown, because it becomes the nearest preceding heading. The script assumes [Unreleased] is empty at release time but does not document or enforce this.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if not FRAGMENT_DIR.is_dir():
print("collate-changelog: no changelog.d/ directory, nothing to do")
return 0
merged, consumed = collect(FRAGMENT_DIR)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: collect() can raise ValueError for a malformed fragment, but main() does not catch it

If a fragment has no bullets, parse_fragment() raises ValueError. Since main() calls collect() without a try/except, the script crashes with an unhandled exception and traceback instead of exiting cleanly with code 1 as documented.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
scripts/collate_changelog.py 112 If [Unreleased] contains bullets, they end up under the new release section

SUGGESTION

File Line Issue
scripts/collate_changelog.py 93 collect() can raise ValueError for a malformed fragment, but main() does not catch it
Files Reviewed (7 files)
  • changelog.d/.gitkeep
  • changelog.d/2290-changelog-fragments.md
  • docs/changelog-fragments.md
  • docs/doc-gate.toml
  • scripts/collate_changelog.py - 2 issues
  • tests/test_collate_changelog.py
  • tests/test_doc_gate.py

Fix these issues in Kilo Cloud


Reviewed by step-3.7-flash · Input: 113.4K · Output: 25.5K · Cached: 703.6K

@jaylfc
jaylfc merged commit aed3c95 into dev Aug 4, 2026
20 of 21 checks passed
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