From 329c028605934d4406a028525086cfb401d37000 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Wed, 5 Aug 2026 15:52:30 +0200 Subject: [PATCH 1/7] Automatically add PR link to nextchanges fragments during the PR that adds them --- .agent/skills/pr-checklist/SKILL.md | 2 +- .github/workflows/nextchanges-pr-link.yml | 116 ++++++++++++++++++++ .nextchanges/README.md | 12 ++- tools/add_pr_links.py | 122 ++++++++++++++++++++++ 4 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/nextchanges-pr-link.yml create mode 100755 tools/add_pr_links.py diff --git a/.agent/skills/pr-checklist/SKILL.md b/.agent/skills/pr-checklist/SKILL.md index 7da1560a156..d79f94e8a45 100644 --- a/.agent/skills/pr-checklist/SKILL.md +++ b/.agent/skills/pr-checklist/SKILL.md @@ -71,5 +71,5 @@ 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. +- You don't need a PR link: the `nextchanges PR link` workflow adds one to each fragment the PR adds and pushes it onto the branch (skipping fork PRs, and any entry that already mentions a `#NNNN`). To point at a different PR or an issue, write `(#NNNN)` yourself; it's expanded to a full link automatically. - See `.nextchanges/README.md` for details. diff --git a/.github/workflows/nextchanges-pr-link.yml b/.github/workflows/nextchanges-pr-link.yml new file mode 100644 index 00000000000..0605e134f93 --- /dev/null +++ b/.github/workflows/nextchanges-pr-link.yml @@ -0,0 +1,116 @@ +name: nextchanges PR link + +# A changelog fragment may reference the PR it came from. The reference is +# optional (see .nextchanges/README.md) and easy to forget, so for every +# fragment a PR *adds* this appends `(#)` where none exists yet, expands it +# to a markdown link, and pushes the result onto the PR branch. +# +# `task links` (part of `task checks`) would otherwise fail the `lint` check on +# an unexpanded `(#1234)`, so the expansion has to happen here rather than being +# left to the release, which does not expand links either (see +# render_nextchanges in internal/genkit/release_tagging.py). +on: + pull_request: + types: [opened, reopened, synchronize] + paths: + - ".nextchanges/*/*.md" + +# One run per PR: a second run pushing concurrently would commit onto a stale head. +concurrency: + group: nextchanges-pr-link-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + add-link: + runs-on: ubuntu-latest + + # Forks: the head branch lives in the contributor's repository and is not + # ours to push to, so those PRs are left to review. The label is the escape + # hatch for an author who wants the fragment left exactly as written + # (mirrors 'override-changelog-guard' in changelog-guard.yml). + if: >- + !github.event.pull_request.head.repo.fork && + !contains(github.event.pull_request.labels.*.name, 'skip-nextchanges-pr-link') + + steps: + # Fetch with the default GITHUB_TOKEN, and persist-credentials: false so + # nothing is left in .git/config. The steps below execute tools/*.py from + # the PR branch, so no push credential may be reachable while they run; + # DECO_GITHUB_TOKEN is therefore scoped to the final step, which runs no + # code from the PR. + - name: Checkout PR branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.head_ref }} + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + version: "0.8.9" + + # Ask the API which files this PR adds rather than diffing locally: it is + # the same notion of "added" GitHub shows in the diff (against the merge + # base) and needs no full-history checkout. + # The file list is kept in RUNNER_TEMP so it never shows up in the working + # tree that the commit below is built from. + - name: List fragments added by this PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: | + gh api --paginate "repos/$REPO/pulls/$PR/files" \ + --jq '.[] | select(.status == "added") | .filename' > "$RUNNER_TEMP/all_added.txt" + # Section fragments only: not the section READMEs, not + # .nextchanges/version, and nothing at another depth (which + # validate_nextchanges.py rejects anyway). + grep -E '^\.nextchanges/[^/]+/[^/]+\.md$' "$RUNNER_TEMP/all_added.txt" \ + | grep -v '/README\.md$' > "$RUNNER_TEMP/added.txt" || true + cat "$RUNNER_TEMP/added.txt" + + - name: Add missing PR references + env: + PR: ${{ github.event.pull_request.number }} + run: | + if [ ! -s "$RUNNER_TEMP/added.txt" ]; then + echo "This PR adds no changelog fragments; nothing to do." + exit 0 + fi + # -d '\n': split the list on newlines only, so a fragment whose name + # contains a space stays one path. `--`: so a name that looks like a + # flag is still treated as a path. + xargs -d '\n' uv run tools/add_pr_links.py --pr "$PR" -- < "$RUNNER_TEMP/added.txt" + # Expand the `(#1234)` just added into the canonical markdown link, + # using the same script the `links` task runs. + xargs -d '\n' uv run tools/update_github_links.py -- < "$RUNNER_TEMP/added.txt" + + - name: Commit + id: commit + run: | + if git diff --quiet; then + echo "Every added fragment already references a PR." + exit 0 + fi + git diff + git config user.name "eng-dev-ecosystem-bot" + git config user.email "eng-dev-ecosystem-bot@users.noreply.github.com" + git commit -am "Add PR link to changelog fragments" + echo "pushable=true" >> "$GITHUB_OUTPUT" + + # The only step holding a push credential, and the only one that runs no + # code from the PR branch. DECO_GITHUB_TOKEN is the eng-dev-ecosystem-bot + # PAT: a push authenticated with the default GITHUB_TOKEN does not fire the + # `synchronize` event, so required checks would never run on the commit + # above and the PR could not enter the merge queue. + - name: Push + if: steps.commit.outputs.pushable == 'true' + env: + TOKEN: ${{ secrets.DECO_GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.head_ref }} + run: |- + git push "https://x-access-token:$TOKEN@github.com/$REPO" "HEAD:refs/heads/$BRANCH" diff --git a/.nextchanges/README.md b/.nextchanges/README.md index f124c39ca64..aca4ffef158 100644 --- a/.nextchanges/README.md +++ b/.nextchanges/README.md @@ -19,10 +19,14 @@ type the path (e.g. `.nextchanges/cli/quickstart.md`), write a sentence, commit. - `` is arbitrary — a feature name (`quickstart.md`) or your PR number (`5464.md`), whatever you like, as long as it's unique. - The leading `* ` is optional. -- A PR link is optional. 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. +- You don't need to add a PR link. The `nextchanges PR link` workflow appends + one to every fragment your PR adds that doesn't have a reference yet, and + pushes the result onto your branch. It skips an entry that already mentions + any `#NNNN`, so write the reference yourself to point at a different PR or an + issue. Fork PRs are not pushed to, so add the link by hand there. +- To add or expand a link locally, write `(#5464)` and run `task links` (or + `task checks`); 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. ### Sections diff --git a/tools/add_pr_links.py b/tools/add_pr_links.py new file mode 100755 index 00000000000..384d7d16442 --- /dev/null +++ b/tools/add_pr_links.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.12" +# /// +"""Add a PR reference to changelog fragments that lack one. + +A PR reference in a `.nextchanges/` fragment is optional (see +`.nextchanges/README.md`) and easy to forget. The `nextchanges-pr-link` workflow +runs this over the fragments a PR *adds* and appends `(#)` to every entry +that has no reference yet. Expanding that into the canonical markdown link is +left to `tools/update_github_links.py`, which the workflow runs next. + +A fragment holds one entry per `* `/`- ` line, or a single entry when it has no +such marker (see `render_nextchanges` in `internal/genkit/release_tagging.py`, +which bullets the first line and passes continuation lines through). The +reference goes at the end of an entry, so an entry wrapped over several lines +gets it on the last line rather than mid-sentence. +""" + +import argparse +import pathlib +import re + +# A `* `/`- ` line starts a new entry; see the module docstring. +ENTRY_MARKER_RE = re.compile(r"^\s*[*-] ") + +# Any `#1234` counts as a reference, raw or already expanded into a link: the +# author pointed at a PR (or a related issue) themselves, so leave the entry +# alone. This is also what makes a re-run a no-op, so the workflow's own push +# cannot retrigger itself into a loop. +EXISTING_REF_RE = re.compile(r"#\d+") + + +def entry_ranges(lines): + """Return one ``(start, stop)`` line-index range per entry. + + >>> entry_ranges(["* first", "* second"]) + [(0, 1), (1, 2)] + >>> entry_ranges(["one entry", "wrapped over two lines"]) + [(0, 2)] + """ + starts = [i for i, line in enumerate(lines) if ENTRY_MARKER_RE.match(line)] + # No marker at all: the whole fragment is a single entry. + if not starts: + starts = [0] + stops = [*starts[1:], len(lines)] + return list(zip(starts, stops, strict=True)) + + +def append_reference(line, pr): + """Append ``(#pr)`` to one line, before its trailing period. + + Existing entries in CHANGELOG.md put the link inside the sentence rather + than after it, e.g. ``… on every run ([#6060](…)).`` + + >>> append_reference("Added the `databricks quickstart` command.", 1234) + 'Added the `databricks quickstart` command (#1234).' + >>> append_reference("* No trailing period", 1234) + '* No trailing period (#1234)' + """ + body = line.rstrip() + if body.endswith("."): + return f"{body[:-1]} (#{pr})." + return f"{body} (#{pr})" + + +def annotate_text(text, pr): + r"""Append ``(#pr)`` to every entry in a fragment that has no reference. + + >>> annotate_text("Added the `databricks quickstart` command.\n", 1234) + 'Added the `databricks quickstart` command (#1234).\n' + + Entries are annotated independently, and one that already references a PR + is left untouched: + + >>> annotate_text("* one\n* two ([#99](https://github.com/databricks/cli/pull/99))\n", 1234) + '* one (#1234)\n* two ([#99](https://github.com/databricks/cli/pull/99))\n' + + A wrapped entry gets the reference at its end, not mid-sentence: + + >>> annotate_text("A long entry that wraps\nover two lines.\n", 1234) + 'A long entry that wraps\nover two lines (#1234).\n' + """ + lines = text.split("\n") + for start, stop in entry_ranges(lines): + entry = lines[start:stop] + if EXISTING_REF_RE.search("\n".join(entry)): + continue + content = [i for i in range(start, stop) if lines[i].strip()] + if not content: + continue + lines[content[-1]] = append_reference(lines[content[-1]], pr) + return "\n".join(lines) + + +def process_file(path, pr): + """Process a single fragment. + + Returns True if the file was *modified*. + """ + original = path.read_text(encoding="utf-8") + updated = annotate_text(original, pr) + 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="Add a PR reference to changelog fragments that lack one.") + parser.add_argument("--pr", type=int, required=True, help="pull request number to reference") + parser.add_argument("files", nargs="+", help="fragment files to annotate") + args = parser.parse_args(argv) + + for file_path in args.files: + process_file(pathlib.Path(file_path), args.pr) + + +if __name__ == "__main__": + main() From 940c2a9f3010128c32a238dee2503a3b242768d6 Mon Sep 17 00:00:00 2001 From: Jan N Rose Date: Wed, 5 Aug 2026 17:01:43 +0200 Subject: [PATCH 2/7] Add dummy.md with request for a link --- .nextchanges/cli/dummy.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nextchanges/cli/dummy.md diff --git a/.nextchanges/cli/dummy.md b/.nextchanges/cli/dummy.md new file mode 100644 index 00000000000..945b2219076 --- /dev/null +++ b/.nextchanges/cli/dummy.md @@ -0,0 +1 @@ +Please add a link for me From 715a45458129d969ff88a4cae450689e88193f8d Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Wed, 5 Aug 2026 17:05:40 +0200 Subject: [PATCH 3/7] Run the nextchanges PR link workflow on the deco runner group The databricks org has an IP allow list that GitHub-hosted runners are not on, so `gh api` from ubuntu-latest failed with HTTP 403. Every other workflow in this repo that calls the API already runs on this group. Co-authored-by: Isaac --- .github/workflows/nextchanges-pr-link.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nextchanges-pr-link.yml b/.github/workflows/nextchanges-pr-link.yml index 0605e134f93..6d7fbc362b4 100644 --- a/.github/workflows/nextchanges-pr-link.yml +++ b/.github/workflows/nextchanges-pr-link.yml @@ -25,7 +25,12 @@ permissions: jobs: add-link: - runs-on: ubuntu-latest + # The databricks org has an IP allow list, and GitHub-hosted runners are not + # on it: `gh api` from ubuntu-latest fails with HTTP 403. Every workflow here + # that calls the API runs on this group for the same reason. + runs-on: + group: databricks-deco-testing-runner-group + labels: ubuntu-latest-deco # Forks: the head branch lives in the contributor's repository and is not # ours to push to, so those PRs are left to review. The label is the escape From 6db9a9f46ce2be66caa3397ac7501372d127c180 Mon Sep 17 00:00:00 2001 From: eng-dev-ecosystem-bot Date: Wed, 5 Aug 2026 15:06:15 +0000 Subject: [PATCH 4/7] Add PR link to changelog fragments --- .nextchanges/cli/dummy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nextchanges/cli/dummy.md b/.nextchanges/cli/dummy.md index 945b2219076..fd3602bff38 100644 --- a/.nextchanges/cli/dummy.md +++ b/.nextchanges/cli/dummy.md @@ -1 +1 @@ -Please add a link for me +Please add a link for me ([#6177](https://github.com/databricks/cli/pull/6177)) From 8eea07af62983ea60fd37718186cf7e8473318d2 Mon Sep 17 00:00:00 2001 From: Jan N Rose Date: Wed, 5 Aug 2026 17:09:16 +0200 Subject: [PATCH 5/7] Delete .nextchanges/cli/dummy.md --- .nextchanges/cli/dummy.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .nextchanges/cli/dummy.md diff --git a/.nextchanges/cli/dummy.md b/.nextchanges/cli/dummy.md deleted file mode 100644 index fd3602bff38..00000000000 --- a/.nextchanges/cli/dummy.md +++ /dev/null @@ -1 +0,0 @@ -Please add a link for me ([#6177](https://github.com/databricks/cli/pull/6177)) From 29e883e9f4e0803f9b902ff972a8cd37cc4f3ea3 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Mon, 10 Aug 2026 11:29:05 +0200 Subject: [PATCH 6/7] Skip the nextchanges PR link workflow on draft PRs Gate the job on !draft (mirroring maintainer-approval.yml) and add the ready_for_review trigger so a fragment added while the PR was a draft gets linked as soon as it's marked ready. Co-authored-by: Isaac --- .github/workflows/nextchanges-pr-link.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/nextchanges-pr-link.yml b/.github/workflows/nextchanges-pr-link.yml index 6d7fbc362b4..ad4ece642e2 100644 --- a/.github/workflows/nextchanges-pr-link.yml +++ b/.github/workflows/nextchanges-pr-link.yml @@ -11,7 +11,9 @@ name: nextchanges PR link # render_nextchanges in internal/genkit/release_tagging.py). on: pull_request: - types: [opened, reopened, synchronize] + # ready_for_review so a fragment added while the PR was a draft gets linked + # as soon as it's marked ready, rather than waiting for the next push. + types: [opened, reopened, synchronize, ready_for_review] paths: - ".nextchanges/*/*.md" @@ -32,11 +34,14 @@ jobs: group: databricks-deco-testing-runner-group labels: ubuntu-latest-deco - # Forks: the head branch lives in the contributor's repository and is not - # ours to push to, so those PRs are left to review. The label is the escape - # hatch for an author who wants the fragment left exactly as written - # (mirrors 'override-changelog-guard' in changelog-guard.yml). + # Drafts are left alone until they're marked ready (mirrors the + # !draft gate in maintainer-approval.yml). Forks: the head branch lives in + # the contributor's repository and is not ours to push to, so those PRs are + # left to review. The label is the escape hatch for an author who wants the + # fragment left exactly as written (mirrors 'override-changelog-guard' in + # changelog-guard.yml). if: >- + !github.event.pull_request.draft && !github.event.pull_request.head.repo.fork && !contains(github.event.pull_request.labels.*.name, 'skip-nextchanges-pr-link') From fa674e2d334366dbd1658918f8f002a931160806 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Mon, 10 Aug 2026 11:41:50 +0200 Subject: [PATCH 7/7] add_pr_links: append the PR reference at the end of the entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the appended reference to the very end of an entry (after any trailing period) instead of before the period, and only skip an entry that already *ends* with a PR reference. A prior PR or issue cited in the body (e.g. "Fixes #6030: …") no longer blocks appending the PR link at the end. Co-authored-by: Isaac --- tools/add_pr_links.py | 53 +++++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/tools/add_pr_links.py b/tools/add_pr_links.py index 384d7d16442..18faf603219 100755 --- a/tools/add_pr_links.py +++ b/tools/add_pr_links.py @@ -13,8 +13,8 @@ A fragment holds one entry per `* `/`- ` line, or a single entry when it has no such marker (see `render_nextchanges` in `internal/genkit/release_tagging.py`, which bullets the first line and passes continuation lines through). The -reference goes at the end of an entry, so an entry wrapped over several lines -gets it on the last line rather than mid-sentence. +reference is appended at the very end of an entry, so an entry wrapped over +several lines gets it on the last line. """ import argparse @@ -24,11 +24,15 @@ # A `* `/`- ` line starts a new entry; see the module docstring. ENTRY_MARKER_RE = re.compile(r"^\s*[*-] ") -# Any `#1234` counts as a reference, raw or already expanded into a link: the -# author pointed at a PR (or a related issue) themselves, so leave the entry -# alone. This is also what makes a re-run a no-op, so the workflow's own push -# cannot retrigger itself into a loop. -EXISTING_REF_RE = re.compile(r"#\d+") +# A reference already at the *end* of an entry, either the raw `(#1234)` this +# tool writes or the `([#1234](…/pull/1234))` link update_github_links.py +# expands it to. Only a trailing reference makes us leave the entry alone; a +# `#1234` earlier in the body (e.g. "Fixes #6030: …") does not, so the PR is +# still appended at the end. Matching both forms is also what makes a re-run a +# no-op, so the workflow's own push cannot retrigger itself into a loop. A pull +# *link* is required rather than any `([#…](…))`, so an issue link at the end +# doesn't block the PR reference; a trailing period is tolerated. +END_REF_RE = re.compile(r"(?:\(#\d+\)|\(\[#\d+\]\([^)]*/pull/\d+\)\))\s*\.?\s*$") def entry_ranges(lines): @@ -48,48 +52,49 @@ def entry_ranges(lines): def append_reference(line, pr): - """Append ``(#pr)`` to one line, before its trailing period. - - Existing entries in CHANGELOG.md put the link inside the sentence rather - than after it, e.g. ``… on every run ([#6060](…)).`` + """Append ``(#pr)`` to the very end of one line. >>> append_reference("Added the `databricks quickstart` command.", 1234) - 'Added the `databricks quickstart` command (#1234).' + 'Added the `databricks quickstart` command. (#1234)' >>> append_reference("* No trailing period", 1234) '* No trailing period (#1234)' """ - body = line.rstrip() - if body.endswith("."): - return f"{body[:-1]} (#{pr})." - return f"{body} (#{pr})" + return f"{line.rstrip()} (#{pr})" def annotate_text(text, pr): r"""Append ``(#pr)`` to every entry in a fragment that has no reference. >>> annotate_text("Added the `databricks quickstart` command.\n", 1234) - 'Added the `databricks quickstart` command (#1234).\n' + 'Added the `databricks quickstart` command. (#1234)\n' - Entries are annotated independently, and one that already references a PR - is left untouched: + Entries are annotated independently, and one that already ends with a PR + reference is left untouched: >>> annotate_text("* one\n* two ([#99](https://github.com/databricks/cli/pull/99))\n", 1234) '* one (#1234)\n* two ([#99](https://github.com/databricks/cli/pull/99))\n' + A reference to a prior PR or issue in the body does not count — the PR is + still appended at the end: + + >>> annotate_text("Fixes [#6030](https://github.com/databricks/cli/issues/6030): a bug.\n", 1234) + 'Fixes [#6030](https://github.com/databricks/cli/issues/6030): a bug. (#1234)\n' + A wrapped entry gets the reference at its end, not mid-sentence: >>> annotate_text("A long entry that wraps\nover two lines.\n", 1234) - 'A long entry that wraps\nover two lines (#1234).\n' + 'A long entry that wraps\nover two lines. (#1234)\n' """ lines = text.split("\n") for start, stop in entry_ranges(lines): - entry = lines[start:stop] - if EXISTING_REF_RE.search("\n".join(entry)): - continue content = [i for i in range(start, stop) if lines[i].strip()] if not content: continue - lines[content[-1]] = append_reference(lines[content[-1]], pr) + last = lines[content[-1]] + # Only a reference at the end of the entry blocks appending one. + if END_REF_RE.search(last): + continue + lines[content[-1]] = append_reference(last, pr) return "\n".join(lines)