Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .claude/commands/address-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Read `.claude/commands/_shared-rails.md` first. Highlights:
- Run tests via `bash tools/test-local.sh` for `fix/bot-*` PRs after
changes.
- For `docs/bot-*` PRs, restrict edits to doc paths only —
`web/sites/guides/`, `.ai/wheels/`, `CLAUDE.md`, `CHANGELOG.md`.
`web/sites/guides/`, `.ai/wheels/`, `CLAUDE.md`, `changelog.d/`.
- Output is **commits to the PR branch** plus **one comment on the
PR** summarizing what you addressed.

Expand Down Expand Up @@ -105,7 +105,8 @@ Read `.claude/commands/_shared-rails.md` first. Highlights:

4. **Branch-aware scope check.** Read the PR's head ref via
`gh pr view <pr-number> --json headRefName -q '.headRefName'`:
- `fix/bot-*` → may modify code, tests, CHANGELOG
- `fix/bot-*` → may modify code, tests, changelog fragments
(`changelog.d/<slug>.<type>.md` — never `CHANGELOG.md` directly)
- `docs/bot-*` → doc paths only. If a consensus finding requires
touching code, post `address-held` and exit (the PR's scope is
wrong for that finding).
Expand Down
18 changes: 12 additions & 6 deletions .claude/commands/propose-fix.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,18 @@ is enforced by code, so don't skip steps.
Confirm the spec now passes. Also confirm no other tests in the layer
regressed. If something else broke, fix it before proceeding.

9. **Add a CHANGELOG entry.** Append one line to `CHANGELOG.md` under
`[Unreleased]` (present tense, no PR number — humans add the link on
merge). **Do not touch MDX guides, `.ai/wheels/`, or `CLAUDE.md`** —
those are handled separately by the `bot-update-docs.yml` workflow,
which runs after this PR opens. The bot's scope here is failing-spec →
implementation → passing-spec → CHANGELOG → PR.
9. **Add a changelog fragment.** Write a new file
`changelog.d/<slug>.<type>.md` (type: `added`, `changed`, `fixed`,
`security`, `performance`, `deprecated`, or `removed`) containing one
markdown bullet line describing the change (present tense, reference
the issue number in parentheses). Do NOT edit `CHANGELOG.md` directly —
fragments are assembled into it at release promotion, and direct edits
recreate the `[Unreleased]`-anchor merge conflicts the fragment system
exists to remove (see `changelog.d/README.md`). **Do not touch MDX
guides, `.ai/wheels/`, or `CLAUDE.md`** — those are handled separately
by the `bot-update-docs.yml` workflow, which runs after this PR opens.
The bot's scope here is failing-spec → implementation → passing-spec →
changelog fragment → PR.

10. **Stage, commit, and prepare the PR.**

Expand Down
4 changes: 3 additions & 1 deletion .claude/commands/review-pr.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ below. Highlights for this command:
- `.ai/wheels/<layer>/` updated if behavior changed
- `web/sites/guides/src/content/docs/v4-0-0-snapshot/` page updated for
user-facing features
- `CHANGELOG.md` `[Unreleased]` entry
- Changelog fragment under `changelog.d/` (`<slug>.<type>.md`; a direct
`CHANGELOG.md` `[Unreleased]` edit is a finding — it recreates the
merge-conflict anchor the fragment system removes)
- `CLAUDE.md` updated if model/controller/view conventions changed

**Commits**
Expand Down
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Closes #
- [ ] **Framework Docs** -- New or updated MDX page under `web/sites/guides/src/content/docs/v4-0-0-snapshot/` with matching sidebar entry
- [ ] **AI Reference Docs** -- New or updated file in `.ai/wheels/` directory
- [ ] **CLAUDE.md** -- Updated if the feature changes model/controller/view conventions
- [ ] **CHANGELOG.md** -- Entry under `[Unreleased]` section
- [ ] **Changelog fragment** -- New `changelog.d/<slug>.<type>.md` file (do NOT edit `CHANGELOG.md` directly; see `changelog.d/README.md`)
- [ ] **Test runner passes** -- All existing tests still pass (`/wheels/tests/core?format=json`)

