diff --git a/.agent/skills/pr-checklist/SKILL.md b/.agent/skills/pr-checklist/SKILL.md index fc3d78e93ec..9699c7238f3 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. +- 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)` (with NNNN being the PR number) 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. --> diff --git a/.github/workflows/changelog-collate.yml b/.github/workflows/changelog-collate.yml new file mode 100644 index 00000000000..25dd4479391 --- /dev/null +++ b/.github/workflows/changelog-collate.yml @@ -0,0 +1,51 @@ +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: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + # Reuse the changelog-collate task so collate + link-expansion stay + # defined in one place (Taskfile.yml). + - name: Collate fragments and expand PR links + run: go tool -modfile=tools/task/go.mod task changelog-collate + + - 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. 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()