From 747fc4621067ebff07f317eff6c8f9f73e470360 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 12 Jun 2026 01:42:53 +0200 Subject: [PATCH 1/3] Add .nextchanges fragment directory and collator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contributors keep adding entries to the single, append-only NEXT_CHANGELOG.md, so concurrent PRs constantly conflict and force a no-op rebase plus full CI rerun. Introduce .nextchanges/
/.md fragments instead: each PR adds its own file, so two PRs never touch the same path and never conflict. The filename is arbitrary (a feature name or PR number) and an entry is just a sentence — creatable straight from the GitHub UI; the leading bullet and a (#NNNN) reference are both optional. tools/collate_changelog.py folds the fragments into the matching NEXT_CHANGELOG.md sections at release time, leaving the release tooling (internal/genkit/tagging.py) to consume NEXT_CHANGELOG.md unchanged. The directory name matches databricks-sdk-py's .nextchanges/ for cross-repo consistency. 'task changelog-check' validates fragment placement and runs as part of 'task checks'. The bare 'cli' gitignore entry is anchored to '/cli' so it no longer ignores .nextchanges/cli/. Co-authored-by: Isaac --- .gitignore | 4 +- .nextchanges/README.md | 44 +++++ .nextchanges/api-changes/.gitkeep | 0 .nextchanges/bundles/.gitkeep | 0 .nextchanges/cli/.gitkeep | 0 .nextchanges/dependency-updates/.gitkeep | 0 .nextchanges/notable-changes/.gitkeep | 0 Taskfile.yml | 14 +- tools/collate_changelog.py | 226 +++++++++++++++++++++++ 9 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 .nextchanges/README.md create mode 100644 .nextchanges/api-changes/.gitkeep create mode 100644 .nextchanges/bundles/.gitkeep create mode 100644 .nextchanges/cli/.gitkeep create mode 100644 .nextchanges/dependency-updates/.gitkeep create mode 100644 .nextchanges/notable-changes/.gitkeep create mode 100755 tools/collate_changelog.py diff --git a/.gitignore b/.gitignore index 71622cd443c..e725ce38da5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,9 @@ *.dll *.so *.dylib -cli +# Root binary from a bare `go build`; anchored so it doesn't also ignore +# nested paths like changelog.d/cli/. +/cli # Test binary, built with `go test -c` *.test diff --git a/.nextchanges/README.md b/.nextchanges/README.md new file mode 100644 index 00000000000..1b2d6296067 --- /dev/null +++ b/.nextchanges/README.md @@ -0,0 +1,44 @@ +# Changelog fragments + +Add a changelog entry by creating a **new file** in the section folder under +`.nextchanges/` that fits your change. Each PR adds its own file, so two PRs +never touch the same path — no merge conflicts, unlike everyone editing one +shared changelog file. + +## How to add an entry (takes 10 seconds) + +Create `.nextchanges/
/.md` and write what changed: + +``` +Added the `databricks quickstart` command. +``` + +You can do this straight from the GitHub UI: **Add file → Create new file**, +type the path (e.g. `.nextchanges/cli/quickstart.md`), write a sentence, commit. + +- `` is arbitrary — a feature name (`quickstart.md`) or your PR number + (`5464.md`), whatever you like, as long as it's unique. +- The leading `* ` is optional. +- A PR link is optional: write `(#5464)` anywhere in the text and it becomes a + full link automatically (see `tools/update_github_links.py`). +- One file is usually one entry; for several, put each on its own `* ` line. + +### Sections + +| Folder | Section in the released changelog | +| --- | --- | +| `.nextchanges/notable-changes/` | Notable Changes (prominent, called out at the top) | +| `.nextchanges/cli/` | CLI | +| `.nextchanges/bundles/` | Bundles | +| `.nextchanges/dependency-updates/` | Dependency updates | +| `.nextchanges/api-changes/` | API Changes | + +See [`.agent/skills/pr-checklist/SKILL.md`](../.agent/skills/pr-checklist/SKILL.md) +for when an entry is warranted. + +## How it's released + +You don't run anything. At release time, `tools/collate_changelog.py` folds +every fragment into the matching section of `NEXT_CHANGELOG.md`, deletes the +fragments, and the release tooling generates `CHANGELOG.md` as before. +`./task changelog-check` validates fragment placement on every PR. diff --git a/.nextchanges/api-changes/.gitkeep b/.nextchanges/api-changes/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.nextchanges/bundles/.gitkeep b/.nextchanges/bundles/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.nextchanges/cli/.gitkeep b/.nextchanges/cli/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.nextchanges/dependency-updates/.gitkeep b/.nextchanges/dependency-updates/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.nextchanges/notable-changes/.gitkeep b/.nextchanges/notable-changes/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Taskfile.yml b/Taskfile.yml index a0e2813afca..a7a45d38fa1 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -262,6 +262,17 @@ tasks: cmds: - "./tools/update_github_links.py" + changelog-collate: + desc: Collate .nextchanges fragments into NEXT_CHANGELOG.md (run at release time) + cmds: + - "./tools/collate_changelog.py" + - "./tools/update_github_links.py NEXT_CHANGELOG.md" + + changelog-check: + desc: Validate .nextchanges fragment placement + cmds: + - "./tools/collate_changelog.py --check" + deadcode: desc: Check for dead code sources: @@ -272,7 +283,7 @@ tasks: - ./tools/check_deadcode.py checks: - desc: Run quick checks (tidy, whitespace, links, deadcode) + desc: Run quick checks (tidy, whitespace, links, deadcode, changelog) # Sequential: `tidy` rewrites go.mod/go.sum and any future tidy work # touching more paths should not race with whitespace/link scanners. cmds: @@ -280,6 +291,7 @@ tasks: - task: ws - task: links - task: deadcode + - task: changelog-check install-pythons: desc: Install Python 3.9-3.13 via uv diff --git a/tools/collate_changelog.py b/tools/collate_changelog.py new file mode 100755 index 00000000000..fc4fac62e9b --- /dev/null +++ b/tools/collate_changelog.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.12" +# /// +"""Collate ``.nextchanges/`` fragments into ``NEXT_CHANGELOG.md``. + +Each PR adds its own file under ``.nextchanges/
/`` instead of editing +the shared ``NEXT_CHANGELOG.md``. Because two PRs never touch the same path, +they never produce a merge conflict. At release time this script folds every +fragment into the matching section of ``NEXT_CHANGELOG.md`` and deletes the +fragment files; the existing release tooling (``internal/genkit/tagging.py``) +then consumes ``NEXT_CHANGELOG.md`` unchanged. + +Usage: + collate_changelog.py # collate fragments into NEXT_CHANGELOG.md + collate_changelog.py --check # validate fragment placement only (no writes) +""" + +import argparse +import pathlib +import re +import sys + +CHANGELOG_DIR = ".nextchanges" +NEXT_CHANGELOG = "NEXT_CHANGELOG.md" + +# Section subdirectory -> ``### `` header text in NEXT_CHANGELOG.md, in the +# order sections appear in the file. The slug is the header lowercased with +# spaces replaced by hyphens; the mapping is explicit because "CLI" and +# "API Changes" don't round-trip through a simple title-case rule. +SECTIONS = ( + ("notable-changes", "Notable Changes"), + ("cli", "CLI"), + ("bundles", "Bundles"), + ("dependency-updates", "Dependency updates"), + ("api-changes", "API Changes"), +) + +SECTION_SLUGS = {slug for slug, _ in SECTIONS} + +# A level-2 or level-3 Markdown heading, i.e. a section boundary. +HEADING_RE = re.compile(r"#{2,3} ") + + +def normalize_entry(text): + """Return *text* as a Markdown bullet, adding the leading ``* `` if absent. + + The leading marker is optional in a fragment so authors can write just the + entry text. A ``-`` marker is normalized to ``*`` to match the changelog. + + >>> normalize_entry("Added a flag (#1).") + '* Added a flag (#1).' + >>> normalize_entry("* Already a bullet (#2).") + '* Already a bullet (#2).' + >>> normalize_entry("- Dash bullet (#3).") + '* Dash bullet (#3).' + + Only the first line is marked; continuation lines are left untouched so an + author can write a multi-line entry or several explicit bullets: + + >>> normalize_entry("First line.\\n continued") + '* First line.\\n continued' + """ + text = text.strip() + first, _, rest = text.partition("\n") + if first.startswith("* "): + pass + elif first.startswith("- "): + first = "* " + first[2:] + elif first in ("*", "-"): + first = "*" + else: + first = "* " + first + return first + ("\n" + rest if rest else "") + + +def insert_entries(changelog, header, entries): + r"""Insert *entries* under the ``### {header}`` section of *changelog*. + + Entries are appended after any existing content in the section, before the + blank line that precedes the next section. Existing lines are left byte for + byte intact so the diff is minimal. + + >>> cl = "## Release v1.0.0\n\n### CLI\n\n### Bundles\n" + >>> print(insert_entries(cl, "CLI", ["* Added a flag (#1)."]), end="") + ## Release v1.0.0 + + ### CLI + * Added a flag (#1). + + ### Bundles + + Appends after content already present in the section: + + >>> cl = "### CLI\n* Existing (#1).\n\n### Bundles\n" + >>> print(insert_entries(cl, "CLI", ["* New (#2)."]), end="") + ### CLI + * Existing (#1). + * New (#2). + + ### Bundles + + Works for the last section in the file: + + >>> print(insert_entries("### API Changes\n", "API Changes", ["* X (#1)."]), end="") + ### API Changes + * X (#1). + """ + lines = changelog.split("\n") + + header_line = f"### {header}" + try: + start = next(i for i, line in enumerate(lines) if line.strip() == header_line) + except StopIteration: + raise SystemExit(f"section '{header_line}' not found in {NEXT_CHANGELOG}") + + # End of the section: the next heading, or end of file. + end = len(lines) + for i in range(start + 1, len(lines)): + if HEADING_RE.match(lines[i].strip()): + end = i + break + + # Skip trailing blank lines so new entries attach directly to existing + # content (or to the header when the section is empty). + insert_at = end + while insert_at - 1 > start and lines[insert_at - 1].strip() == "": + insert_at -= 1 + + lines[insert_at:insert_at] = entries + return "\n".join(lines) + + +def iter_fragment_files(changelog_dir): + """Yield every ``*.md`` fragment under *changelog_dir*, excluding READMEs.""" + for path in sorted(changelog_dir.rglob("*.md")): + if path.name == "README.md": + continue + yield path + + +def find_misplaced(changelog_dir): + """Return fragment paths that are not ``.nextchanges/
/.md``.""" + misplaced = [] + for path in iter_fragment_files(changelog_dir): + rel = path.relative_to(changelog_dir) + if len(rel.parts) != 2 or rel.parts[0] not in SECTION_SLUGS: + misplaced.append(path) + return misplaced + + +def check(root): + """Validate fragment placement. Returns a process exit code.""" + changelog_dir = root / CHANGELOG_DIR + if not changelog_dir.is_dir(): + return 0 + + problems = [] + for path in find_misplaced(changelog_dir): + problems.append(f"{path}: not in a known section directory") + for path in iter_fragment_files(changelog_dir): + if not path.read_text(encoding="utf-8").strip(): + problems.append(f"{path}: empty fragment") + + if problems: + for msg in problems: + print(msg, file=sys.stderr) + valid = ", ".join(slug for slug, _ in SECTIONS) + print(f"\nFragments must live at {CHANGELOG_DIR}/
/.md", file=sys.stderr) + print(f"Valid sections: {valid}", file=sys.stderr) + return 1 + return 0 + + +def collate(root): + """Fold fragments into NEXT_CHANGELOG.md and delete them.""" + changelog_dir = root / CHANGELOG_DIR + next_changelog = root / NEXT_CHANGELOG + + misplaced = find_misplaced(changelog_dir) if changelog_dir.is_dir() else [] + if misplaced: + for path in misplaced: + print(f"{path}: not in a known section directory", file=sys.stderr) + raise SystemExit(1) + + content = next_changelog.read_text(encoding="utf-8") + consumed = [] + total = 0 + for slug, header in SECTIONS: + section_dir = changelog_dir / slug + if not section_dir.is_dir(): + continue + entries = [] + for path in sorted(section_dir.glob("*.md")): + if path.name == "README.md": + continue + entries.append(normalize_entry(path.read_text(encoding="utf-8"))) + consumed.append(path) + if entries: + content = insert_entries(content, header, entries) + total += len(entries) + print(f"{header}: collated {len(entries)} entr{'y' if len(entries) == 1 else 'ies'}") + + if not consumed: + print("No changelog fragments to collate.") + return + + next_changelog.write_text(content, encoding="utf-8") + for path in consumed: + path.unlink() + print(f"Collated {total} entries into {NEXT_CHANGELOG} and removed {len(consumed)} fragments.") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--check", action="store_true", help="validate fragment placement without writing") + parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd(), help="repository root") + args = parser.parse_args(argv) + + if args.check: + sys.exit(check(args.root)) + collate(args.root) + + +if __name__ == "__main__": + main() From 368042d88d0ad678f199eb7a2a7bce17408f41af Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 12 Jun 2026 01:43:01 +0200 Subject: [PATCH 2/3] Add changelog-collate release workflow A manually dispatched workflow that runs tools/collate_changelog.py, expands any (#NNNN) references to links, and opens a single 'Collate changelog fragments' PR via peter-evans/create-pull-request (matching the bump-vuln-deps pattern of opening a PR rather than pushing to main). Run it and merge the PR before dispatching the 'tagging' workflow. With no fragments present the collator is a no-op, so no PR is opened. Co-authored-by: Isaac --- .github/workflows/changelog-collate.yml | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/changelog-collate.yml diff --git a/.github/workflows/changelog-collate.yml b/.github/workflows/changelog-collate.yml new file mode 100644 index 00000000000..6092315e1a1 --- /dev/null +++ b/.github/workflows/changelog-collate.yml @@ -0,0 +1,52 @@ +name: changelog-collate + +# Release-prep step: fold all .nextchanges/ fragments into NEXT_CHANGELOG.md and +# open a single PR. Run this (and merge the PR) before dispatching the `tagging` +# workflow so the release picks up every entry. Between releases, fragments +# accumulate under .nextchanges/ without ever touching NEXT_CHANGELOG.md, so +# contributor PRs don't conflict. +on: + workflow_dispatch: + +# Ensure two dispatches don't race on the PR branch. +concurrency: + group: changelog-collate + +permissions: + contents: write + pull-requests: write + +jobs: + collate: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.8.9" + + - name: Collate fragments into NEXT_CHANGELOG.md + run: uv run --script tools/collate_changelog.py + + - name: Expand PR references to links + run: uv run --script tools/update_github_links.py NEXT_CHANGELOG.md + + - name: Determine release version + id: version + run: echo "version=$(grep -m1 '## Release v' NEXT_CHANGELOG.md | sed 's/^## Release //')" >> "$GITHUB_OUTPUT" + + - name: Create pull request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + # A fixed branch means a re-run updates the existing open PR in place + # rather than opening a new one. + branch: auto/collate-changelog + commit-message: "Collate changelog fragments for ${{ steps.version.outputs.version }}" + title: "Collate changelog fragments for ${{ steps.version.outputs.version }}" + body: |- + Folds every `.nextchanges/` fragment into the matching section of `NEXT_CHANGELOG.md` and removes the fragment files. + + Merge this before dispatching the `tagging` workflow so the release picks up every entry. No fragments means no diff and no PR. From 7707dd1d837db3104bb75be608f7f4af21d28b0a Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 12 Jun 2026 01:43:12 +0200 Subject: [PATCH 3/3] Point contributors at .nextchanges in PR template and pr-checklist Update the contributor-facing guidance to add a .nextchanges/
/.md fragment instead of editing NEXT_CHANGELOG.md: each PR adds its own file so entries never conflict, the filename is arbitrary, the leading bullet and the PR link are optional, and it can be created from the GitHub UI. These are the only two docs that described the old workflow. Co-authored-by: Isaac --- .agent/skills/pr-checklist/SKILL.md | 10 +++++----- .github/PULL_REQUEST_TEMPLATE.md | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.agent/skills/pr-checklist/SKILL.md b/.agent/skills/pr-checklist/SKILL.md index fc3d78e93ec..4ac86450821 100644 --- a/.agent/skills/pr-checklist/SKILL.md +++ b/.agent/skills/pr-checklist/SKILL.md @@ -55,7 +55,7 @@ If an agent (you) authored or substantially helped author the PR, disclose it on ## Changelog entry -Add a `NEXT_CHANGELOG.md` entry when your change is user-visible. CI generates the real `CHANGELOG.md` from `NEXT_CHANGELOG.md` at release time, so never hand-edit `CHANGELOG.md` directly. +Add a changelog fragment under `.nextchanges/` when your change is user-visible. Each PR adds its own file, so entries never conflict between PRs. CI collates the fragments and generates the real `CHANGELOG.md` at release time, so never hand-edit `CHANGELOG.md` or `NEXT_CHANGELOG.md` directly. **When to add an entry:** - New or changed CLI command, flag, or subcommand behavior @@ -69,7 +69,7 @@ Add a `NEXT_CHANGELOG.md` entry when your change is user-visible. CI generates t - Auto-generated output changes without a corresponding user-facing change **How to add:** -- Pick the right section (`CLI`, `Bundles`, `Dependency updates`) under the current `## Release vX.Y.Z` header. -- One or two sentences, user-facing language, no Jira links. -- Reference the PR number once it's open: after `gh pr create`, edit the entry to append ` (#NNNN)` or similar matching nearby entries. -- Match the voice and tense of the existing entries in the file. +- Create `.nextchanges/
/.md`, picking the section folder that fits: `cli`, `bundles`, `dependency-updates`, `notable-changes`, or `api-changes`. `` is arbitrary (a feature name or your PR number) — just keep it unique. You can create it straight from the GitHub UI. +- Write one or two sentences in user-facing language, no Jira links. The leading `* ` is optional. Match the voice and tense of existing changelog entries. +- A PR link is optional: write `(#NNNN)` in the text and it's expanded to a full link automatically. +- See `.nextchanges/README.md` for details. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2b6289b935b..21bad8efd76 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,4 +9,5 @@ For example, were there any decisions behind the change that are not reflected i +add a changelog fragment: create .nextchanges/
/.md with a +one-line description (e.g. .nextchanges/cli/quickstart.md). See .nextchanges/README.md. -->