## Test Plan
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/bot-tdd-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ jobs:
# Narrow the test-path exclusion to `specs/` so non-spec files under tests/ (e.g.
# vendor/wheels/tests/html.cfm, the test-runner result-page UI rendered to users'
# browsers) correctly count as implementation when a bot fix touches them.
impl_changes=$(echo "$changed" | grep -vE '^(tests/specs/|vendor/wheels/tests/specs/|cli/lucli/tests/specs/|\.ai/|CHANGELOG\.md|docs/|web/|\.github/)' || true)
impl_changes=$(echo "$changed" | grep -vE '^(tests/specs/|vendor/wheels/tests/specs/|cli/lucli/tests/specs/|\.ai/|CHANGELOG\.md|changelog\.d/|docs/|web/|\.github/)' || true)
if [[ -z "$impl_changes" ]]; then
echo "::error::Bot PR has tests but no implementation"
echo ""
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ All historical references to "CFWheels" in this changelog have been preserved fo

## [Unreleased]

<!-- Do not add entries here. Write a fragment file under changelog.d/
(see changelog.d/README.md); fragments are assembled into this file
at release promotion via tools/changelog-promote.sh. -->

### Added

- `wheels upgrade check --strict` escalates advisory findings (the "Recommended Improvements" section) to the same hard-fail path as breaking findings, throwing `Wheels.UpgradeCheckFailed` and exiting non-zero so CI pipelines can gate on opt-in convention changes. Without `--strict`, advisories continue to report-and-pass. Under `--format=json` the emitted document's `success` field is gated on both breaking findings and the strict-advisory case, and the `strict` flag is echoed back so `jq .success` and `$?` always agree. The flag is documented in `wheels upgrade` help output (#2963).
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,10 @@ Notes:
- `ci` is a TYPE, not a scope — never write `refactor(ci):`.
- DCO sign-off email must match `git config user.email` — prefer `git commit -s` over manual trailer.

### Changelog entries

User-facing `fix`/`feat` PRs add a **fragment file**, never a direct `CHANGELOG.md` edit: write `changelog.d/<slug>.<type>.md` (type ∈ `added,changed,deprecated,removed,fixed,security,performance`) containing the complete markdown bullet line(s). Direct `[Unreleased]` edits recreate the same-anchor merge conflicts the fragment system removes. At release cut, `tools/changelog-promote.sh <version>` assembles fragments (plus any legacy `[Unreleased]` content) into the new version section and clears the folder. See `changelog.d/README.md`.

## CLI / MCP

**Canonical surface (Wheels 4.0+):** the Wheels CLI's stdio MCP server at `wheels mcp wheels`.
Expand Down
51 changes: 51 additions & 0 deletions changelog.d/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# changelog.d — unreleased changelog fragments

One file per PR (or per logical change) instead of editing `CHANGELOG.md`
directly. Every campaign PR used to append its entry at the same
`[Unreleased]` anchor, so each merge conflicted with every other open PR.
Fragments live in separate files, so they never conflict; they are assembled
into `CHANGELOG.md` at release promotion and this folder is cleared, ready
for the next cycle.

## Adding an entry

Create `changelog.d/<slug>.<type>.md`:

- **`<slug>`** — short kebab-case description, unique enough to avoid
collisions (the issue/PR number works too: `2971-ratelimiter.fixed.md`).
- **`<type>`** — one of `added`, `changed`, `deprecated`, `removed`,
`performance`, `fixed`, `security` (the order they appear under in the
promoted release section). This selects the `### Type` heading the entry
lands under.

File content is one or more **complete markdown bullet lines**, exactly as
they should appear in `CHANGELOG.md`:

```markdown
- `Seeder.runSeeds()` no longer reports success when individual `seedOnce()` entries failed validation (#2973)
```

Rules:

