Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agent/skills/pr-checklist/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,5 @@ Add a changelog fragment under `.nextchanges/` when your change is user-visible.
**How to add:**
- Create `.nextchanges/<section>/<name>.md`, picking the section folder that fits: `cli`, `bundles`, `dependency-updates`, `notable-changes`, or `api-changes`. `<name>` is arbitrary (a feature name or your PR number) — just keep it unique.
- Write one or two sentences in user-facing language, no Jira links. The leading `* ` is optional. Match the voice and tense of existing changelog entries.
- A PR link is optional: write `(#NNNN)` (with NNNN being the PR number) in the text and it's expanded to a full link automatically.
- 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.
121 changes: 121 additions & 0 deletions .github/workflows/nextchanges-pr-link.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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 `(#<pr>)` 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:
# 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
# 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"
12 changes: 8 additions & 4 deletions .nextchanges/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ type the path (e.g. `.nextchanges/cli/quickstart.md`), write a sentence, commit.
- `<name>` is arbitrary — a feature name (`quickstart.md`) or your PR number
(`5464.md`), whatever you like, as long as it's unique.
- The leading `* ` is optional.
- A PR link is optional. 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
Expand Down
122 changes: 122 additions & 0 deletions tools/add_pr_links.py
Original file line number Diff line number Diff line change
@@ -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 `(#<pr>)` 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()
Loading