Skip to content
Open
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
10 changes: 5 additions & 5 deletions .agent/skills/pr-checklist/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<section>/<name>.md`, picking the section folder that fits: `cli`, `bundles`, `dependency-updates`, `notable-changes`, or `api-changes`. `<name>` 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.
3 changes: 2 additions & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ For example, were there any decisions behind the change that are not reflected i
<!-- How have you tested the changes? -->

<!-- If your PR needs to be included in the release notes for next release,
add a separate entry in NEXT_CHANGELOG.md as part of your PR. -->
add a changelog fragment: create .nextchanges/<section>/<name>.md with a
one-line description (e.g. .nextchanges/cli/quickstart.md). See .nextchanges/README.md. -->
51 changes: 51 additions & 0 deletions .github/workflows/changelog-collate.yml
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions .nextchanges/README.md
Original file line number Diff line number Diff line change
@@ -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/<section>/<name>.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.

- `<name>` 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.
Empty file.
Empty file added .nextchanges/bundles/.gitkeep
Empty file.
Empty file added .nextchanges/cli/.gitkeep
Empty file.
Empty file.
Empty file.
14 changes: 13 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -272,14 +283,15 @@ 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:
- task: tidy
- task: ws
- task: links
- task: deadcode
- task: changelog-check

install-pythons:
desc: Install Python 3.9-3.13 via uv
Expand Down
226 changes: 226 additions & 0 deletions tools/collate_changelog.py
Original file line number Diff line number Diff line change
@@ -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/<section>/`` 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
<BLANKLINE>
### CLI
* Added a flag (#1).
<BLANKLINE>
### 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).
<BLANKLINE>
### 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/<section>/<name>.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}/<section>/<name>.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()
Loading