- Start each entry with `- `. No headings, no frontmatter.
- Reference the issue or PR number in parentheses at the end, matching the
existing CHANGELOG style.
- A PR with entries in multiple sections writes multiple fragment files
(e.g. `my-feature.added.md` + `my-feature.fixed.md`).
- Do NOT edit `CHANGELOG.md`'s `[Unreleased]` section directly — that
recreates the merge-conflict anchor this folder exists to remove.

## Releasing

At the release cut:

```bash
tools/changelog-promote.sh --preview # see what would be assembled
tools/changelog-promote.sh 4.1.0 # promote (date defaults to today)
tools/changelog-promote.sh 4.1.0 2026-07-01 # explicit date
```

Promotion merges all fragments (plus anything already sitting under
`[Unreleased]`, e.g. entries that predate this system) into a new
`## [<version>] - <date>` section, resets `[Unreleased]` to empty, and
deletes the fragment files. Review the diff, then commit.
1 change: 1 addition & 0 deletions changelog.d/changelog-fragments.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Changelog entries are now authored as per-PR fragment files under `changelog.d/` (`<slug>.<type>.md`) instead of direct `CHANGELOG.md` edits, eliminating the `[Unreleased]`-anchor merge conflict every concurrently-open PR used to have with every other. `tools/changelog-promote.sh <version>` assembles fragments (plus any legacy `[Unreleased]` content) into the new version section at release cut and clears the folder. PR template, bot prompts (propose-fix, review-pr, address-review), and the TDD gate updated to the fragment convention (#2958)
185 changes: 185 additions & 0 deletions tools/changelog-promote.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# Assemble changelog.d/ fragments into CHANGELOG.md (see changelog.d/README.md).
#
# Usage:
# tools/changelog-promote.sh --preview # print assembled sections, change nothing
# tools/changelog-promote.sh <version> [date] # promote: merge fragments + current
# # [Unreleased] body into a new
# # "## [<version>] - <date>" section,
# # reset [Unreleased], delete fragments
#
# Promotion only edits files — review the diff and commit yourself. The
# script refuses to promote when there is nothing to promote, and fails
# loudly on malformed fragment names/content so a bad file can't silently
# vanish from the release notes.
set -euo pipefail

PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_ROOT"

python3 - "$@" <<'PYEOF'
import datetime
import pathlib
import re
import sys

FRAG_DIR = pathlib.Path("changelog.d")
CHANGELOG = pathlib.Path("CHANGELOG.md")
# Section order for newly-created headings. Existing headings under
# [Unreleased] keep their file order; missing ones are appended in this order.
CANONICAL = ["Added", "Changed", "Deprecated", "Removed", "Performance", "Fixed", "Security"]
TYPES = {t.lower(): t for t in CANONICAL}
# Marker comment kept at the top of the empty [Unreleased] section.
MARKER = (
"<!-- Do not add entries here. Write a fragment file under changelog.d/\n"
" (see changelog.d/README.md); fragments are assembled into this file\n"
" at release promotion via tools/changelog-promote.sh. -->"
)


def fail(msg):
print(f"changelog-promote: {msg}", file=sys.stderr)
sys.exit(1)


def read_fragments():
"""Returns {SectionName: [bullet lines]} preserving per-section filename order."""
sections = {}
if not FRAG_DIR.is_dir():
return sections
for path in sorted(FRAG_DIR.glob("*.md")):
if path.name == "README.md":
continue
parts = path.name.rsplit(".", 2)
if len(parts) != 3 or parts[1].lower() not in TYPES:
fail(
f"{path}: fragment files must be named <slug>.<type>.md with type one of "
f"{', '.join(sorted(TYPES))}"
)
section = TYPES[parts[1].lower()]
body = path.read_text(encoding="utf-8").strip()
if not body:
fail(f"{path}: fragment is empty")
lines = body.splitlines()
if not lines[0].startswith("- "):
fail(f"{path}: fragment content must start with a markdown bullet ('- ')")
for line in lines:
if line.startswith("#"):
fail(f"{path}: fragments may not contain headings")
sections.setdefault(section, []).extend(lines)
return sections


def parse_unreleased(text):
"""Returns (head, body_sections, tail, had_unreleased).

head = everything through the '## [Unreleased]' line
body_sections = ordered list of (heading-or-None, [lines]) inside Unreleased
tail = everything from the next '## ' heading onward
"""
match = re.search(r"(?m)^## \[Unreleased\][^\n]*\n", text)
if not match:
fail("CHANGELOG.md has no '## [Unreleased]' section")
head = text[: match.end()]
rest = text[match.end():]
next_heading = re.search(r"(?m)^## ", rest)
body = rest[: next_heading.start()] if next_heading else rest
tail = rest[next_heading.start():] if next_heading else ""

# Strip the do-not-edit marker from the body so it never gets promoted.
body = body.replace(MARKER, "")

sections = []
current = (None, [])
for line in body.splitlines():
if line.startswith("### "):
if current[0] is not None or any(l.strip() for l in current[1]):
sections.append(current)
current = (line[4:].strip(), [])
else:
current[1].append(line)
if current[0] is not None or any(l.strip() for l in current[1]):
sections.append(current)
return head, sections, tail


def merged_sections(existing, fragments):
"""Merge fragment bullets into the existing Unreleased sections."""
out = []
seen = set()
for heading, lines in existing:
if heading is None:
# Prose before the first ### heading (rare) — keep as-is.
out.append((heading, [l for l in lines if l.strip()]))
continue
bullets = [l for l in lines if l.strip()]
if heading in fragments:
bullets.extend(fragments[heading])
out.append((heading, bullets))
seen.add(heading)
for heading in CANONICAL:
if heading in fragments and heading not in seen:
out.append((heading, list(fragments[heading])))
# Drop empty sections.
return [(h, b) for h, b in out if b]


def render(sections):
chunks = []
for heading, bullets in sections:
if heading is not None:
chunks.append(f"### {heading}\n")
chunks.append("\n".join(bullets) + "\n")
return "\n".join(chunks)


args = sys.argv[1:]
if not args:
fail("usage: tools/changelog-promote.sh --preview | <version> [date]")

fragments = read_fragments()

if args[0] == "--preview":
if CHANGELOG.exists():
text = CHANGELOG.read_text(encoding="utf-8")
_, existing, _ = parse_unreleased(text)
else:
existing = []
merged = merged_sections(existing, fragments)
if not merged:
print("Nothing to promote: no fragments and [Unreleased] is empty.")
sys.exit(0)
for heading, bullets in merged:
if heading is not None:
print(f"### {heading}\n")
print("\n".join(bullets))
print()
sys.exit(0)

version = args[0]
if not re.fullmatch(r"\d+\.\d+\.\d+(-[A-Za-z0-9.]+)?", version):
fail(f"'{version}' does not look like a semver version")
date = args[1] if len(args) > 1 else datetime.date.today().isoformat()
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
fail(f"'{date}' is not a YYYY-MM-DD date")

text = CHANGELOG.read_text(encoding="utf-8")
head, existing, tail = parse_unreleased(text)
merged = merged_sections(existing, fragments)
if not merged:
fail("nothing to promote: no fragments and [Unreleased] is empty")

new_unreleased = f"\n{MARKER}\n\n"
version_section = f"## [{version}] - {date}\n\n{render(merged)}\n"
CHANGELOG.write_text(head + new_unreleased + version_section + tail, encoding="utf-8")

removed = []
for path in sorted(FRAG_DIR.glob("*.md")):
if path.name != "README.md":
path.unlink()
removed.append(path.name)

print(f"[{version}] - {date} now carries {sum(len(b) for _, b in merged)} entries (fragments + prior [Unreleased] content).")
print(f"Removed {len(removed)} fragment(s): {', '.join(removed) if removed else '(none)'}")
print("Review the CHANGELOG.md diff, then commit.")
PYEOF
Loading