From c5c61909037d82538cd730b05a7d9d66ce07bbd9 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Wed, 26 Aug 2026 15:19:00 +0200 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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))