Skip to content
Open
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
12 changes: 10 additions & 2 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@ of the PR were done in a specific way -->

### Changelog notice

<!-- Notice the release manager should include in the release tag message changelog -->
<!-- See https://keepachangelog.com/en/1.0.0/ for examples -->
<!-- Changes users should know about, for the crates' CHANGELOG.md. Leave it out if there are none.
Write one ```changelog block, outside this comment, as in CONTRIBUTING.md:

```changelog
### Added
- bdk_chain: Add `TxGraph::foo` to do bar.
### Fixed
- bdk_esplora, bdk_electrum: `full_scan` no longer stops early when ...
```
-->

### Checklists

Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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): `- <crate>: <change>`, 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
------------------

Expand Down
105 changes: 105 additions & 0 deletions ci/changelog.py
Original file line number Diff line number Diff line change
@@ -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 <rev-range> [<crate>]
Collect the notices of the PRs merged in <rev-range> (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 `- <crate>: <change>` on one line: {line!r}")
continue
if heading is None:
errors.append(f"line {n}: entry before any `### <heading>`")
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__)
Loading