From c5c61909037d82538cd730b05a7d9d66ce07bbd9 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Wed, 26 Aug 2026 15:19:00 +0200 Subject: [PATCH 01/15] Remove update_github_links.py --- .github/workflows/changelog-preview.yml | 1 - Taskfile.yml | 16 +-- tools/update_github_links.py | 155 ------------------------ 3 files changed, 3 insertions(+), 169 deletions(-) delete mode 100755 tools/update_github_links.py diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 4bd896b47e5..84b8af5e844 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -16,7 +16,6 @@ on: - ".nextchanges/**" - "internal/genkit/**" - "tools/validate_nextchanges.py" - - "tools/update_github_links.py" push: branches: - main diff --git a/Taskfile.yml b/Taskfile.yml index 8ed24ad0f60..e0553cbfc23 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -281,17 +281,8 @@ tasks: cmds: - "./tools/validate_whitespace.py --fix" - links: - desc: Update GitHub links in CHANGELOG.md and .nextchanges/ fragments - sources: - - CHANGELOG.md - - ".nextchanges/**/*.md" - - tools/update_github_links.py - cmds: - - "./tools/update_github_links.py" - check-changelog: - desc: Validate .nextchanges fragment placement + desc: Validate .nextchanges fragment placement and links cmds: - "./tools/validate_nextchanges.py" @@ -321,13 +312,12 @@ tasks: - "! git grep -lF databricks.com -- '*uv.lock' '*.py.lock'" checks: - desc: Run quick checks (tidy, whitespace, links, deadcode, changelog, lockfiles) + desc: Run quick checks (tidy, whitespace, deadcode, changelog, lockfiles) # Sequential: `tidy` rewrites go.mod/go.sum and any future tidy work - # touching more paths should not race with whitespace/link scanners. + # touching more paths should not race with the whitespace scanner. cmds: - task: tidy - task: ws - - task: links - task: deadcode - task: check-changelog - task: check-lockfiles diff --git a/tools/update_github_links.py b/tools/update_github_links.py deleted file mode 100755 index 01f940d47d4..00000000000 --- a/tools/update_github_links.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.12" -# /// -"""Update PR references in changelog files. - -1. Convert occurrences of `#1234` to the canonical markdown link - `([#1234](https://github.com/databricks/cli/pull/1234))`. -2. Validate that for existing converted references the PR number in the text - and in the URL match. - -By default this processes CHANGELOG.md and every .nextchanges/ fragment, so -raw references in fragments are expanded here (via the `links` task, enforced -in CI) before the release renders them into CHANGELOG.md. -""" - -import argparse -import pathlib -import re -import sys - - -def default_files(): - """CHANGELOG.md plus every .nextchanges/ fragment (README excluded).""" - files = [pathlib.Path("CHANGELOG.md")] - files += sorted(p for p in pathlib.Path(".nextchanges").glob("*/*.md") if p.name != "README.md") - return files - - -# Canonical form: ([#1234](https://github.com/databricks/cli/pull/1234)) -CONVERTED_LINK_RE = re.compile( - r"\(\[#(?P\d+)\]\(" # ([#1234]( - r"https://github\.com/databricks/cli/pull/(?P\d+)" # …/pull/1234 - r"\)\)" # )) -) - -# Double-paren form produced by a previous incorrect run: -# (([#1234](https://github.com/databricks/cli/pull/1234))) -DOUBLE_PAREN_LINK_RE = re.compile( - r"\(\(\[#(?P\d+)\]\(" - r"https://github\.com/databricks/cli/pull/\d+" - r"\)\)\)" -) - -# Raw reference already wrapped in parens: (#1234) -PAREN_RAW_REF_RE = re.compile(r"\(#(?P\d+)\)") - -# Bare raw reference not already part of a converted link or paren-wrapped ref. -# Negative look-behinds: '[' means it's inside a converted link; '(' means -# it will be handled by PAREN_RAW_REF_RE above. -RAW_REF_RE = re.compile(r"(?\d+)\b") - - -def find_mismatched_links(text): - """Return texts of mismatching converted links. - - >>> find_mismatched_links("([#1234](https://github.com/databricks/cli/pull/1234))") - [] - >>> find_mismatched_links("([#1234](https://github.com/databricks/cli/pull/9999))") - ['Converted link numbers differ: text #1234 vs URL #9999 — …([#1234](https://github.com/databricks/cli/pull/9999))…'] - """ - mismatches = [] - for m in CONVERTED_LINK_RE.finditer(text): - num_text, num_url = m.group("num_text"), m.group("num_url") - if num_text != num_url: - context = text[max(0, m.start() - 20) : m.end() + 20] - mismatches.append(f"Converted link numbers differ: text #{num_text} vs URL #{num_url} — …{context}…") - return mismatches - - -def convert_raw_references(text): - """Convert raw `#1234` references to markdown links. - - Already-converted single-paren links are left unchanged: - - >>> convert_raw_references("([#1234](https://github.com/databricks/cli/pull/1234))") - '([#1234](https://github.com/databricks/cli/pull/1234))' - - Double-paren links from a previous incorrect run are collapsed to single-paren: - - >>> convert_raw_references("(([#1234](https://github.com/databricks/cli/pull/1234)))") - '([#1234](https://github.com/databricks/cli/pull/1234))' - - A raw reference with surrounding parens becomes a single-paren link (not double): - - >>> convert_raw_references("(#3456)") - '([#3456](https://github.com/databricks/cli/pull/3456))' - - A bare raw reference gets wrapped in a single-paren link: - - >>> convert_raw_references("#3456") - '([#3456](https://github.com/databricks/cli/pull/3456))' - - Idempotent: running twice produces the same result: - - >>> t = "(#3456) and #7890" - >>> convert_raw_references(convert_raw_references(t)) == convert_raw_references(t) - True - """ - - def _make_link(num): - return f"([#{num}](https://github.com/databricks/cli/pull/{num}))" - - # Fix existing double-paren links produced by a previous incorrect run. - text = DOUBLE_PAREN_LINK_RE.sub(lambda m: _make_link(m.group("num")), text) - - # Convert (#1234) — parens already present, replace the whole token. - text = PAREN_RAW_REF_RE.sub(lambda m: _make_link(m.group("num")), text) - - # Convert bare #1234 — not preceded by [ (converted) or ( (paren-wrapped). - text = RAW_REF_RE.sub(lambda m: _make_link(m.group("num")), text) - - return text - - -def process_file(path): - """Process a single file. - - Returns True if the file was *modified*. - Raises `SystemExit` with non-zero status on mismatching converted links. - """ - original = path.read_text(encoding="utf-8") - - mismatches = find_mismatched_links(original) - if mismatches: - for msg in mismatches: - print(f"{path}:{msg}", file=sys.stderr) - sys.exit(1) - - updated = convert_raw_references(original) - if updated != original: - path.write_text(updated, encoding="utf-8") - print(f"Updated {path}") - return True - - return False - - -def main(argv=None): - parser = argparse.ArgumentParser(description="Convert #PR references in changelogs to links.") - parser.add_argument( - "files", - nargs="*", - help="Markdown files to process (default: CHANGELOG.md and .nextchanges/ fragments)", - ) - args = parser.parse_args(argv) - - files = [pathlib.Path(f) for f in args.files] if args.files else default_files() - modified_any = False - for file_path in files: - modified_any |= process_file(file_path) - - -if __name__ == "__main__": - main() From 6a9f7d31a90a128f68619bb4808b061727157ccf Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Wed, 26 Aug 2026 15:29:03 +0200 Subject: [PATCH 02/15] Update validate_nextchanges.py --- tools/validate_nextchanges.py | 222 +++++++++++++++++++++++++++++++++- 1 file changed, 217 insertions(+), 5 deletions(-) diff --git a/tools/validate_nextchanges.py b/tools/validate_nextchanges.py index a6755ea5d25..314f0ad6744 100755 --- a/tools/validate_nextchanges.py +++ b/tools/validate_nextchanges.py @@ -15,6 +15,7 @@ import json import pathlib import re +import subprocess import sys CHANGELOG_DIR = ".nextchanges" @@ -38,6 +39,154 @@ # drift. The release renderer only reads *.md fragments, so it ignores this. NEXTVERSION_GO = "nextversion.go" +# A fragment is a single changelog entry: one line that starts with "* " and +# ends with a period. An optional trailing PR link group "([#N](pull-url))" — or +# a comma-separated list "([#N](…), [#M](…))" for entries spanning several PRs — +# may follow the period. Every "#N" reference must be written as a full markdown +# link: a bare or paren-wrapped "#N" would render as an unintended auto-link in +# CHANGELOG.md, and nothing expands links anymore. +BULLET_PREFIX = "* " +_PR_LINK = r"\[#\d+\]\(https://github\.com/databricks/cli/pull/\d+\)" +TRAILING_PR_LINKS_RE = re.compile(rf" \((?P{_PR_LINK}(?:, {_PR_LINK})*)\)$") +_PR_LINK_NUM_RE = re.compile(r"\[#(\d+)\]") + +# A "#N" not preceded by "[" is a raw, unexpanded reference (bare, or wrapped in +# parens); the "[#N]" of a markdown link is preceded by "[" and so is excluded. +RAW_REF_RE = re.compile(r"(?>> fragment_format_problem("* Added the `foo` command.") + >>> fragment_format_problem("* Fixed a bug. ([#6208](https://github.com/databricks/cli/pull/6208))") + >>> fragment_format_problem("Added the `foo` command.") + 'must start with a "* " bullet marker' + >>> fragment_format_problem("* Added the `foo` command") + 'must end with a period' + >>> fragment_format_problem("* First entry.\n* Second entry.") + 'must be a single entry on one line' + >>> fragment_format_problem(" ") + 'empty fragment' + """ + stripped = text.strip() + if not stripped: + return "empty fragment" + if "\n" in stripped: + return "must be a single entry on one line" + if not stripped.startswith(BULLET_PREFIX): + return 'must start with a "* " bullet marker' + # The trailing PR link group follows the period; ignore it when checking + # that the entry text itself ends with a period. + if not TRAILING_PR_LINKS_RE.sub("", stripped).endswith("."): + return "must end with a period" + return None + + +def trailing_pr_numbers(text): + r"""Return the PR numbers in ``text``'s trailing PR link group (possibly + several), or an empty list if there is none. + + >>> trailing_pr_numbers("* A change. ([#6208](https://github.com/databricks/cli/pull/6208))") + ['6208'] + >>> trailing_pr_numbers("* A change. ([#12](https://github.com/databricks/cli/pull/12), [#34](https://github.com/databricks/cli/pull/34))") + ['12', '34'] + >>> trailing_pr_numbers("* A change.") + [] + """ + m = TRAILING_PR_LINKS_RE.search(text.strip()) + return _PR_LINK_NUM_RE.findall(m.group("links")) if m else [] + + +def link_problem(text): + r"""Return a problem with the ``#`` references in ``text``, or ``None``. + + Every reference must be a full markdown link; a bare or paren-wrapped ``#N`` + (which GitHub would auto-link in the rendered CHANGELOG.md) is rejected. A PR + link's text number and URL number must also agree. + + >>> link_problem("* Fixed a bug. ([#5](https://github.com/databricks/cli/pull/5))") + >>> link_problem("* Fixed a bug (#5).") + 'unexpanded reference #5: write it as a markdown link, e.g. [#5](https://github.com/databricks/cli/pull/5)' + >>> link_problem("* Reverts #7 for now.") + 'unexpanded reference #7: write it as a markdown link, e.g. [#7](https://github.com/databricks/cli/pull/7)' + >>> link_problem("* Oops. ([#5](https://github.com/databricks/cli/pull/9))") + 'PR link text #5 does not match its URL (pull/9)' + """ + m = RAW_REF_RE.search(text) + if m: + ref = m.group(0) + return f"unexpanded reference {ref}: write it as a markdown link, e.g. [{ref}](https://github.com/databricks/cli/pull/{ref[1:]})" + for lm in PR_LINK_RE.finditer(text): + if lm.group(1) != lm.group(2): + return f"PR link text #{lm.group(1)} does not match its URL (pull/{lm.group(2)})" + return None + + +def pr_link_problem(text, require_pr_link, expected_pr): + r"""Return a problem with ``text``'s trailing PR link group, or ``None``. + + ``expected_pr`` is the PR that introduced the fragment (see + ``infer_expected_pr``); it must appear among the linked PRs, so an entry may + also list follow-up PRs. ``require_pr_link`` makes the link mandatory — set + whenever the change is associated with a PR (see ``main``). + + >>> pr_link_problem("* A change.", False, None) + >>> pr_link_problem("* A change.", True, "5") + 'missing trailing PR link: end with ([#5](https://github.com/databricks/cli/pull/5))' + >>> pr_link_problem("* A change.", True, None) + 'missing trailing PR link: end with ([#](https://github.com/databricks/cli/pull/))' + >>> pr_link_problem("* A change. ([#5](https://github.com/databricks/cli/pull/5))", True, "5") + >>> pr_link_problem("* A change. ([#5](https://github.com/databricks/cli/pull/5), [#9](https://github.com/databricks/cli/pull/9))", True, "9") + >>> pr_link_problem("* A change. ([#5](https://github.com/databricks/cli/pull/5))", True, "9") + 'trailing PR link #5 must include the PR that added this fragment (#9)' + >>> pr_link_problem("* A change. ([#5](https://github.com/databricks/cli/pull/5))", False, None) + """ + numbers = trailing_pr_numbers(text) + if not numbers: + if not require_pr_link: + return None + pr = expected_pr or "" + return f"missing trailing PR link: end with ([#{pr}](https://github.com/databricks/cli/pull/{pr}))" + if expected_pr is not None and expected_pr not in numbers: + shown = ", ".join("#" + n for n in numbers) + return f"trailing PR link {shown} must include the PR that added this fragment (#{expected_pr})" + return None + + +def infer_expected_pr(path, fallback_pr, root): + """Return the PR number that introduced the fragment at ``path``. + + databricks/cli squash-merges end the commit subject with ``(#N)``, so the + commit that most recently added the file names its PR. A fragment not yet on + main (added on the current branch, or uncommitted) has no such commit, so + ``fallback_pr`` — the current PR — is used. ``git`` runs in ``root`` so the + repo being validated is queried even when ``--root`` differs from the process + CWD. Requires full git history (the workflow checks out with + ``fetch-depth: 0``); best-effort, so any git failure falls back rather than + erroring.""" + try: + result = subprocess.run( + ["git", "log", "-1", "--diff-filter=A", "--format=%s", "--", str(path)], + capture_output=True, + text=True, + timeout=10, + cwd=root, + ) + except (OSError, subprocess.SubprocessError): + return fallback_pr + if result.returncode == 0: + m = re.search(r"\(#(\d+)\)\s*$", result.stdout.strip()) + if m: + return m.group(1) + return fallback_pr + def load_sections(root): """Return the section slugs from .codegen.json, in changelog order. @@ -54,10 +203,13 @@ def load_sections(root): return tuple(sections) -def find_problems(changelog_dir, sections): +def find_problems(changelog_dir, sections, require_pr_link=False, fallback_pr=None, root=None): """Return a list of ``(path, message)`` for anything unexpected under ``.nextchanges/``: files that aren't a section fragment or known scaffolding, - empty fragments, and a missing/malformed version file.""" + malformed fragments, a trailing PR link that is missing or names the wrong + PR, and a missing/malformed version file. ``require_pr_link`` and + ``fallback_pr`` drive the PR-link checks (set in CI / from the branch's PR, + see ``main``); ``root`` is the repo the PR inference queries via git.""" problems = [] known_sections = set(sections) for path in sorted(changelog_dir.rglob("*")): @@ -81,8 +233,16 @@ def find_problems(changelog_dir, sections): continue if not name.endswith(".md"): problems.append((path, "unexpected file (fragments must be *.md)")) - elif not path.read_text(encoding="utf-8").strip(): - problems.append((path, "empty fragment")) + else: + text = path.read_text(encoding="utf-8") + problem = fragment_format_problem(text) or link_problem(text) + if problem is None: + # Only infer the expected PR (a git call) once the fragment + # is structurally valid. + expected_pr = infer_expected_pr(path, fallback_pr, root) + problem = pr_link_problem(text, require_pr_link, expected_pr) + if problem: + problems.append((path, problem)) continue # Wrong depth or an unknown section directory. @@ -96,9 +256,46 @@ def find_problems(changelog_dir, sections): return problems +def current_branch_pr(root): + """Best-effort PR number for the current branch (via ``gh``), or ``None``. + + Used locally to associate the branch with a PR: its presence means the link + is required, and its value is the fallback PR for not-yet-merged fragments. + ``gh`` runs in ``root`` so it resolves the repo being validated. Any failure — + ``gh`` missing, offline, unauthenticated, or no PR for the branch — returns + ``None`` so local runs never hard-fail on tooling.""" + try: + result = subprocess.run( + ["gh", "pr", "view", "--json", "number", "-q", ".number"], + capture_output=True, + text=True, + timeout=10, + cwd=root, + ) + except (OSError, subprocess.SubprocessError): + return None + out = result.stdout.strip() + return out if result.returncode == 0 and out.isdigit() else None + + +def has_fragments(changelog_dir): + """Whether any *.md fragment (excluding README.md) exists under a section.""" + return any(p.name != README for p in changelog_dir.glob("*/*.md")) + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd(), help="repository root") + parser.add_argument( + "--strict", + action="store_true", + help="fail closed: require every fragment's trailing PR link even when the branch's PR can't be auto-detected (set in CI)", + ) + parser.add_argument( + "--pr-number", + default=None, + help="the PR under review; used as the expected link for not-yet-merged fragments (CI passes it for pull requests)", + ) args = parser.parse_args(argv) changelog_dir = args.root / CHANGELOG_DIR @@ -107,11 +304,26 @@ def main(argv=None): sections = load_sections(args.root) - problems = find_problems(changelog_dir, sections) + # A trailing PR link is required whenever the change can be associated with a + # PR, and must name that PR. CI passes --strict (pull requests and pushes to + # main) so enforcement never fails open there, plus --pr-number for pull + # requests. Locally we best-effort detect the branch's open PR — but only + # when there are fragments to check, to avoid a `gh` call on unrelated runs. + require_pr_link = args.strict + fallback_pr = args.pr_number + if not require_pr_link and has_fragments(changelog_dir): + branch_pr = current_branch_pr(args.root) + if branch_pr is not None: + require_pr_link = True + fallback_pr = fallback_pr or branch_pr + + problems = find_problems(changelog_dir, sections, require_pr_link, fallback_pr, args.root) if problems: for path, msg in problems: print(f"{path}: {msg}", file=sys.stderr) print(f"\nFragments must live at {CHANGELOG_DIR}/
/.md", file=sys.stderr) + print("and be a single line with a `* ` bullet marker and a trailing period, e.g.", file=sys.stderr) + print(" * Added the `databricks quickstart` command.", file=sys.stderr) print(f"Valid sections: {', '.join(sections)}", file=sys.stderr) print(f"{CHANGELOG_DIR}/{VERSION_FILE} must hold the next release version.", file=sys.stderr) sys.exit(1) From 2eeb8313d716fe976c884a57086e9743ab2e7cc0 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Wed, 26 Aug 2026 15:24:06 +0200 Subject: [PATCH 03/15] Make changelog-preview pass PR number and --strict flag to validate_nextchanges --- .github/workflows/changelog-preview.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 84b8af5e844..664fce87c02 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -31,6 +31,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history so the validator can infer each fragment's PR from the + # squash-merge commit that added it (see tools/validate_nextchanges.py). + fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 @@ -39,8 +43,15 @@ jobs: # Fail the check on a misplaced/unexpected file under .nextchanges/ so it # can't slip through as a silently-skipped (unrendered) fragment. + # + # --strict fails closed: require the trailing PR link on every fragment and + # check it names the right PR (never fall open to best-effort detection). + # Both triggers are associated with a PR: on pull_request we pass the PR + # number (a fragment added by the PR is not yet on main, so it can't be + # inferred from a squash-merge commit); on push to main each fragment's PR + # is inferred from the commit that added it. - name: Validate .nextchanges placement - run: uv run tools/validate_nextchanges.py + run: uv run tools/validate_nextchanges.py --strict ${{ github.event_name == 'pull_request' && format('--pr-number {0}', github.event.pull_request.number) || '' }} - name: Render changelog preview run: |- From 8e9768b7d7842618573b6dc2f2bd5e2dade69e05 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Wed, 26 Aug 2026 14:45:47 +0200 Subject: [PATCH 04/15] Documentation --- .agents/skills/bump-sdk/SKILL.md | 4 ++-- .agents/skills/bump-tf/SKILL.md | 4 ++-- .agents/skills/pr-checklist/SKILL.md | 4 ++-- .nextchanges/README.md | 27 +++++++++++++++++++-------- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.agents/skills/bump-sdk/SKILL.md b/.agents/skills/bump-sdk/SKILL.md index fb8a1f39b2a..998c18b57c7 100644 --- a/.agents/skills/bump-sdk/SKILL.md +++ b/.agents/skills/bump-sdk/SKILL.md @@ -72,9 +72,9 @@ Confirm no internal proxy URL leaked into any lock file: `./task check-uv-lock` The `check-uv-lock` glob and the genkit lock revert are a known coverage gap; the internal proxy can re-leak into `internal/genkit/*.py.lock` on any future `generate-clijson`, so re-check after every run. **9. Changelog fragment.** -Add a `dependency-updates` entry per the `pr-checklist` skill's "Changelog entry" section, modeled on prior bumps: ``Bump `github.com/databricks/databricks-sdk-go` from vOLD to vNEW.``. +Add a `dependency-updates` entry per the `pr-checklist` skill's "Changelog entry" section, modeled on prior bumps: ``* Bump `github.com/databricks/databricks-sdk-go` from vOLD to vNEW.``. Never reference the Terraform provider version in the changelog fragment or PR body. -Add it without `(#NNNN)` now; backfill the number after the PR exists, then run `./task links` to expand it into the full markdown link in place and commit the result. +Omit the trailing PR link now (you don't have the number yet); after the PR exists, append `([#NNNN](https://github.com/databricks/cli/pull/NNNN))` after the period and commit the result. **10. Commit, push, PR.** If the push 403s, the active gh account lacks write access to `databricks/cli`; switch to one that has it with `gh auth switch`. diff --git a/.agents/skills/bump-tf/SKILL.md b/.agents/skills/bump-tf/SKILL.md index f26e1d28d47..3d6a5d4c85b 100644 --- a/.agents/skills/bump-tf/SKILL.md +++ b/.agents/skills/bump-tf/SKILL.md @@ -72,10 +72,10 @@ Regenerate the affected test's `out*` files with `go test ./acceptance -run 'Tes Add a `dependency-updates` entry per the `pr-checklist` skill's "Changelog entry" section: ``` -Bump Terraform provider from v{old_version} to v{version} (#{pr_number}). +* Bump Terraform provider from v{old_version} to v{version}. ([#{pr_number}](https://github.com/databricks/cli/pull/{pr_number})) ``` -Add it without `(#NNNN)` now; backfill the number after the PR exists, then run `./task links` to expand it into the full markdown link in place and commit the result. +Omit the trailing PR link now (you don't have the number yet); after the PR exists, append `([#NNNN](https://github.com/databricks/cli/pull/NNNN))` after the period and commit the result. **7. Commit, push, PR.** Run `./task fmt` and `./task lint-q` (if either touches `acceptance/`, a fixture is wrong, so fix the source rather than editing output). diff --git a/.agents/skills/pr-checklist/SKILL.md b/.agents/skills/pr-checklist/SKILL.md index 7da1560a156..c3b7da3f4b8 100644 --- a/.agents/skills/pr-checklist/SKILL.md +++ b/.agents/skills/pr-checklist/SKILL.md @@ -70,6 +70,6 @@ Add a changelog fragment under `.nextchanges/` when your change is user-visible. **How to add:** - 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. +- Write a single line in user-facing language, no Jira links: start it with a `* ` bullet marker and end it with a period. Match the voice and tense of existing changelog entries. +- A trailing PR link is required whenever the change is associated with a PR, and the introducing PR must be among the linked ones (the checker infers it and fails if it's missing) — enforced in CI (every PR and `main`) and locally once your branch has an open PR. Write the full markdown link at the very end, after the period: `([#NNNN](https://github.com/databricks/cli/pull/NNNN))` (your PR number). For an entry spanning several PRs, list them comma-separated: `([#NNNN](…), [#MMMM](…))`. Every `#NNNN` reference must be a full markdown link — a bare or paren-wrapped `#NNNN` is rejected. - See `.nextchanges/README.md` for details. diff --git a/.nextchanges/README.md b/.nextchanges/README.md index 5782a54e2dc..f475d66d25e 100644 --- a/.nextchanges/README.md +++ b/.nextchanges/README.md @@ -10,20 +10,31 @@ shared changelog file. Create `.nextchanges/
/.md` and write what changed: ``` -Added the `databricks quickstart` command. +* 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. +type the path (e.g. `.nextchanges/cli/quickstart.md`), write the entry, 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. If you want one, write `(#5464)` and run `task links` - (or `task checks`) to expand it into a full markdown link in place; CI fails - if a raw `(#5464)` is left unexpanded. The release does not expand links, so - the fragment must already be expanded when it lands. -- One file is usually one entry; for several, put each on its own `* ` line. +- One file is exactly one entry: a single line that starts with a `* ` bullet + marker and ends with a period. `task check-changelog` (and CI) enforces this. +- A trailing PR link is required whenever the change is associated with a PR, + and the PR that introduces the entry must be among the linked ones — the + checker infers that PR (from the squash-merge commit that added the fragment, + or your open branch PR) and fails if it isn't listed. CI enforces this on + every PR and on `main`, and `task check-changelog` enforces it locally too + once your branch has an open PR (detected best-effort via `gh`; skipped before + the PR exists or when `gh` is unavailable). Write the full markdown link at the + very end, after the period: + `([#5464](https://github.com/databricks/cli/pull/5464))` (your PR number). For + an entry spanning several PRs, list them comma-separated: + `([#5464](…), [#5500](…))`, as long as the introducing PR is included. +- Every `#5464` reference — inline or trailing — must be a full markdown link. + A bare or paren-wrapped `#5464` is rejected: GitHub would render it as an + unintended auto-link in `CHANGELOG.md`. Nothing rewrites links, so the + fragment must already be correct when it lands. ### Sections From 2e51904cdaa28446052c48e3aef1d9e515badbfa Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Thu, 27 Aug 2026 14:33:35 +0200 Subject: [PATCH 05/15] TEST ONLY: add test fragments --- .nextchanges/cli/test1.md | 1 + .nextchanges/cli/test2.md | 1 + .nextchanges/cli/test3.md | 1 + .nextchanges/cli/test4.md | 1 + 4 files changed, 4 insertions(+) create mode 100644 .nextchanges/cli/test1.md create mode 100644 .nextchanges/cli/test2.md create mode 100644 .nextchanges/cli/test3.md create mode 100644 .nextchanges/cli/test4.md diff --git a/.nextchanges/cli/test1.md b/.nextchanges/cli/test1.md new file mode 100644 index 00000000000..167deb7c294 --- /dev/null +++ b/.nextchanges/cli/test1.md @@ -0,0 +1 @@ +bad format (no leading bullet). ([#6395](https://github.com/databricks/cli/pull/6395)) diff --git a/.nextchanges/cli/test2.md b/.nextchanges/cli/test2.md new file mode 100644 index 00000000000..c518fc4d9f7 --- /dev/null +++ b/.nextchanges/cli/test2.md @@ -0,0 +1 @@ +* bad format #123 unexpanded link. ([#6395](https://github.com/databricks/cli/pull/6395)) diff --git a/.nextchanges/cli/test3.md b/.nextchanges/cli/test3.md new file mode 100644 index 00000000000..ca1c6e2000a --- /dev/null +++ b/.nextchanges/cli/test3.md @@ -0,0 +1 @@ +* bad format, fixing [#123](https://github.com/databricks/cli/pull/123). Wrong PR attribution. ([#6394](https://github.com/databricks/cli/pull/6394)) diff --git a/.nextchanges/cli/test4.md b/.nextchanges/cli/test4.md new file mode 100644 index 00000000000..e226c04af97 --- /dev/null +++ b/.nextchanges/cli/test4.md @@ -0,0 +1 @@ +* Happy path, reverts [#123](https://github.com/databricks/cli/123). Splits over two PRs. ([#6177](https://github.com/databricks/cli/6177), [#6395](https://github.com/databricks/cli/pull/6395)) From 7f4fbc8159bf644cafa30cca45b2e850cf8e5140 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Thu, 27 Aug 2026 14:37:02 +0200 Subject: [PATCH 06/15] Fix test4 which should be happy path --- .nextchanges/cli/test4.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nextchanges/cli/test4.md b/.nextchanges/cli/test4.md index e226c04af97..a7e62bb251b 100644 --- a/.nextchanges/cli/test4.md +++ b/.nextchanges/cli/test4.md @@ -1 +1 @@ -* Happy path, reverts [#123](https://github.com/databricks/cli/123). Splits over two PRs. ([#6177](https://github.com/databricks/cli/6177), [#6395](https://github.com/databricks/cli/pull/6395)) +* Happy path, reverts [#123](https://github.com/databricks/cli/pull/123). Splits over two PRs. ([#6177](https://github.com/databricks/cli/pull/6177), [#6395](https://github.com/databricks/cli/pull/6395)) From 99d5458b3248482f21e2b5f12568bbd8d74d6c44 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Thu, 27 Aug 2026 14:49:02 +0200 Subject: [PATCH 07/15] Correctly attribute trailing parentheses group with wrong link vs trailing period --- tools/validate_nextchanges.py | 58 +++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/tools/validate_nextchanges.py b/tools/validate_nextchanges.py index 314f0ad6744..cdc72e27ad8 100755 --- a/tools/validate_nextchanges.py +++ b/tools/validate_nextchanges.py @@ -46,14 +46,21 @@ # link: a bare or paren-wrapped "#N" would render as an unintended auto-link in # CHANGELOG.md, and nothing expands links anymore. BULLET_PREFIX = "* " -_PR_LINK = r"\[#\d+\]\(https://github\.com/databricks/cli/pull/\d+\)" -TRAILING_PR_LINKS_RE = re.compile(rf" \((?P{_PR_LINK}(?:, {_PR_LINK})*)\)$") -_PR_LINK_NUM_RE = re.compile(r"\[#(\d+)\]") + +# The trailing PR link group: a parenthesized, comma-separated list of markdown +# links at the very end of the entry, e.g. "([#12](…), [#34](…))". Matched +# loosely (any "[..](..)" link) so a malformed link inside still makes the group +# recognizable — it is then reported as a link error, rather than misfiring as +# "must end with a period" because a strict pattern failed to match. +_LINK = r"\[[^\]]*\]\([^)]*\)" +TRAILING_GROUP_RE = re.compile(rf" \((?P{_LINK}(?:, {_LINK})*)\)$") +LINK_RE = re.compile(_LINK) # A "#N" not preceded by "[" is a raw, unexpanded reference (bare, or wrapped in # parens); the "[#N]" of a markdown link is preceded by "[" and so is excluded. RAW_REF_RE = re.compile(r"(?>> trailing_pr_numbers("* A change. ([#6208](https://github.com/databricks/cli/pull/6208))") - ['6208'] - >>> trailing_pr_numbers("* A change. ([#12](https://github.com/databricks/cli/pull/12), [#34](https://github.com/databricks/cli/pull/34))") - ['12', '34'] - >>> trailing_pr_numbers("* A change.") - [] - """ - m = TRAILING_PR_LINKS_RE.search(text.strip()) - return _PR_LINK_NUM_RE.findall(m.group("links")) if m else [] - - def link_problem(text): r"""Return a problem with the ``#`` references in ``text``, or ``None``. @@ -132,28 +126,38 @@ def link_problem(text): def pr_link_problem(text, require_pr_link, expected_pr): r"""Return a problem with ``text``'s trailing PR link group, or ``None``. - ``expected_pr`` is the PR that introduced the fragment (see - ``infer_expected_pr``); it must appear among the linked PRs, so an entry may - also list follow-up PRs. ``require_pr_link`` makes the link mandatory — set - whenever the change is associated with a PR (see ``main``). + The group is recognized loosely, then each link must be a well-formed PR link + (a malformed URL is reported as such). ``expected_pr`` is the PR that + introduced the fragment (see ``infer_expected_pr``); it must appear among the + linked PRs, so an entry may also list follow-up PRs. ``require_pr_link`` makes + the group mandatory — set whenever the change is associated with a PR (see + ``main``). Text/URL number agreement is checked by ``link_problem``. >>> pr_link_problem("* A change.", False, None) >>> pr_link_problem("* A change.", True, "5") 'missing trailing PR link: end with ([#5](https://github.com/databricks/cli/pull/5))' >>> pr_link_problem("* A change.", True, None) 'missing trailing PR link: end with ([#](https://github.com/databricks/cli/pull/))' + >>> pr_link_problem("* A change. ([#6177](https://github.com/databricks/cli/6177))", True, "6177") + 'malformed trailing PR link "[#6177](https://github.com/databricks/cli/6177)": expected [#N](https://github.com/databricks/cli/pull/N)' >>> pr_link_problem("* A change. ([#5](https://github.com/databricks/cli/pull/5))", True, "5") >>> pr_link_problem("* A change. ([#5](https://github.com/databricks/cli/pull/5), [#9](https://github.com/databricks/cli/pull/9))", True, "9") >>> pr_link_problem("* A change. ([#5](https://github.com/databricks/cli/pull/5))", True, "9") 'trailing PR link #5 must include the PR that added this fragment (#9)' >>> pr_link_problem("* A change. ([#5](https://github.com/databricks/cli/pull/5))", False, None) """ - numbers = trailing_pr_numbers(text) - if not numbers: + m = TRAILING_GROUP_RE.search(text.strip()) + if m is None: if not require_pr_link: return None pr = expected_pr or "" return f"missing trailing PR link: end with ([#{pr}](https://github.com/databricks/cli/pull/{pr}))" + numbers = [] + for link in LINK_RE.findall(m.group("links")): + lm = PR_LINK_RE.fullmatch(link) + if lm is None: + return f'malformed trailing PR link "{link}": expected [#N](https://github.com/databricks/cli/pull/N)' + numbers.append(lm.group(1)) if expected_pr is not None and expected_pr not in numbers: shown = ", ".join("#" + n for n in numbers) return f"trailing PR link {shown} must include the PR that added this fragment (#{expected_pr})" From 6b463c6861da827f3348b45cbe196bd615e054b1 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Thu, 27 Aug 2026 15:27:41 +0200 Subject: [PATCH 08/15] Remove test files --- .nextchanges/cli/test1.md | 1 - .nextchanges/cli/test2.md | 1 - .nextchanges/cli/test3.md | 1 - .nextchanges/cli/test4.md | 1 - 4 files changed, 4 deletions(-) delete mode 100644 .nextchanges/cli/test1.md delete mode 100644 .nextchanges/cli/test2.md delete mode 100644 .nextchanges/cli/test3.md delete mode 100644 .nextchanges/cli/test4.md diff --git a/.nextchanges/cli/test1.md b/.nextchanges/cli/test1.md deleted file mode 100644 index 167deb7c294..00000000000 --- a/.nextchanges/cli/test1.md +++ /dev/null @@ -1 +0,0 @@ -bad format (no leading bullet). ([#6395](https://github.com/databricks/cli/pull/6395)) diff --git a/.nextchanges/cli/test2.md b/.nextchanges/cli/test2.md deleted file mode 100644 index c518fc4d9f7..00000000000 --- a/.nextchanges/cli/test2.md +++ /dev/null @@ -1 +0,0 @@ -* bad format #123 unexpanded link. ([#6395](https://github.com/databricks/cli/pull/6395)) diff --git a/.nextchanges/cli/test3.md b/.nextchanges/cli/test3.md deleted file mode 100644 index ca1c6e2000a..00000000000 --- a/.nextchanges/cli/test3.md +++ /dev/null @@ -1 +0,0 @@ -* bad format, fixing [#123](https://github.com/databricks/cli/pull/123). Wrong PR attribution. ([#6394](https://github.com/databricks/cli/pull/6394)) diff --git a/.nextchanges/cli/test4.md b/.nextchanges/cli/test4.md deleted file mode 100644 index a7e62bb251b..00000000000 --- a/.nextchanges/cli/test4.md +++ /dev/null @@ -1 +0,0 @@ -* Happy path, reverts [#123](https://github.com/databricks/cli/pull/123). Splits over two PRs. ([#6177](https://github.com/databricks/cli/pull/6177), [#6395](https://github.com/databricks/cli/pull/6395)) From bd6956d72791432f8b21a7625b2eedfd7176f480 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 1 Sep 2026 12:04:31 +0200 Subject: [PATCH 09/15] Infer cli args from CI env var and PR_NUMBER (added to workflow) --- .github/workflows/changelog-preview.yml | 18 +++---- tools/validate_nextchanges.py | 67 +++++++++++++++---------- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 664fce87c02..76b80d1cc8a 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -42,16 +42,16 @@ jobs: version: "0.8.9" # Fail the check on a misplaced/unexpected file under .nextchanges/ so it - # can't slip through as a silently-skipped (unrendered) fragment. - # - # --strict fails closed: require the trailing PR link on every fragment and - # check it names the right PR (never fall open to best-effort detection). - # Both triggers are associated with a PR: on pull_request we pass the PR - # number (a fragment added by the PR is not yet on main, so it can't be - # inferred from a squash-merge commit); on push to main each fragment's PR - # is inferred from the commit that added it. + # can't slip through as a silently-skipped (unrendered) fragment. Running in + # CI (GITHUB_ACTIONS) makes the trailing PR link mandatory and checks it + # names the right PR. PR_NUMBER is the authoritative event PR (empty on push + # to main, where each fragment's PR is inferred from its squash-merge + # commit); it must have full history to attribute fragments, hence the + # fetch-depth: 0 checkout above. - name: Validate .nextchanges placement - run: uv run tools/validate_nextchanges.py --strict ${{ github.event_name == 'pull_request' && format('--pr-number {0}', github.event.pull_request.number) || '' }} + env: + PR_NUMBER: ${{ github.event.number }} + run: uv run tools/validate_nextchanges.py - name: Render changelog preview run: |- diff --git a/tools/validate_nextchanges.py b/tools/validate_nextchanges.py index cdc72e27ad8..d25f34ad300 100755 --- a/tools/validate_nextchanges.py +++ b/tools/validate_nextchanges.py @@ -13,6 +13,7 @@ import argparse import json +import os import pathlib import re import subprocess @@ -260,14 +261,22 @@ def find_problems(changelog_dir, sections, require_pr_link=False, fallback_pr=No return problems +def in_ci(): + """Whether we're running in GitHub Actions (where the link is required). + + GitHub Actions sets ``GITHUB_ACTIONS`` for every step; see + https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables""" + return os.environ.get("GITHUB_ACTIONS") == "true" + + def current_branch_pr(root): """Best-effort PR number for the current branch (via ``gh``), or ``None``. - Used locally to associate the branch with a PR: its presence means the link - is required, and its value is the fallback PR for not-yet-merged fragments. - ``gh`` runs in ``root`` so it resolves the repo being validated. Any failure — - ``gh`` missing, offline, unauthenticated, or no PR for the branch — returns - ``None`` so local runs never hard-fail on tooling.""" + A local convenience only; the branch is ambiguous when its name maps to + several PRs, so CI uses the authoritative event number instead (see + ``detect_current_pr``). ``gh`` runs in ``root`` so it resolves the repo being + validated. Any failure — ``gh`` missing, offline, unauthenticated, or no PR + for the branch — returns ``None`` so local runs never hard-fail on tooling.""" try: result = subprocess.run( ["gh", "pr", "view", "--json", "number", "-q", ".number"], @@ -282,6 +291,22 @@ def current_branch_pr(root): return out if result.returncode == 0 and out.isdigit() else None +def detect_current_pr(root): + """PR number to expect for not-yet-merged fragments, or ``None``. + + In CI, use the authoritative event PR number (``PR_NUMBER``, set from + ``github.event.number``) — never the branch, which is ambiguous when a name + maps to several PRs. It is empty on a CI push (e.g. to main), where each + fragment's PR is inferred from its own squash-merge commit instead. Locally, + fall back to a best-effort ``gh`` lookup of the branch's PR.""" + pr = os.environ.get("PR_NUMBER", "").strip() + if pr: + return pr + if in_ci(): + return None + return current_branch_pr(root) + + def has_fragments(changelog_dir): """Whether any *.md fragment (excluding README.md) exists under a section.""" return any(p.name != README for p in changelog_dir.glob("*/*.md")) @@ -290,16 +315,6 @@ def has_fragments(changelog_dir): def main(argv=None): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd(), help="repository root") - parser.add_argument( - "--strict", - action="store_true", - help="fail closed: require every fragment's trailing PR link even when the branch's PR can't be auto-detected (set in CI)", - ) - parser.add_argument( - "--pr-number", - default=None, - help="the PR under review; used as the expected link for not-yet-merged fragments (CI passes it for pull requests)", - ) args = parser.parse_args(argv) changelog_dir = args.root / CHANGELOG_DIR @@ -308,18 +323,16 @@ def main(argv=None): sections = load_sections(args.root) - # A trailing PR link is required whenever the change can be associated with a - # PR, and must name that PR. CI passes --strict (pull requests and pushes to - # main) so enforcement never fails open there, plus --pr-number for pull - # requests. Locally we best-effort detect the branch's open PR — but only - # when there are fragments to check, to avoid a `gh` call on unrelated runs. - require_pr_link = args.strict - fallback_pr = args.pr_number - if not require_pr_link and has_fragments(changelog_dir): - branch_pr = current_branch_pr(args.root) - if branch_pr is not None: - require_pr_link = True - fallback_pr = fallback_pr or branch_pr + # A trailing PR link is required whenever the change is associated with a PR: + # always in CI (fail closed), and locally when the branch has an open PR. + # ``fallback_pr`` is the PR to expect for not-yet-merged fragments — the + # authoritative event number in CI, else the branch's PR locally. Detected + # only when there are fragments, to avoid a `gh` call on unrelated runs. + require_pr_link = False + fallback_pr = None + if has_fragments(changelog_dir): + fallback_pr = detect_current_pr(args.root) + require_pr_link = in_ci() or fallback_pr is not None problems = find_problems(changelog_dir, sections, require_pr_link, fallback_pr, args.root) if problems: From a4c33b35603f95e054ccc6df8772b0167385360b Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 1 Sep 2026 12:09:40 +0200 Subject: [PATCH 10/15] Surface gh error when branch PR detection fails The best-effort `gh pr view` lookup swallowed OSError/SubprocessError silently, hiding why local PR detection did nothing. Print the error to stderr so it is visible. Co-authored-by: Isaac --- tools/validate_nextchanges.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/validate_nextchanges.py b/tools/validate_nextchanges.py index d25f34ad300..2ade8c8d590 100755 --- a/tools/validate_nextchanges.py +++ b/tools/validate_nextchanges.py @@ -285,7 +285,8 @@ def current_branch_pr(root): timeout=10, cwd=root, ) - except (OSError, subprocess.SubprocessError): + except (OSError, subprocess.SubprocessError) as e: + print(f"gh pr view failed: {e}", file=sys.stderr) return None out = result.stdout.strip() return out if result.returncode == 0 and out.isdigit() else None From 8baa901b9daec58d43d70549d93c09d28a616ec7 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 1 Sep 2026 13:20:19 +0200 Subject: [PATCH 11/15] Add --fix for nextchanges --- Taskfile.yml | 7 +++++ tools/validate_nextchanges.py | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/Taskfile.yml b/Taskfile.yml index e0553cbfc23..ffc2380040b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -47,6 +47,7 @@ tasks: full: desc: More complete dev loop (full rather than incremental formatters and linters) cmds: + - task: fix-changelog - task: checks - task: fmt - task: lint @@ -68,6 +69,7 @@ tasks: - task: pydabs-codegen - task: pydabs-lint - task: pydabs-test + - task: fix-changelog - task: checks - task: fmt - task: lint @@ -286,6 +288,11 @@ tasks: cmds: - "./tools/validate_nextchanges.py" + fix-changelog: + desc: Add the branch's PR link to .nextchanges fragments missing one, then validate + cmds: + - "./tools/validate_nextchanges.py --fix" + changelog-preview: desc: Print the CHANGELOG.md section the next release would add from .nextchanges/ cmds: diff --git a/tools/validate_nextchanges.py b/tools/validate_nextchanges.py index 2ade8c8d590..934f0e86de0 100755 --- a/tools/validate_nextchanges.py +++ b/tools/validate_nextchanges.py @@ -313,9 +313,57 @@ def has_fragments(changelog_dir): return any(p.name != README for p in changelog_dir.glob("*/*.md")) +def fragment_paths(changelog_dir): + """The section fragments under ``changelog_dir`` (README.md excluded), sorted.""" + return sorted(p for p in changelog_dir.glob("*/*.md") if p.name != README) + + +def fixed_fragment(text, pr): + r"""Return ``text`` with a trailing PR link for ``pr`` appended, or unchanged. + + Only a well-formed fragment (single line, ``* `` bullet, trailing period) with + no trailing link group is changed; anything else is returned unchanged for the + linter to report, so the fix never guesses at a malformed entry. + + >>> fixed_fragment("* Added a thing.\n", "42") + '* Added a thing. ([#42](https://github.com/databricks/cli/pull/42))\n' + >>> fixed_fragment("* Already linked. ([#7](https://github.com/databricks/cli/pull/7))\n", "42") + '* Already linked. ([#7](https://github.com/databricks/cli/pull/7))\n' + >>> fixed_fragment("No bullet.\n", "42") + 'No bullet.\n' + """ + stripped = text.strip() + if fragment_format_problem(text) is not None or TRAILING_GROUP_RE.search(stripped): + return text + return f"{stripped} ([#{pr}](https://github.com/databricks/cli/pull/{pr}))\n" + + +def autofix(changelog_dir, root): + """Append the branch's PR link to fragments missing one (a lint autofix). + + The PR is inferred from the current branch (local only; see + ``current_branch_pr``). Prints each file changed. Does nothing when the branch + has no PR yet — the number can't be inferred, so there's nothing to add.""" + pr = current_branch_pr(root) + if pr is None: + print("--fix: no PR found for the current branch; cannot infer the link", file=sys.stderr) + return + for path in fragment_paths(changelog_dir): + text = path.read_text(encoding="utf-8") + fixed = fixed_fragment(text, pr) + if fixed != text: + path.write_text(fixed, encoding="utf-8") + print(f"Fixed {path.relative_to(root)}: added ([#{pr}](.../pull/{pr}))") + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd(), help="repository root") + parser.add_argument( + "--fix", + action="store_true", + help="add the branch's PR link to fragments missing one (inferred from the branch via gh), then validate", + ) args = parser.parse_args(argv) changelog_dir = args.root / CHANGELOG_DIR @@ -324,6 +372,9 @@ def main(argv=None): sections = load_sections(args.root) + if args.fix and has_fragments(changelog_dir): + autofix(changelog_dir, args.root) + # A trailing PR link is required whenever the change is associated with a PR: # always in CI (fail closed), and locally when the branch has an open PR. # ``fallback_pr`` is the PR to expect for not-yet-merged fragments — the From 6a8f3684bab58b0476a878ccee10ca146723b43d Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 1 Sep 2026 13:34:59 +0200 Subject: [PATCH 12/15] Fix existing violations --- .nextchanges/bundles/duration-timestamp-diff.md | 2 +- .nextchanges/bundles/fix-app-source-code-path-regression.md | 2 +- .nextchanges/bundles/pydabs-catalogs.md | 2 +- .nextchanges/bundles/reference-nonletter-resource-keys.md | 2 +- .nextchanges/bundles/serverless-environment-version-v5.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.nextchanges/bundles/duration-timestamp-diff.md b/.nextchanges/bundles/duration-timestamp-diff.md index c05b9341e27..1642810bc0f 100644 --- a/.nextchanges/bundles/duration-timestamp-diff.md +++ b/.nextchanges/bundles/duration-timestamp-diff.md @@ -1 +1 @@ -Fixed the direct engine silently ignoring edits to duration and timestamp fields, such as a Lakebase endpoint's `suspend_timeout_duration`. Such a change planned `0 to change` and was never applied. +* Fixed the direct engine silently ignoring edits to duration and timestamp fields, such as a Lakebase endpoint's `suspend_timeout_duration`. Such a change planned `0 to change` and was never applied. ([#6377](https://github.com/databricks/cli/pull/6377)) diff --git a/.nextchanges/bundles/fix-app-source-code-path-regression.md b/.nextchanges/bundles/fix-app-source-code-path-regression.md index b174f1e43ce..bf178a792fd 100644 --- a/.nextchanges/bundles/fix-app-source-code-path-regression.md +++ b/.nextchanges/bundles/fix-app-source-code-path-regression.md @@ -1 +1 @@ -Fix `bundle deploy` failing with `deployment_source.source_code_path cannot be set on UpdateApp` (400) when updating an app that has an active deployment ([#6401](https://github.com/databricks/cli/issues/6401)). +* Fix `bundle deploy` failing with `deployment_source.source_code_path cannot be set on UpdateApp` (400) when updating an app that has an active deployment ([#6401](https://github.com/databricks/cli/issues/6401)). ([#6404](https://github.com/databricks/cli/pull/6404)) diff --git a/.nextchanges/bundles/pydabs-catalogs.md b/.nextchanges/bundles/pydabs-catalogs.md index de1f9b8ea4c..049d965fd00 100644 --- a/.nextchanges/bundles/pydabs-catalogs.md +++ b/.nextchanges/bundles/pydabs-catalogs.md @@ -1 +1 @@ -Added PyDABs (Python) support for catalogs: `Resources.add_catalog` and the `catalog_mutator` decorator. +* Added PyDABs (Python) support for catalogs: `Resources.add_catalog` and the `catalog_mutator` decorator. ([#6408](https://github.com/databricks/cli/pull/6408)) diff --git a/.nextchanges/bundles/reference-nonletter-resource-keys.md b/.nextchanges/bundles/reference-nonletter-resource-keys.md index 73a0ce970d6..8e83ebadd71 100644 --- a/.nextchanges/bundles/reference-nonletter-resource-keys.md +++ b/.nextchanges/bundles/reference-nonletter-resource-keys.md @@ -1 +1 @@ -Fixed `${resources...}` references to resource keys starting with an underscore (e.g. `_my_job`). On the direct engine, deploying such a resource with `permissions` or `grants` failed with `cannot parse "/jobs/${resources.jobs._my_job.id}"`, and user-written references to it were silently left unresolved. +* Fixed `${resources...}` references to resource keys starting with an underscore (e.g. `_my_job`). On the direct engine, deploying such a resource with `permissions` or `grants` failed with `cannot parse "/jobs/${resources.jobs._my_job.id}"`, and user-written references to it were silently left unresolved. ([#6422](https://github.com/databricks/cli/pull/6422)) diff --git a/.nextchanges/bundles/serverless-environment-version-v5.md b/.nextchanges/bundles/serverless-environment-version-v5.md index 5cb280deaf6..492059d35cb 100644 --- a/.nextchanges/bundles/serverless-environment-version-v5.md +++ b/.nextchanges/bundles/serverless-environment-version-v5.md @@ -1 +1 @@ -Bundle templates now use serverless [environment version 5](https://docs.databricks.com/aws/en/release-notes/serverless/environment-version/five), which offers better performance, and `databricks-connect` 16.4. +* Bundle templates now use serverless [environment version 5](https://docs.databricks.com/aws/en/release-notes/serverless/environment-version/five), which offers better performance, and `databricks-connect` 16.4. ([#6378](https://github.com/databricks/cli/pull/6378)) From 9b06a48b3cf2bf355a22151cc9a8b73b3cf18ee0 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 1 Sep 2026 13:40:54 +0200 Subject: [PATCH 13/15] Fix more files --- .nextchanges/bundles/postgres-leaf-update-mask.md | 2 +- .nextchanges/bundles/postgres-map-update-mask.md | 2 +- .nextchanges/bundles/table-update-trigger-condition.md | 2 +- .nextchanges/cli/setup-local-multiline-toml.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.nextchanges/bundles/postgres-leaf-update-mask.md b/.nextchanges/bundles/postgres-leaf-update-mask.md index c9c73786523..607ba1458c4 100644 --- a/.nextchanges/bundles/postgres-leaf-update-mask.md +++ b/.nextchanges/bundles/postgres-leaf-update-mask.md @@ -1 +1 @@ -direct: Fix deploying an update to `postgres_projects.default_endpoint_settings` ([#6440](https://github.com/databricks/cli/pull/6440)). +* direct: Fix deploying an update to `postgres_projects.default_endpoint_settings` ([#6440](https://github.com/databricks/cli/pull/6440)). diff --git a/.nextchanges/bundles/postgres-map-update-mask.md b/.nextchanges/bundles/postgres-map-update-mask.md index 6617e62057a..8396c5f9fc8 100644 --- a/.nextchanges/bundles/postgres-map-update-mask.md +++ b/.nextchanges/bundles/postgres-map-update-mask.md @@ -1 +1 @@ -direct: Fix deploying an update to `postgres_endpoints.settings.pg_settings` ([#6441](https://github.com/databricks/cli/pull/6441)). +* direct: Fix deploying an update to `postgres_endpoints.settings.pg_settings` ([#6441](https://github.com/databricks/cli/pull/6441)). diff --git a/.nextchanges/bundles/table-update-trigger-condition.md b/.nextchanges/bundles/table-update-trigger-condition.md index 6a6fed760be..48d018b6007 100644 --- a/.nextchanges/bundles/table-update-trigger-condition.md +++ b/.nextchanges/bundles/table-update-trigger-condition.md @@ -1 +1 @@ -Fixed a job with a `table_update` trigger never converging on the direct engine ([#6442](https://github.com/databricks/cli/pull/6442)). +* Fixed a job with a `table_update` trigger never converging on the direct engine ([#6442](https://github.com/databricks/cli/pull/6442)). diff --git a/.nextchanges/cli/setup-local-multiline-toml.md b/.nextchanges/cli/setup-local-multiline-toml.md index fb086ce00f5..34af7c54acc 100644 --- a/.nextchanges/cli/setup-local-multiline-toml.md +++ b/.nextchanges/cli/setup-local-multiline-toml.md @@ -1 +1 @@ -Allow `databricks environments setup-local` to update `pyproject.toml` files containing TOML multi-line strings. +* Allow `databricks environments setup-local` to update `pyproject.toml` files containing TOML multi-line strings. From 078c1af603d523d74e3e681bdfe2d6290908a239 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 1 Sep 2026 13:43:26 +0200 Subject: [PATCH 14/15] More fixes --- .nextchanges/bundles/postgres-leaf-update-mask.md | 2 +- .nextchanges/bundles/postgres-map-update-mask.md | 2 +- .nextchanges/bundles/table-update-trigger-condition.md | 2 +- .nextchanges/cli/setup-local-multiline-toml.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.nextchanges/bundles/postgres-leaf-update-mask.md b/.nextchanges/bundles/postgres-leaf-update-mask.md index 607ba1458c4..4f0a67d6861 100644 --- a/.nextchanges/bundles/postgres-leaf-update-mask.md +++ b/.nextchanges/bundles/postgres-leaf-update-mask.md @@ -1 +1 @@ -* direct: Fix deploying an update to `postgres_projects.default_endpoint_settings` ([#6440](https://github.com/databricks/cli/pull/6440)). +* direct: Fix deploying an update to `postgres_projects.default_endpoint_settings`. ([#6440](https://github.com/databricks/cli/pull/6440)) diff --git a/.nextchanges/bundles/postgres-map-update-mask.md b/.nextchanges/bundles/postgres-map-update-mask.md index 8396c5f9fc8..4f5d4bee954 100644 --- a/.nextchanges/bundles/postgres-map-update-mask.md +++ b/.nextchanges/bundles/postgres-map-update-mask.md @@ -1 +1 @@ -* direct: Fix deploying an update to `postgres_endpoints.settings.pg_settings` ([#6441](https://github.com/databricks/cli/pull/6441)). +* direct: Fix deploying an update to `postgres_endpoints.settings.pg_settings`. ([#6441](https://github.com/databricks/cli/pull/6441)) diff --git a/.nextchanges/bundles/table-update-trigger-condition.md b/.nextchanges/bundles/table-update-trigger-condition.md index 48d018b6007..8e4e40b16c1 100644 --- a/.nextchanges/bundles/table-update-trigger-condition.md +++ b/.nextchanges/bundles/table-update-trigger-condition.md @@ -1 +1 @@ -* Fixed a job with a `table_update` trigger never converging on the direct engine ([#6442](https://github.com/databricks/cli/pull/6442)). +* Fixed a job with a `table_update` trigger never converging on the direct engine. ([#6442](https://github.com/databricks/cli/pull/6442)) diff --git a/.nextchanges/cli/setup-local-multiline-toml.md b/.nextchanges/cli/setup-local-multiline-toml.md index 34af7c54acc..21c4c23a5b0 100644 --- a/.nextchanges/cli/setup-local-multiline-toml.md +++ b/.nextchanges/cli/setup-local-multiline-toml.md @@ -1 +1 @@ -* Allow `databricks environments setup-local` to update `pyproject.toml` files containing TOML multi-line strings. +* Allow `databricks environments setup-local` to update `pyproject.toml` files containing TOML multi-line strings. ([#6445](https://github.com/databricks/cli/pull/6445)) From 95e2a123bf45f3cdb0fe1952d2478671a27066ad Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 1 Sep 2026 13:51:53 +0200 Subject: [PATCH 15/15] Auto-fix by default --- Taskfile.yml | 7 ------- tools/validate_nextchanges.py | 15 ++++++--------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index dc75100d546..6f97e3ae06f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -47,7 +47,6 @@ tasks: full: desc: More complete dev loop (full rather than incremental formatters and linters) cmds: - - task: fix-changelog - task: checks - task: fmt - task: lint @@ -69,7 +68,6 @@ tasks: - task: pydabs-codegen - task: pydabs-lint - task: pydabs-test - - task: fix-changelog - task: checks - task: fmt - task: lint @@ -288,11 +286,6 @@ tasks: cmds: - "./tools/validate_nextchanges.py" - fix-changelog: - desc: Add the branch's PR link to .nextchanges fragments missing one, then validate - cmds: - - "./tools/validate_nextchanges.py --fix" - changelog-preview: desc: Print the CHANGELOG.md section the next release would add from .nextchanges/ env: diff --git a/tools/validate_nextchanges.py b/tools/validate_nextchanges.py index 934f0e86de0..545a6bdff25 100755 --- a/tools/validate_nextchanges.py +++ b/tools/validate_nextchanges.py @@ -342,11 +342,11 @@ def autofix(changelog_dir, root): """Append the branch's PR link to fragments missing one (a lint autofix). The PR is inferred from the current branch (local only; see - ``current_branch_pr``). Prints each file changed. Does nothing when the branch - has no PR yet — the number can't be inferred, so there's nothing to add.""" + ``current_branch_pr``). Prints each file changed. Silently does nothing when + the branch has no PR yet — the number can't be inferred, so there's nothing + to add — since this runs by default on every validation.""" pr = current_branch_pr(root) if pr is None: - print("--fix: no PR found for the current branch; cannot infer the link", file=sys.stderr) return for path in fragment_paths(changelog_dir): text = path.read_text(encoding="utf-8") @@ -359,11 +359,6 @@ def autofix(changelog_dir, root): def main(argv=None): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd(), help="repository root") - parser.add_argument( - "--fix", - action="store_true", - help="add the branch's PR link to fragments missing one (inferred from the branch via gh), then validate", - ) args = parser.parse_args(argv) changelog_dir = args.root / CHANGELOG_DIR @@ -372,7 +367,9 @@ def main(argv=None): sections = load_sections(args.root) - if args.fix and has_fragments(changelog_dir): + # Auto-fix by default: add the branch's PR link to fragments missing one + # before validating. A no-op in CI and anywhere the branch has no PR. + if has_fragments(changelog_dir): autofix(changelog_dir, args.root) # A trailing PR link is required whenever the change is associated with a PR: