From 1366909ee26257384b258ccb90deb6300c3807d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 22 Oct 2026 07:43:06 +0000 Subject: [PATCH] ci: standardize and check the PR changelog notice Define the changelog notice as one fenced `changelog` block in the PR description: keepachangelog headings, one unwrapped `- : ` line per entry. Document it in CONTRIBUTING.md and the PR template. Add `ci/changelog.py`: `check` validates a PR description's notice (a PR without one passes), run by a new workflow on PR open/edit; `scrape` collects the notices of the PRs merged in a revision range into per-crate keepachangelog sections for the release manager. Fixes #2025 Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 12 +++- .github/workflows/changelog.yml | 21 +++++++ CONTRIBUTING.md | 21 +++++++ ci/changelog.py | 105 +++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/changelog.yml create mode 100644 ci/changelog.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a9bcabef05..adbe6a2a1a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,8 +11,16 @@ of the PR were done in a specific way --> ### Changelog notice - - + ### Checklists diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000000..d3bd5caf39 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,21 @@ +name: Changelog notice + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: {} + +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Check the changelog notice in the PR description + env: + BODY: ${{ github.event.pull_request.body }} + run: printf '%s' "$BODY" | python3 ci/changelog.py check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85d157d436..7b638a0828 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,27 @@ comment suggesting that you're working on it. If someone is already assigned, don't hesitate to ask if the assigned party or previous commenter are still working on it if it has been a while. +Changelog notice +---------------- + +Each crate has its own `CHANGELOG.md`, which the release manager fills from the +changelog notices of the merged PRs (`python3 ci/changelog.py scrape`). If your PR changes +something users should know about, add one fenced `changelog` block to its +description: + + ```changelog + ### Changed + - bdk_chain: `LocalChain::apply_update` now returns ... + ### Fixed + - bdk_esplora, bdk_electrum: `full_scan` no longer stops early when ... + ``` + +- Headings are [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)'s: + `### Added`, `Changed`, `Deprecated`, `Removed`, `Fixed` or `Security`. +- Each entry is one line (don't wrap it): `- : `, naming the crates + it affects (comma-separated) and the API. The script adds the PR number. +- Leave the block out if there is nothing to log. CI checks the block's format. + Deprecation policy ------------------ diff --git a/ci/changelog.py b/ci/changelog.py new file mode 100644 index 0000000000..af7cc42cd1 --- /dev/null +++ b/ci/changelog.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Check and scrape the changelog notices of PR descriptions. + +A notice is one fenced ```changelog block in the PR description (see +CONTRIBUTING.md). PRs without one have nothing for the changelog. + + python3 ci/changelog.py check < body.md + Check the notice in a PR description read from stdin. + python3 ci/changelog.py scrape [] + Collect the notices of the PRs merged in (e.g. + `chain-0.23.2..master`) into keepachangelog sections, one per crate. + Needs `gh`. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +HEADINGS = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"] +CRATES = sorted("bdk_" + p.name for p in (Path(__file__).parent.parent / "crates").iterdir() if p.is_dir()) +BLOCK = re.compile(r"^```changelog[ \t]*\n(.*?)^```[ \t]*$", re.M | re.S) +ENTRY = re.compile(r"- ((?:`?bdk_\w+`?, )*`?bdk_\w+`?): (\S.*)") + + +def parse(body): + """Return ([(heading, crates, text)], [error]) for a PR description.""" + body = re.sub(r"", "", body.replace("\r\n", "\n"), flags=re.S) + blocks = BLOCK.findall(body) + if len(blocks) > 1: + return [], ["more than one ```changelog block"] + if not blocks and re.search(rf"^### ({'|'.join(HEADINGS)})\b", body, re.M): + return [], ["changelog headings outside a ```changelog block"] + entries, errors, heading = [], [], None + for n, line in enumerate(blocks[0].splitlines() if blocks else [], 1): + if not line.strip(): + continue + if line.startswith("### "): + heading = line[4:].strip() + if heading not in HEADINGS: + errors.append(f"line {n}: heading must be one of {', '.join(HEADINGS)}: {line!r}") + continue + m = ENTRY.fullmatch(line.rstrip()) + if not m: + errors.append(f"line {n}: expected `- : ` on one line: {line!r}") + continue + if heading is None: + errors.append(f"line {n}: entry before any `### `") + continue + crates = m[1].replace("`", "").split(", ") + for c in crates: + if c not in CRATES: + errors.append(f"line {n}: unknown crate {c!r} (one of {', '.join(CRATES)})") + entries.append((heading, crates, m[2])) + if blocks and not entries and not errors: + errors.append("the ```changelog block has no entries (remove it if there is nothing to log)") + return entries, errors + + +def check(): + entries, errors = parse(sys.stdin.read()) + for e in errors: + print(f"error: {e}", file=sys.stderr) + if not errors: + print(f"ok: {len(entries)} changelog entries") + return 1 if errors else 0 + + +def scrape(rev_range, crate=None): + subjects = subprocess.run( + ["git", "log", "--merges", "--reverse", "--format=%s", rev_range], + check=True, capture_output=True, text=True, + ).stdout + out = {} # crate -> heading -> [line] + for pr in re.findall(r"^Merge bitcoindevkit/bdk#(\d+):", subjects, re.M): + body = json.loads(subprocess.run( + ["gh", "pr", "view", pr, "--repo", "bitcoindevkit/bdk", "--json", "body"], + check=True, capture_output=True, text=True, + ).stdout)["body"] + entries, errors = parse(body) + for e in errors: + print(f"warning: #{pr}: {e}", file=sys.stderr) + for heading, crates, text in entries: + if not text.endswith(f"#{pr}"): + text += f" #{pr}" + for c in crates: + out.setdefault(c, {}).setdefault(heading, []).append(f"- {text}") + for c in sorted(out): + if crate and c != crate: + continue + print(f"## {c}\n") + for h in HEADINGS: + if h in out[c]: + print(f"### {h}\n") + print("\n".join(out[c][h]) + "\n") + return 0 + + +if __name__ == "__main__": + if sys.argv[1:2] == ["check"] and len(sys.argv) == 2: + sys.exit(check()) + if sys.argv[1:2] == ["scrape"] and len(sys.argv) in (3, 4): + sys.exit(scrape(*sys.argv[2:])) + sys.exit(__doc__)