Przeglądaj źródła

Closes #23518: Add skills for preparing and publishing a NetBox release (#23557)

Jeremy Stretch 1 dzień temu
rodzic
commit
90e1ac6812

+ 164 - 0
.claude/skills/draft-release-notes/SKILL.md

@@ -0,0 +1,164 @@
+---
+name: draft-release-notes
+description: Draft the release notes for a NetBox core (this repository, not a plugin) patch release from its GitHub milestone, audit the commits since the previous release for noteworthy changes missing from the milestone, and render the notes as the release PR or GitHub release body. Use when the user asks to draft, preview, or update the release notes or changelog for an upcoming NetBox release, to check a release milestone against the commit log, or when the prepare-release skill reaches its release notes step.
+---
+
+# Draft NetBox Release Notes
+
+Each patch release has a GitHub milestone named for it (e.g. `v4.7.5`), and every issue closed as completed must carry one (`.github/workflows/enforce-milestone.yml` reopens any that don't). The milestone is therefore the starting point for the release notes. But it only holds the issues someone assigned to it, so this skill also audits every commit since the previous release for changes the milestone misses. It then turns the issues into a section of `docs/release-notes/version-X.Y.md`.
+
+The notes are published text. Present the draft to the user for review and editing; never commit it unreviewed.
+
+## 1. Gather the Milestone
+
+```bash
+gh issue list --milestone vX.Y.Z --state all --limit 200 \
+  --json number,title,state,stateReason,labels,url
+```
+
+- Warn about any issue still open. The release should not proceed until it is closed or moved to another milestone.
+- Skip issues closed as `NOT_PLANNED` or `DUPLICATE`; only `COMPLETED` issues are candidates.
+
+## 2. Audit the Commits Against the Milestone
+
+A change whose issue was never assigned to the milestone, or was assigned to the wrong one, is invisible to step 1, even though it shipped. Check every commit since the previous release:
+
+```bash
+git fetch origin main --tags
+python3 .claude/skills/draft-release-notes/audit_milestone.py vX.Y.Z
+```
+
+The script follows each first-parent commit on `main` since the previous release tag to its PR, and to the issues that the PR closes or the commit subject references (`Fixes #N`). It skips release commits, translation updates, and updates to the list of vouched contributors (`.github/VOUCHED.td`), and prints two tables:
+
+- **Reverts, and commits not linked to the milestone**, with each linked issue's milestone and `type:` label, and the areas of the codebase changed (`app`, `dependencies`, `install`, `schema`, `docs`, `tests`, `tooling`). Reverts are always listed, even when their issue is in the milestone. A commit marked "not on GitHub" hasn't been pushed, so its PR and issues can't be looked up.
+- **Milestone issues not linked to any commit**
+
+To audit a past release, pass `--base vPREV --head vX.Y.Z`.
+
+Review every row; the script only finds the gaps, and deciding what's noteworthy is up to you:
+
+- **Linked issue with no milestone, or a different one**: the change shipped in this release, so the issue belongs in this milestone. Propose moving it (`gh issue edit N --milestone vX.Y.Z`). Once the user confirms, it goes through the remaining steps like any other issue.
+- **No linked issue**: read the PR and the diff (`gh pr view P --json title,body,files`, `git show --stat <sha>`). Changes confined to `tooling`, `tests`, or `docs` are rarely noteworthy. For any other change, decide whether users would notice it: a bug fix, a change in behavior or in the API, a new feature, a performance change, or a dependency upgrade. If they would, search for an issue the PR should have referenced (`gh search issues --repo netbox-community/netbox "<keywords>"`) and propose assigning it to the milestone. If no issue exists, tell the user; every entry links to an issue, and opening one is up to them.
+- **A commit and its revert** cancel out, and neither belongs in the notes. For example, in v4.7.1, the fix for #23097 (which had no milestone) was reverted before the release.
+- **Milestone issues not linked to any commit**: confirm that each one actually shipped. It may have been fixed by a commit which didn't reference it (check `git log` for related changes), or closed without a code change (e.g. an investigation, such as #23362 in v4.7.4). Or it may have been fixed on `feature` instead (`git log origin/feature --oneline --grep "#N"`), in which case it belongs in the next minor release's milestone, and not in these notes.
+
+Report a verdict for every row: noteworthy and how to handle it, or not noteworthy and why. Make no milestone changes without the user's confirmation.
+
+## 3. Sort Into Sections
+
+Assign each issue by its `type:` label, then sort each section by ascending issue number:
+
+| Label | Section |
+|---|---|
+| `type: feature` | `### Enhancements` |
+| `type: performance` | `### Performance Improvements` |
+| `type: bug` | `### Bug Fixes` |
+| `type: housekeeping`, `type: documentation`, `type: translation` | Omitted |
+| `type: deprecation`, `type: removal`, or no `type:` label | Flag for the user |
+
+Omit any section that ends up empty.
+
+## 4. Write Each Entry
+
+For each included issue, read the issue body and the PR(s) that closed it:
+
+```bash
+gh issue view N --json title,body,closedByPullRequestsReferences
+gh pr view P --json title,body,files
+```
+
+Use parallel subagents to read the issues when there are more than a dozen or so, asking each one for a proposed entry plus a one-line note on what changed.
+
+Each entry is one line:
+
+```markdown
+* [#N](https://github.com/netbox-community/netbox/issues/N) - <description>
+```
+
+Write the description for the end user, not from the issue title or the code:
+
+- Describe the effect of the change, so that it makes sense without reading the issue.
+- Start with a verb: Add, Fix, Prevent, Restrict, Support, Enable, Correct, Avoid, Display.
+- Use "Fix exception when ..." for crashes and HTTP 500 errors.
+- Include terms users will search for: exception names, field and parameter names in backticks, "REST API", "GraphQL", the UI view involved.
+- No trailing period.
+
+Examples from v4.7.4 (issue title → entry):
+
+| Issue title | Entry |
+|---|---|
+| Add device from location page has wrong argument | Pre-populate the location when adding a non-racked device from a location's view |
+| Every non-bulk change enqueues its own Search cache update job instead of coalescing — RQ queue flood | Coalesce search cache updates rather than enqueuing a background job for each change |
+| Bookmark, journal entry and related API endpoints fail for objects without a REST serializer | Fix exception when listing bookmarks, journal entries, and other objects assigned to objects which have no REST API serializer |
+| Please add several more basic widely used trunk cable profiles ... | Add 8C1P, 12C1P, and 24C1P trunk cable profiles |
+
+## 5. Flag Judgment Calls
+
+Recommend omitting, but let the user decide:
+
+- Changes with no user-visible effect, such as internal helpers, tests, or CI (e.g. #23432, a TypeScript type guard fix, was left out of v4.7.4).
+- Dependency upgrades filed as features (e.g. #23383, an upgrade of `strawberry-graphql-django`).
+- Bugs introduced and fixed within the same release cycle, which no user ever saw.
+
+Present every issue in a table grouped as **included**, **omitted**, and **needs your decision**, with a short reason for each omission.
+
+## 6. Add Warnings
+
+Place a warning above the first section for anything a user or operator must act on: an API or GraphQL change they need to adapt to, a dependency upgrade requiring action (the prepare-release skill passes these in), or a new minimum version. State what to do, concretely:
+
+```markdown
+!!! warning "GraphQL Circuit Status Values"
+    The GraphQL enum for circuit and virtual circuit status ... must be updated to use the prefixed form (e.g. `{exact: STATUS_ACTIVE}`). See [#23469](https://github.com/netbox-community/netbox/issues/23469) for details.
+```
+
+Use absolute URLs for links; relative links to other docs pages break in the PR and release bodies.
+
+## 7. Output
+
+The section goes at the top of `docs/release-notes/version-X.Y.md`, directly below the `# NetBox vX.Y` heading, and is separated from the previous release by `---`:
+
+```markdown
+# NetBox vX.Y
+
+## vX.Y.Z (YYYY-MM-DD)
+
+!!! warning "..."
+    ...
+
+### Enhancements
+
+* ...
+
+### Bug Fixes
+
+* ...
+
+---
+
+## vX.Y.(Z-1) (...)
+```
+
+The date is the planned release date (the milestone's due date, and `published` in `netbox/release.yaml`).
+
+## Rendering the PR and Release Bodies
+
+Once the section is in the docs file, render the GitHub bodies from it rather than by hand:
+
+```bash
+python3 .claude/skills/draft-release-notes/render_release_notes.py X.Y.Z --format pr       # release PR body
+python3 .claude/skills/draft-release-notes/render_release_notes.py X.Y.Z --format release  # GitHub release body
+```
+
+The script drops the `## vX.Y.Z` heading and converts admonitions to GitHub alerts (`> [!WARNING]`, dropping the title). The `release` format also promotes `###` headings to `##`, as the release checklist specifies.
+
+## Why These Choices
+
+- **Start from the milestone, then audit the commits.** The milestone provides each change's issue, type label, and context, which commit subjects often lack (e.g. `fix(core): ...`). But the milestone workflow only catches issues closed with no milestone, not issues given the wrong one, or never linked to the fix at all. Only the commit log shows everything that shipped.
+- **The audit is scripted, the verdicts aren't.** Mapping some 50 commits to PRs and issues takes one batched GraphQL query in the script, but well over a hundred separate `gh` calls by hand. Whether a change is noteworthy depends on reading it.
+- **Entries are rewritten, not copied.** Issue titles describe the reporter's symptom; the notes must describe the change for someone who never saw the issue.
+- **Rendering is scripted.** The docs file is the single source of truth, so the PR body and the release body (often created in a later session) can't drift from it or from each other.
+
+## References
+
+- [`docs/development/release-checklist.md`](../../../docs/development/release-checklist.md): Update Version and Changelog
+- [`docs/release-notes/`](../../../docs/release-notes/): Past release notes, for style

+ 168 - 0
.claude/skills/draft-release-notes/audit_milestone.py

@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+"""List the commits since the previous release which aren't linked to an issue in the release's milestone."""
+
+import argparse
+import json
+import re
+import subprocess
+
+OWNER, NAME = 'netbox-community', 'netbox'
+BATCH_SIZE = 50
+ISSUE_REF = re.compile(r'\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):? #(\d+)', re.IGNORECASE)
+ISSUE_FIELDS = 'number title milestone { title } labels(first: 20) { nodes { name } }'
+
+
+def run(*args):
+    return subprocess.run(args, capture_output=True, text=True, check=True).stdout
+
+
+def graphql(fields):
+    """Query the repository. Fields which can't be resolved (e.g. an issue number which is a PR) return None."""
+    query = f'{{ repository(owner: "{OWNER}", name: "{NAME}") {{ {" ".join(fields)} }} }}'
+    result = subprocess.run(['gh', 'api', 'graphql', '-f', f'query={query}'], capture_output=True, text=True)
+    try:
+        response = json.loads(result.stdout)
+    except json.JSONDecodeError:
+        raise SystemExit(result.stderr) from None
+    if not response.get('data') or any(error.get('type') != 'NOT_FOUND' for error in response.get('errors', [])):
+        raise SystemExit(result.stderr)
+    return response['data']['repository']
+
+
+def get_commits(base, head):
+    """Return the SHA, subject, and changed paths (relative to the first parent) of each first-parent commit."""
+    log = run(
+        'git', 'log', '--first-parent', '--diff-merges=first-parent', '--reverse', '--name-only',
+        '--format=%x00%H%x09%s', f'{base}..{head}',
+    )
+    commits = []
+    for entry in log.split('\0')[1:]:
+        header, *paths = entry.splitlines()
+        sha, subject = header.split('\t', 1)
+        commits.append((sha, subject, [path for path in paths if path]))
+    return commits
+
+
+def get_areas(paths):
+    """Return the areas of the codebase to which the given paths belong."""
+    areas = set()
+    for path in paths:
+        if path.startswith('netbox/translations/'):
+            areas.add('translations')
+        elif '/tests/' in path:
+            areas.add('tests')
+        elif path.startswith('netbox/'):
+            areas.add('app')
+        elif path in ('base_requirements.txt', 'requirements.txt', 'pyproject.toml'):
+            areas.add('dependencies')
+        elif path in ('contrib/openapi.json', 'contrib/generated_schema.json'):
+            areas.add('schema')
+        elif path == 'upgrade.sh' or path.startswith('contrib/'):
+            areas.add('install')
+        elif path.startswith('docs/') or path in ('mkdocs.yml', 'README.md'):
+            areas.add('docs')
+        else:
+            areas.add('tooling')
+    return areas
+
+
+def get_links(commits):
+    """Map each commit to its pull requests and to the issues it references or its PRs close."""
+    links = {}
+    for i in range(0, len(commits), BATCH_SIZE):
+        batch = commits[i:i + BATCH_SIZE]
+        data = graphql(
+            f'c{j}: object(oid: "{sha}") {{ ... on Commit {{ associatedPullRequests(first: 3) {{ nodes {{ '
+            f'number closingIssuesReferences(first: 10) {{ nodes {{ {ISSUE_FIELDS} }} }} }} }} }} }}'
+            for j, (sha, _, _) in enumerate(batch)
+        )
+        for j, (sha, _, _) in enumerate(batch):
+            # A commit which hasn't been pushed resolves to None
+            if (commit := data[f'c{j}']) is None:
+                links[sha] = {'prs': None, 'issues': {}}
+                continue
+            prs = commit['associatedPullRequests']['nodes']
+            issues = {issue['number']: issue for pr in prs for issue in pr['closingIssuesReferences']['nodes']}
+            links[sha] = {'prs': [pr['number'] for pr in prs], 'issues': issues}
+
+    # Issues referenced only in a commit subject (e.g. "Fixes #123: ...")
+    missing = {
+        int(number) for sha, subject, _ in commits
+        for number in ISSUE_REF.findall(subject) if int(number) not in links[sha]['issues']
+    }
+    if missing:
+        data = graphql(f'i{number}: issue(number: {number}) {{ {ISSUE_FIELDS} }}' for number in missing)
+        for sha, subject, _ in commits:
+            for number in map(int, ISSUE_REF.findall(subject)):
+                if issue := data.get(f'i{number}'):
+                    links[sha]['issues'].setdefault(number, issue)
+    return links
+
+
+def escape(text):
+    """Escape pipes for use in a Markdown table cell."""
+    return text.replace('|', r'\|')
+
+
+def describe_issue(issue):
+    milestone = issue['milestone']['title'] if issue['milestone'] else 'no milestone'
+    types = [label['name'] for label in issue['labels']['nodes'] if label['name'].startswith('type:')]
+    return f"#{issue['number']} ({', '.join([milestone, *types])})"
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('milestone', help='Release milestone, e.g. v4.7.5')
+    parser.add_argument('--head', default='origin/main', help='Last commit of the release (default: origin/main)')
+    parser.add_argument('--base', help='Previous release tag (default: the latest tag before --head)')
+    args = parser.parse_args()
+
+    base = args.base or run('git', 'describe', '--tags', '--abbrev=0', f'{args.head}^').strip()
+    commits = get_commits(base, args.head)
+    links = get_links(commits)
+
+    uncovered, routine, linked_issues = [], 0, set()
+    for sha, subject, paths in commits:
+        issues = links[sha]['issues']
+        linked_issues.update(issues)
+        areas = get_areas(paths)
+        in_milestone = any(
+            issue['milestone'] and issue['milestone']['title'] == args.milestone for issue in issues.values()
+        )
+        if subject.startswith('Release v') or areas == {'translations'} or set(paths) == {'.github/VOUCHED.td'}:
+            routine += 1
+        # Always list reverts, which cancel out the commit they revert
+        elif subject.startswith('Revert ') or not in_milestone:
+            uncovered.append((sha, subject, links[sha]['prs'], issues, areas))
+
+    print(f'# Milestone audit: {args.milestone} ({base}..{args.head})\n')
+    print(
+        f'{len(commits)} commits: {len(commits) - len(uncovered) - routine} linked to the milestone, '
+        f'{routine} routine (releases, translations, vouched contributors), '
+        f'{len(uncovered)} reverts or not linked to the milestone.\n'
+    )
+    print('## Reverts, and commits not linked to the milestone\n')
+    print('| Commit | Subject | PRs | Linked issues | Areas changed |')
+    print('|---|---|---|---|---|')
+    for sha, subject, prs, issues, areas in uncovered:
+        prs = 'not on GitHub' if prs is None else ', '.join(f'#{pr}' for pr in prs) or '-'
+        issues = ', '.join(map(describe_issue, issues.values())) or '-'
+        print(f"| {sha[:9]} | {escape(subject)} | {prs} | {issues} | {', '.join(sorted(areas))} |")
+
+    milestone_issues = json.loads(run(
+        'gh', 'issue', 'list', '--repo', f'{OWNER}/{NAME}', '--milestone', args.milestone,
+        '--state', 'closed', '--limit', '500', '--json', 'number,title,stateReason',
+    ))
+    unmatched = [
+        issue for issue in milestone_issues
+        if issue['stateReason'] == 'COMPLETED' and issue['number'] not in linked_issues
+    ]
+    print('\n## Milestone issues not linked to any commit\n')
+    print('| Issue | Title |')
+    print('|---|---|')
+    for issue in sorted(unmatched, key=lambda issue: issue['number']):
+        print(f"| #{issue['number']} | {escape(issue['title'])} |")
+
+
+if __name__ == '__main__':
+    main()

+ 83 - 0
.claude/skills/draft-release-notes/render_release_notes.py

@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""Render a release's notes from docs/release-notes/ as the body of its GitHub PR or release."""
+
+import argparse
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+
+# MkDocs admonition type (including Material's aliases) -> GitHub alert type
+ALERTS = {
+    'attention': 'WARNING',
+    'bug': 'CAUTION',
+    'caution': 'WARNING',
+    'danger': 'CAUTION',
+    'error': 'CAUTION',
+    'failure': 'CAUTION',
+    'hint': 'TIP',
+    'important': 'IMPORTANT',
+    'info': 'NOTE',
+    'note': 'NOTE',
+    'tip': 'TIP',
+    'warning': 'WARNING',
+}
+
+
+def extract_section(text, version):
+    """Return the lines of the section for the given version, excluding its heading."""
+    lines = text.splitlines()
+    heading = re.compile(rf'^## v{re.escape(version)} \(')
+    start = next((i for i, line in enumerate(lines) if heading.match(line)), None)
+    if start is None:
+        raise SystemExit(f'No section for v{version} found')
+    section = []
+    for line in lines[start + 1:]:
+        if line == '---' or line.startswith('## '):
+            break
+        section.append(line)
+    return section
+
+
+def convert_admonitions(lines):
+    """Convert MkDocs admonitions to GitHub alerts. GitHub alerts have no title, so it is dropped."""
+    output = []
+    i = 0
+    while i < len(lines):
+        if not (match := re.match(r'^!!! (\w+)', lines[i])):
+            output.append(lines[i])
+            i += 1
+            continue
+        output.append(f'> [!{ALERTS.get(match[1], "NOTE")}]')
+        i += 1
+        # The body is every following line indented four spaces, including blank lines between paragraphs
+        end = i
+        while end < len(lines) and (lines[end].startswith('    ') or not lines[end].strip()):
+            end += 1
+        while end > i and not lines[end - 1].strip():
+            end -= 1
+        output.extend(f'> {line[4:]}' if line.strip() else '>' for line in lines[i:end])
+        i = end
+    return output
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('version', help='Release version, e.g. 4.7.5')
+    parser.add_argument('--format', choices=('pr', 'release'), default='pr')
+    args = parser.parse_args()
+
+    version = args.version.removeprefix('v')
+    major, minor = version.split('.')[:2]
+    text = (ROOT / 'docs' / 'release-notes' / f'version-{major}.{minor}.md').read_text()
+
+    lines = convert_admonitions(extract_section(text, version))
+    if args.format == 'release':
+        # Promote each heading one level (### -> ##)
+        lines = [re.sub(r'^#(#+ )', r'\1', line) for line in lines]
+
+    print('\n'.join(lines).strip())
+
+
+if __name__ == '__main__':
+    main()

+ 283 - 0
.claude/skills/prepare-release/SKILL.md

@@ -0,0 +1,283 @@
+---
+name: prepare-release
+description: Prepare a NetBox core (this repository, not a plugin) patch release vX.Y.Z up to an open release PR — upgrade Python, UI, and dev dependencies, pull translations, bump release.yaml, draft the release notes, regenerate the schemas, validate, and open the "Release vX.Y.Z" pull request. Use when a maintainer asks to prepare, cut, or start a NetBox release. Publishing after the PR merges is the publish-release skill.
+---
+
+# Prepare a NetBox Patch Release
+
+This skill automates the [Patch Releases](../../../docs/development/release-checklist.md#patch-releases) section of the release checklist, up to opening the release PR. The checklist remains the authoritative process; if the two disagree, follow the checklist and point out the discrepancy.
+
+**Ground rules**
+
+- **The release PR contains only release changes.** Never fix application code, tests, or lint on the release branch. If an upgrade breaks something, hold it back (step 3) and report it; the fix belongs in a separate PR merged before the release.
+- **Confirm before anything outward-facing**: pushing, opening the PR, or editing milestones and issues.
+- **Report problems early.** Each step lists what to check; stop and ask whenever something unexpected turns up rather than working around it.
+- Run all commands from the repository root, with the NetBox venv active, unless stated otherwise. Write temporary files to the session scratchpad (`$SCRATCH` below), not `/tmp`.
+
+## 0. Inputs
+
+- **Version**: default to a patch bump of `version` in `netbox/release.yaml`. Confirm that an open milestone with the same name exists:
+
+    ```bash
+    gh api 'repos/netbox-community/netbox/milestones?state=open&per_page=100' \
+      --jq '.[] | select(.title == "vX.Y.Z") | {number, due_on, open_issues, closed_issues}'
+    ```
+
+- **Release date**: default to the milestone's `due_on` date.
+
+Confirm both with the user before continuing.
+
+**This skill covers patch releases only.** If the version is `X.Y.0`, or carries a designation such as `beta1` or `rc1`, stop: minor and pre-releases need the manual steps in the checklist's Minor Version Releases section (new install, upgrade path tests, merging `feature`) and target different branches.
+
+## 1. Preflight
+
+Run every check, then report all failures together. All are read-only.
+
+```bash
+git fetch origin --tags
+git status --porcelain                                  # must be empty
+git rev-parse --abbrev-ref HEAD                         # must be main
+git rev-parse main origin/main                          # must match
+git remote get-url origin                               # must be netbox-community/netbox
+gh repo set-default --view                              # must be netbox-community/netbox
+gh api repos/netbox-community/netbox --jq .permissions.push   # must be true
+gh api orgs/netboxlabs/members/$(gh api user -q .login) --silent   # must succeed
+gh run list --workflow ci.yml --branch main --limit 3 --json headSha,status,conclusion
+pgrep -af '[m]anage.py test'                            # must find no other test run
+```
+
+- **gh default repository**: the `gh` commands in this skill and in `draft-release-notes` rely on it. If it's unset or points at a fork, ask the user to run `gh repo set-default netbox-community/netbox`.
+- **CI**: the most recent completed run on `main` must have succeeded. CI skips commits that touch only translations, `contrib/`, or issue templates, so HEAD may have no run of its own; if so, confirm with `git diff --stat <run headSha>..origin/main` that only such paths changed since.
+- **Milestone**: `open_issues` must be 0. Otherwise list the open issues and ask whether to wait or move them to the next milestone (`gh issue edit N --milestone vX.Y.(Z+1)`, only after confirmation).
+- **Test runs**: the brackets in the `pgrep` pattern stop it from matching the shell that runs it. Without them, it always finds a match.
+- **Maintainer status**: a release PR has no linked issue. `AGENTS.md` waives that requirement only for maintainers (members of the `netboxlabs` GitHub organization), which the membership check confirms. Push permission is needed as well, to push the release branch.
+- **Tools**: `tx` with a token (`~/.transifexrc` or `$TX_TOKEN`), `msgfmt` (gettext), `zensical`, and the UI package manager matching the lockfile in `netbox/project-static/` (`yarn.lock` → yarn 1; `package-lock.json` → npm). If `netbox/project-static/.nvmrc` exists, `node --version` must satisfy it.
+
+## 2. Create the Release Branch
+
+```bash
+git checkout -B release-vX.Y.Z main
+```
+
+## 3. Update Python Dependencies
+
+`base_requirements.txt` holds the loose constraints, plus a comment above each package with its changelog URL. `requirements.txt` holds the exact pins that ship.
+
+1. Upgrade the active venv to the latest allowed versions (this is the checklist's procedure, and it modifies the developer's venv):
+
+    ```bash
+    pip install -U -r base_requirements.txt
+    pip check
+    ```
+
+2. Rewrite the pins from the installed versions, keeping the existing `package[extras]==version` format:
+
+    ```bash
+    python - <<'PY'
+    import re
+    from importlib.metadata import version
+    from pathlib import Path
+
+    path = Path('requirements.txt')
+    lines = []
+    for line in path.read_text().splitlines():
+        name, extras = re.match(r'^([A-Za-z0-9_.-]+)(\[[^\]]*\])?==', line).groups()
+        lines.append(f'{name}{extras or ""}=={version(name)}')
+    path.write_text('\n'.join(lines) + '\n')
+    PY
+    git diff requirements.txt
+    ```
+
+3. Review the changelog of every upgraded package across the full version range, using the URL from its comment in `base_requirements.txt`. Use parallel subagents when there are more than a few. Classify each upgrade:
+    - **Breaking**: affects NetBox or its users (removed APIs, changed defaults, new minimum versions of Python/PostgreSQL/Django).
+    - **Needs a release note warning**: users or operators must act (e.g. the sorl-thumbnail 13.4.0 upgrade in v4.7.4 required `manage.py thumbnail clear` for some storage setups).
+    - **Routine**.
+4. If an upgrade breaks something (here, or in the tests in step 10), hold it back: constrain it in `base_requirements.txt` with a comment explaining why (and linking the upstream issue, if there is one), reinstall the previous version, and restore its pin. Report every hold to the user. If step 8 has already run, run it again, so that the schemas match the versions that ship.
+5. Verify the pins match the constraints:
+
+    ```bash
+    python3 scripts/verify_dependencies.py
+    ```
+
+## 4. Update UI Dependencies
+
+Work in `netbox/project-static/`. Pick the commands by lockfile.
+
+**npm** (`package-lock.json`):
+
+```bash
+npm ci
+npm run upgrade    # upgrades all but the held dependencies, and explains what remains behind
+npm run check
+npm run build
+```
+
+**yarn** (`yarn.lock`):
+
+```bash
+yarn install --frozen-lockfile
+yarn outdated
+```
+
+Upgrade patch and minor updates, keeping each entry's existing version style in `package.json`. `yarn upgrade` writes exactly the version spec it's given:
+
+- Under `dependencies` (exact pins): `yarn upgrade <package>@<version>`
+- Under `devDependencies` (caret ranges): `yarn upgrade '<package>@^<version>'`
+
+Ask the user about each major upgrade, and about any minor upgrade of `@tabler/core` or `bootstrap`, which take patch releases only. Then:
+
+```bash
+yarn validate
+yarn bundle
+```
+
+**Either way**, stage the rebuilt bundles, then run `scripts/verify-bundles.sh` from the repository root:
+
+```bash
+git add netbox/project-static/dist/
+scripts/verify-bundles.sh
+git diff --cached --stat netbox/project-static/dist/
+```
+
+The script rebuilds the bundles and fails if the result differs from the files in the index. Unstaged changes in `dist/` would make it fail even when the build is fine, which is why the bundles are staged first. The last command shows the change in each bundle's size (the bundles are marked binary in `.gitattributes`, so the diffstat reports bytes). Flag any large change for the user.
+
+## 5. Update Translations
+
+1. Ask the user to confirm that they have checked the [Transifex dashboard](https://app.transifex.com/netbox-community/netbox/dashboard/) and machine-translated any language not marked _ready for use_ (see [translations.md](../../../docs/development/translations.md#updating-translated-strings)). This step can't be automated.
+2. Pull and compile:
+
+    ```bash
+    tx pull --force
+    (cd netbox && python manage.py compilemessages)
+    ```
+
+3. Confirm that only translation files changed; this should print nothing:
+
+    ```bash
+    git status --porcelain netbox/translations | grep -vE 'LC_MESSAGES/django\.(po|mo)$'
+    ```
+
+## 6. Bump the Version
+
+Set `version` and `published` in `netbox/release.yaml`:
+
+```yaml
+version: "X.Y.Z"
+edition: "Community"
+published: "YYYY-MM-DD"
+```
+
+Do this before step 8: the OpenAPI schema embeds the version. Don't edit `pyproject.toml`; the package version is derived from `release.yaml`.
+
+## 7. Write the Release Notes
+
+Follow the `draft-release-notes` skill, passing it the upgrades that need a warning from step 3. Its commit audit checks every commit since the previous release for noteworthy changes that the milestone misses, and may propose assigning issues to the milestone (confirm each change with the user). Insert the section at the top of `docs/release-notes/version-X.Y.md`.
+
+Then stop and ask the user to review and edit the notes. They are published under the user's name, so they should be in the user's own words (see `AI_POLICY.md`).
+
+## 8. Regenerate the Schemas
+
+Device type definition schema:
+
+```bash
+(cd netbox && python manage.py buildschema --write)
+```
+
+OpenAPI schema, generated with all plugins disabled. Use a configuration module derived from the testing configuration, so that the output doesn't depend on the local `configuration.py`:
+
+```bash
+printf 'from netbox.configuration_testing import *  # noqa: F403\n\nPLUGINS = []\n' > $SCRATCH/release_schema_config.py
+(cd netbox && PYTHONPATH=$SCRATCH NETBOX_CONFIGURATION=release_schema_config \
+  python manage.py spectacular --format openapi-json > $SCRATCH/openapi.json) \
+  && test -s $SCRATCH/openapi.json && cp $SCRATCH/openapi.json contrib/openapi.json
+```
+
+The schema is written to the scratchpad first, so a failed run can't leave `contrib/openapi.json` empty. If the command fails, stop and report the error.
+
+Check the result:
+
+- `grep -c '"/api/plugins/' contrib/openapi.json` prints `0`.
+- `info.version` in `contrib/openapi.json` is `X.Y.Z`.
+- Every other change in `git diff contrib/openapi.json` corresponds to an API change merged this cycle. Flag any that doesn't, because it suggests an unexpected effect of a dependency upgrade.
+
+## 9. Update Development Dependencies
+
+Ruff is pinned in three places, which must agree:
+
+- `.pre-commit-config.yaml`: `rev: vX` under `ruff-pre-commit`
+- `.github/workflows/ci.yml`: `version: "X"` under `ruff-action`
+- `pyproject.toml`: `ruff==X` in the `dev` group
+
+Look up the latest release (`curl -s https://pypi.org/pypi/ruff/json | python3 -c 'import json,sys; print(json.load(sys.stdin)["info"]["version"])'`). If it's newer, update all three pins, then `pip install ruff==X` and run `ruff check`. If the new version reports any violations, revert the three pins and tell the user: fixing them is cleanup, which belongs in its own PR.
+
+## 10. Validate
+
+1. Run the full test suite with the `run-tests` skill. Check `pgrep -af '[m]anage.py test'` immediately beforehand, and run it in the background. On a failure, first rule out the test harness: re-run the failing tests without `--parallel`, against a freshly built database (`--noinput`, without `--keepdb`). Then find the responsible upgrade, by reinstalling previous versions one at a time, and hold it back (step 3).
+
+    After holding back any upgrade, rebuild whatever was generated with it before continuing. For a Python package, re-run step 8 (the schemas). For a UI package, restore its previous version in `package.json` and the lockfile, then rebuild, stage, and verify the bundles as at the end of step 4. Then re-run the tests.
+2. Run the remaining checks:
+
+    ```bash
+    (cd netbox && python manage.py check && python manage.py makemigrations --check --dry-run)
+    ruff check
+    zensical build
+    python3 scripts/verify_dependencies.py
+    ```
+
+    A dependency upgrade can generate new migrations; treat any as a breaking upgrade.
+3. Start the development server (`python manage.py runserver` from `netbox/`, in the background). Confirm that `/login/` returns HTTP 200 and that `/static/netbox.js` is served. Then ask the user to log in and click through the UI, the REST API browser, and GraphiQL before you continue. Stop the server afterward.
+
+## 11. Check the Scope
+
+`git status --porcelain` may list only these paths:
+
+- `base_requirements.txt`, `requirements.txt`
+- `netbox/project-static/package.json`, the lockfile, and `netbox/project-static/dist/`
+- `netbox/translations/*/LC_MESSAGES/django.{po,mo}`
+- `netbox/release.yaml`
+- `docs/release-notes/version-X.Y.md`
+- `contrib/generated_schema.json`, `contrib/openapi.json`
+- `.pre-commit-config.yaml`, `.github/workflows/ci.yml`, `pyproject.toml` (ruff pins only)
+
+A dependency upgrade sometimes forces a change to the documentation build configuration (e.g. v4.7.4 changed `mkdocs.yml` after `mkdocs-material` was dropped). Such a change needs the user's explicit sign-off. For anything else, stop and ask.
+
+## 12. Commit and Open the PR
+
+Confirm with the user before pushing. Then stage files by explicit path (never `git add -A`) and commit with the pre-commit hooks enabled:
+
+```bash
+git add <paths from step 11>
+git commit -m "Release vX.Y.Z"
+git push -u origin release-vX.Y.Z
+
+python3 .claude/skills/draft-release-notes/render_release_notes.py X.Y.Z --format pr > $SCRATCH/pr-body.md
+gh pr create --repo netbox-community/netbox --base main --head release-vX.Y.Z \
+  --title "Release vX.Y.Z" --body-file $SCRATCH/pr-body.md
+```
+
+Ask the user who should review the PR, and add them with `gh pr edit --add-reviewer`. Don't add any "generated by" text to the commit or the PR.
+
+## 13. Hand Off
+
+Summarize for the user:
+
+- Upgraded dependencies worth knowing about, and any that were held back (with reasons)
+- Release note judgment calls they made, or still need to make
+- The PR URL
+
+Once CI passes, a colleague approves, and the PR is merged, the next step is the `publish-release` skill.
+
+## Why These Choices
+
+- **Version before schemas.** `spectacular` writes the version from `release.yaml` into `contrib/openapi.json`; generating it first leaves a stale version in the schema.
+- **A plugin-free configuration derived from the testing configuration.** The checklist requires disabling plugins. A fixed configuration makes the output the same for every maintainer, regardless of their local `configuration.py`.
+- **Hold, don't fix.** The checklist forbids functional changes in a release PR so that it can be reviewed quickly. A pin constraint keeps the release moving, and the real fix gets its own review.
+- **The milestone date is the release date.** Each patch milestone's due date is the planned release date, and it becomes `published` in `release.yaml` and the release title.
+
+## References
+
+- [`docs/development/release-checklist.md`](../../../docs/development/release-checklist.md): The authoritative release process
+- [`docs/development/web-ui.md`](../../../docs/development/web-ui.md): UI dependency management
+- [`docs/development/translations.md`](../../../docs/development/translations.md): Transifex setup
+- [`scripts/verify_dependencies.py`](../../../scripts/verify_dependencies.py), [`scripts/verify-bundles.sh`](../../../scripts/verify-bundles.sh): Checks also run by CI
+- `draft-release-notes` and `run-tests` skills

+ 164 - 0
.claude/skills/publish-release/SKILL.md

@@ -0,0 +1,164 @@
+---
+name: publish-release
+description: Publish a NetBox core (this repository, not a plugin) release after its "Release vX.Y.Z" PR has merged — check the publishing prerequisites, create the GitHub release and tag, watch the PyPI publishing workflow, verify and smoke-test the published package, and close out the milestone. Use when a maintainer asks to publish, tag, or ship a NetBox release whose release PR is merged.
+---
+
+# Publish a NetBox Release
+
+This skill automates the checklist's steps from [Confirm Package Publishing Prerequisites](../../../docs/development/release-checklist.md#confirm-package-publishing-prerequisites) onward, after the release PR (prepared with the `prepare-release` skill) has merged. The checklist remains the authoritative process.
+
+Creating the GitHub release pushes the `vX.Y.Z` tag, and the tag push starts `.github/workflows/release.yml`, which publishes to PyPI. **Files uploaded to PyPI can never be replaced**, so check everything before step 3, and confirm with the user before every outward-facing action.
+
+Run commands from the repository root. Write temporary files to the session scratchpad (`$SCRATCH` below).
+
+## 1. Verify the Merge
+
+```bash
+git status --porcelain                                        # must be empty
+git fetch origin --tags
+gh repo set-default --view                                    # must be netbox-community/netbox
+gh pr list --repo netbox-community/netbox --head release-vX.Y.Z --state merged --json number,mergeCommit
+git checkout --detach <merge commit SHA>
+grep '^version:' netbox/release.yaml                          # must be "X.Y.Z"
+git ls-remote --tags origin vX.Y.Z                            # must print nothing
+gh run list --repo netbox-community/netbox --workflow ci.yml --commit <merge commit SHA> --json status,conclusion
+```
+
+- The release PR must be merged. Its merge commit is the **release commit**, and every remaining step uses it rather than `main` HEAD. Automated commits (source translation strings, the VOUCHED list) can land on `main` after the PR merges, and they must not end up in the tag.
+- `netbox/release.yaml` must hold the version being released. The published package version comes from this file, not from the tag.
+- If the tag already exists, stop: the release has been created, at least in part.
+- The CI run for the release commit must have succeeded.
+
+## 2. Check the Publishing Prerequisites
+
+These checks are read-only. **Never change repository or environment settings yourself**; report what's missing and let the user decide.
+
+**The `pypi` environment's approval gate.** The checklist requires the `pypi` environment to have required reviewers with **Prevent self-review** enabled, and deployments restricted to `v*` tags:
+
+```bash
+gh api repos/netbox-community/netbox/environments/pypi \
+  --jq '{rules: [.protection_rules[] | {type, prevent_self_review, reviewers: [.reviewers[]?.reviewer | .login // .slug]}], policy: .deployment_branch_policy}'
+gh api repos/netbox-community/netbox/environments/pypi/deployment-branch-policies \
+  --jq '[.branch_policies[] | {name, type}]'
+```
+
+Expect a `required_reviewers` rule with `prevent_self_review: true` and at least one reviewer, a policy with `custom_branch_policies: true`, and a `{"name": "v*", "type": "tag"}` branch policy. (The second command returns 404 when no custom policy is configured.)
+
+**If any of these is missing, stop.** Explain that creating the release will publish to PyPI immediately, with no approval step, and ask whether to fix the environment first (under Settings → Environments → `pypi`) or to proceed regardless.
+
+**The PyPI trusted publisher.** Its configuration on PyPI can't be queried. Treat it as confirmed if the previous release's publish job succeeded and the workflow's publish jobs haven't changed since:
+
+```bash
+PREV=$(git describe --tags --abbrev=0 HEAD^)
+gh run list --workflow release.yml --event push --branch $PREV --json databaseId,conclusion,url
+git diff $PREV..HEAD -- .github/workflows/release.yml
+```
+
+Otherwise, ask the user to confirm that the trusted publisher on PyPI still matches this repository, `release.yml`, and the `pypi` environment name.
+
+## 3. Create the Release
+
+Render the release body from the merged release notes:
+
+```bash
+python3 .claude/skills/draft-release-notes/render_release_notes.py X.Y.Z --format release > $SCRATCH/release-body.md
+```
+
+Show the user the title and body, and state plainly that creating the release pushes the tag and starts publishing to PyPI. Only after they confirm:
+
+```bash
+gh release create vX.Y.Z --repo netbox-community/netbox \
+  --target <release commit SHA> \
+  --title "vX.Y.Z - YYYY-MM-DD" \
+  --notes-file $SCRATCH/release-body.md
+```
+
+The date in the title is `published` from `netbox/release.yaml`. The checklist targets `main`. Targeting the release commit's SHA instead keeps out anything pushed to `main` after the release PR merged.
+
+## 4. Watch the Workflow
+
+The tag push starts a `release.yml` run within a minute or so:
+
+```bash
+gh run list --repo netbox-community/netbox --workflow release.yml --event push --branch vX.Y.Z --json databaseId,status,url
+gh run watch <run id> --repo netbox-community/netbox --exit-status    # in the background
+gh run view <run id> --repo netbox-community/netbox --json jobs --jq '.jobs[] | "\(.databaseId) \(.name): \(.status) \(.conclusion)"'
+```
+
+Report the results of the build and verification jobs: **Build package artifacts**, **Verify dependency pins are in sync**, **Verify the sdist builds a wheel**, **Smoke test wheel CLI (no dependencies)**, and **Smoke test wheel install**.
+
+When **Publish package to PyPI** is waiting on the `pypi` environment, tell the user that a colleague must approve the deployment in the Actions UI (the environment prevents self-review). **Never approve a deployment yourself**, for example through the `pending_deployments` API.
+
+If any job fails, stop and follow [pypi-recovery.md](pypi-recovery.md). Never re-run the whole workflow.
+
+## 5. Verify the Publication
+
+1. The publish job deployed to the `pypi` environment:
+
+    ```bash
+    gh api "repos/netbox-community/netbox/deployments?environment=pypi&ref=vX.Y.Z" --jq '.[] | {id, environment, created_at}'
+    ```
+
+2. PyPI lists exactly one wheel and one sdist for the version:
+
+    ```bash
+    curl -sf https://pypi.org/pypi/netbox/X.Y.Z/json | python3 -c 'import json, sys
+    for f in json.load(sys.stdin)["urls"]:
+        print(f["packagetype"], f["filename"], f["digests"]["sha256"])'
+    ```
+
+3. Smoke-test the published wheel, following [Test-installing the wheel](../../../docs/development/building-the-package.md#test-installing-the-wheel) but installing from PyPI. It builds `psycopg[c]`, which needs the PostgreSQL client headers (`libpq-dev`) and a compiler. If pip can't find the new version yet, wait a minute and retry.
+
+    ```bash
+    python3 -m venv $SCRATCH/netbox-wheel-test
+    $SCRATCH/netbox-wheel-test/bin/python -m pip install --upgrade pip
+    $SCRATCH/netbox-wheel-test/bin/python -m pip install --no-cache-dir "netbox==X.Y.Z"
+    $SCRATCH/netbox-wheel-test/bin/netbox --version
+    PYTHONPATH=$PWD/scripts \
+    NETBOX_CONFIGURATION=smoketest_configuration \
+    NETBOX_ROOT=$SCRATCH/netbox-wheel-root \
+    NETBOX_SMOKETEST_BASE=$SCRATCH/netbox-wheel-root \
+    $SCRATCH/netbox-wheel-test/bin/netbox check
+    ```
+
+    `netbox --version` must report `X.Y.Z`, and `netbox check` must report no issues.
+
+## 6. Close Out the Milestone
+
+Confirm each change with the user before making it.
+
+```bash
+gh api 'repos/netbox-community/netbox/milestones?state=open&per_page=100' \
+  --jq '.[] | {number, title, due_on, open_issues}'
+```
+
+1. If milestone `vX.Y.Z` still has open issues, list them and ask whether to move them to the next milestone (`gh issue edit N --milestone vX.Y.(Z+1)`).
+2. Close it: `gh api -X PATCH repos/netbox-community/netbox/milestones/<number> -f state=closed`
+3. If milestone `vX.Y.(Z+1)` doesn't exist yet, create it, due two weeks after this release (patch releases ship every two weeks):
+
+    ```bash
+    gh api repos/netbox-community/netbox/milestones -f title=vX.Y.(Z+1) -f due_on=YYYY-MM-DDT00:00:00Z
+    ```
+
+## 7. Hand Off
+
+Give the user:
+
+- The release URL, the PyPI project URL (`https://pypi.org/project/netbox/X.Y.Z/`), and the workflow run URL
+- The milestone changes made
+- Remaining tasks for them: merging `main` into `feature`
+
+Finally, return the checkout to `main` (`git checkout main`), since step 1 left it on the detached release commit.
+
+## Why These Choices
+
+- **Stop on a missing approval gate.** Without one, the tag push uploads straight to PyPI, where a mistake can only be corrected by publishing a new version. The checklist depends on the gate as the final review point.
+- **Never approve or re-run publishing automatically.** Approval is deliberately a second person's decision, and re-running the wrong jobs rebuilds distributions that PyPI may already have accepted.
+- **Render the body from the merged docs.** The release notes in `docs/release-notes/` are the reviewed text; rendering from them avoids copying the PR body by hand.
+
+## References
+
+- [`docs/development/release-checklist.md`](../../../docs/development/release-checklist.md): The authoritative release process
+- [`docs/development/building-the-package.md`](../../../docs/development/building-the-package.md): Building and test-installing the package
+- [`.github/workflows/release.yml`](../../../.github/workflows/release.yml): The publishing workflow
+- [pypi-recovery.md](pypi-recovery.md): What to do when publishing fails

+ 70 - 0
.claude/skills/publish-release/pypi-recovery.md

@@ -0,0 +1,70 @@
+# Recovering From a Failed Publish
+
+This guide condenses the "Production PyPI uploads are final" warning in the [release checklist](../../../docs/development/release-checklist.md#publish-to-pypi). Every recovery action here is outward-facing: present the diagnosis and the proposed action, and wait for the user to confirm.
+
+## A Build or Verification Job Failed
+
+The publish jobs depend on every verification job, so nothing has been uploaded. Read the failed job's log:
+
+```bash
+gh run view <run id> --repo netbox-community/netbox --log-failed
+```
+
+- **Verify the release tag** (in the build job): the tag doesn't match the version in `netbox/release.yaml`. The tag points at the wrong commit, or `release.yaml` wasn't bumped.
+- **verify-dependencies**: `requirements.txt` has drifted from `base_requirements.txt`, or the wheel's `Requires-Dist` doesn't match `requirements.txt`.
+- **verify-sdist**: the sdist ships unexpected configuration files, or can't rebuild a valid wheel.
+- **smoke-test** or **cli-smoke-test**: the installed wheel fails `netbox check`, `netbox upgrade`, or its CLI commands.
+
+Each of these needs a fix merged to `main`. Since the tag and the GitHub release already exist, discuss with the user whether to delete and recreate the release and its tag at the fixed commit (nothing has reached PyPI, so the version is still free), or to ship the fix as the next patch version.
+
+## The Publish Job Failed
+
+First, find out whether PyPI accepted any file:
+
+```bash
+curl -s -o /dev/null -w '%{http_code}\n' https://pypi.org/pypi/netbox/X.Y.Z/json   # 404: nothing accepted
+gh run view <run id> --repo netbox-community/netbox --log-failed
+```
+
+Then:
+
+1. **No file accepted, and the cause can be fixed without changing the built distributions** (e.g. a trusted publisher or environment misconfiguration): once the user has fixed the cause, re-run only the failed publish job. This reuses the artifacts the original run built and verified:
+
+    ```bash
+    gh run view <run id> --repo netbox-community/netbox --json jobs --jq '.jobs[] | "\(.databaseId) \(.name) \(.conclusion)"'
+    gh run rerun <run id> --repo netbox-community/netbox --job <publish job id>
+    ```
+
+    **Never re-run all jobs** (or use the Re-run all jobs button): that rebuilds the distributions.
+
+2. **The fix requires changing package contents or metadata**: prepare a new NetBox version and tag. The current one can't be reused.
+
+3. **PyPI accepted either file**: don't retry. Production publishing fails on duplicate filenames by design, so a retry fails at the file already accepted. Instead:
+    - Record the accepted filenames and their SHA-256 hashes (the job log prints them).
+    - Ask the user to yank the incomplete release on PyPI. This is done in the PyPI web UI, by a project owner.
+    - Publish a new NetBox version. Never combine files from separate builds.
+
+## Optional: Rehearse on Test PyPI
+
+A rehearsal is useful after changes to packaging or publishing, but isn't needed for every release. Dispatch it from the release tag while the production deployment is still awaiting approval, and approve production only after the rehearsal succeeds:
+
+```bash
+gh workflow run release.yml --repo netbox-community/netbox --ref vX.Y.Z
+```
+
+The rehearsal rebuilds the distributions, so it validates the packaging and publishing path, not the exact files awaiting production approval. Test PyPI also never accepts the same filename twice: once it has accepted a file for a tag, dispatching that tag again fails, and a further rehearsal needs a new version.
+
+To test the rehearsal package, create a fresh venv (the one in step 5 of the skill doesn't exist yet at this point). Install the pinned runtime dependencies from PyPI first, then the candidate alone from Test PyPI:
+
+```bash
+rm -rf $SCRATCH/netbox-rehearsal
+python3 -m venv $SCRATCH/netbox-rehearsal
+$SCRATCH/netbox-rehearsal/bin/python -m pip install --upgrade pip
+$SCRATCH/netbox-rehearsal/bin/python -m pip install -r requirements.txt
+$SCRATCH/netbox-rehearsal/bin/python -m pip install --no-deps \
+  --index-url https://test.pypi.org/simple/ "netbox==X.Y.Z"
+```
+
+Then run `netbox --version` and `netbox check` from `$SCRATCH/netbox-rehearsal/bin/`, with the environment variables shown in step 5 of the skill.
+
+Dispatching the workflow from a branch, rather than a tag, runs the build and verification jobs as a dry run without publishing anywhere.

+ 3 - 0
docs/development/release-checklist.md

@@ -10,6 +10,9 @@ While major releases generally introduce some very substantial changes to the ap
 
 For patch releases (e.g. upgrading from v4.2.2 to v4.2.3), begin at the [patch releases](#patch-releases) heading below. For minor or major releases, complete the entire checklist.
 
+!!! tip "Claude Code skills"
+    Maintainers using Claude Code can automate most of the patch release process with the `prepare-release`, `draft-release-notes`, and `publish-release` skills in `.claude/skills/`. When changing this checklist, update those skills to match.
+
 ## Minor Version Releases
 
 ### Address Constrained Dependencies