Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,16 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Check and preview PR release notes
if: github.event_name == 'pull_request'
run: |
merge_base="$(git merge-base "origin/${{ github.base_ref }}" HEAD)"
python3 scripts/release_notes.py check-pr \
--range "${merge_base}..HEAD" \
--summary "$GITHUB_STEP_SUMMARY"

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
Expand Down
247 changes: 206 additions & 41 deletions scripts/release_notes.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
#!/usr/bin/env python3
"""Collect curated release notes from commit messages.
"""Collect and validate curated release notes from commit messages.

Convention: a commit that should contribute user-facing release notes carries a
`Release-Notes:` line in its body; everything after that line (to the end of
the message) is the note, written as Markdown. Notes are collected across the
release range (previous release tag → the release ref), oldest first, so the
release body reads chronologically. The publish-time version-injection flow
authors no synthetic commits, so every
commit in range is a real change eligible to contribute notes.
Convention: a commit that should contribute release notes carries one or more
`Release-Notes:` lines in its body. A note may start on the marker line or the
following line. It ends at the next `Release-Notes:` line, the next squash
subject line (`* `), or the end of the message.

Notes may start with `Breaking:`, `Added:`, `Fixed:`, `Changed:`, or
`Internal:` (case-insensitive). The legacy `Fixed - ` and `Changed - ` forms
remain accepted. Publication groups categorized notes in that order, followed
by uncategorized historical notes under `Other`.

The `check-pr` command requires a categorized note when a range changes
anything under `crates/`, and rejects uncategorized notes in every PR. Use an
`Internal:` note, with a short explanation, as the escape hatch for a product
change that intentionally has no public-facing release note.

The release pipeline uses two entry points:
- the manually dispatched release workflow runs `collect --require` for the
Expand All @@ -22,15 +29,25 @@
from __future__ import annotations

import argparse
import re
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]

MARKER = "Release-Notes:"
MARKER_RE = re.compile(r"^Release-Notes:(.*)$")
SQUASH_SUBJECT_RE = re.compile(r"^\*\s")
RECORD_SEPARATOR = "\x1e"
FIELD_SEPARATOR = "\x1f"
CATEGORIES = ("Breaking", "Added", "Fixed", "Changed", "Internal")
CATEGORY_COLON_RE = re.compile(
r"^(Breaking|Added|Fixed|Changed|Internal):\s*(.*)$", re.IGNORECASE
)
LEGACY_CATEGORY_RE = re.compile(r"^(Fixed|Changed)\s+-\s+(.*)$", re.IGNORECASE)
MALFORMED_CATEGORY_ERROR = (
"every release note must start with one of: "
"Breaking:, Added:, Fixed:, Changed:, Internal:"
)


def previous_tag(end: str) -> str | None:
Expand Down Expand Up @@ -67,56 +84,173 @@ def end_is_released(end: str) -> bool:
return result.returncode == 0 and bool(result.stdout.strip())


def extract_note(body: str) -> str | None:
"""The Markdown after a standalone `Release-Notes:` line, or None."""
def extract_notes(body: str) -> list[str]:
"""Extract every bounded release-note block from one commit body."""
lines = body.splitlines()
for index, line in enumerate(lines):
if line.strip() == MARKER:
note = "\n".join(lines[index + 1 :]).strip()
return note or None
# Also accept inline form: "Release-Notes: text on the same line".
if line.strip().startswith(MARKER):
first = line.strip()[len(MARKER) :].strip()
rest = "\n".join(lines[index + 1 :]).strip()
note = "\n".join(part for part in (first, rest) if part).strip()
return note or None
return None
notes: list[str] = []
index = 0
while index < len(lines):
marker = MARKER_RE.match(lines[index])
if marker is None:
index += 1
continue

note_lines: list[str] = []
inline = marker.group(1).strip()
if inline:
note_lines.append(inline)
index += 1

def collect_notes(end: str) -> list[str]:
prev = previous_tag(end)
range_spec = f"{prev}..{end}" if prev else end
while index < len(lines):
line = lines[index]
if MARKER_RE.match(line) or SQUASH_SUBJECT_RE.match(line):
break
note_lines.append(line)
index += 1

note = "\n".join(note_lines).strip()
if note:
notes.append(note)

return notes


def categorize_note(note: str) -> tuple[str | None, str]:
"""Return the normalized category and note text without its prefix."""
first_line, separator, remainder = note.partition("\n")
match = CATEGORY_COLON_RE.match(first_line)
if match is None:
match = LEGACY_CATEGORY_RE.match(first_line)
if match is None:
return None, note

category = match.group(1).capitalize()
first_text = match.group(2).strip()
text = "\n".join(
part for part in (first_text, remainder if separator else "") if part
).strip()
return category, text


def render_notes(notes: list[str]) -> str:
"""Group notes by category and render the publication Markdown."""
grouped: dict[str, list[str]] = {category: [] for category in CATEGORIES}
grouped["Other"] = []
for note in notes:
category, text = categorize_note(note)
grouped[category or "Other"].append(text)

sections: list[str] = []
for category in (*CATEGORIES, "Other"):
category_notes = grouped[category]
if category_notes:
sections.append(f"## {category}\n\n" + "\n\n".join(category_notes))
return "\n\n".join(sections).strip()


def commit_bodies(range_spec: str) -> list[str]:
"""Return commit bodies in chronological order for an arbitrary range."""
result = subprocess.run(
[
"git",
"log",
"--reverse",
f"--format=%H{FIELD_SEPARATOR}%s{FIELD_SEPARATOR}%B{RECORD_SEPARATOR}",
f"--format=%B{RECORD_SEPARATOR}",
range_spec,
],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return [
record.strip("\n")
for record in result.stdout.split(RECORD_SEPARATOR)
if record.strip()
]


def collect_notes_for_range(range_spec: str) -> list[str]:
"""Collect all notes from an arbitrary git range, oldest first."""
notes: list[str] = []
for record in result.stdout.split(RECORD_SEPARATOR):
record = record.strip("\n")
if not record.strip():
continue
parts = record.split(FIELD_SEPARATOR)
if len(parts) != 3:
continue
_sha, _subject, body = parts
note = extract_note(body)
if note:
notes.append(note)
for body in commit_bodies(range_spec):
notes.extend(extract_notes(body))
return notes


def collect_notes(end: str) -> list[str]:
"""Collect notes for the previous-release-tag-to-end range."""
prev = previous_tag(end)
range_spec = f"{prev}..{end}" if prev else end
return collect_notes_for_range(range_spec)


def changed_paths(range_spec: str) -> list[str]:
"""Return paths changed in an arbitrary git range."""
result = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=ACDMRTUXB", range_spec],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return [line for line in result.stdout.splitlines() if line]


def validate_pr_notes(paths: list[str], notes: list[str]) -> list[str]:
"""Validate the product-note requirement and every note's category."""
errors: list[str] = []
if any(path.startswith("crates/") for path in paths) and not notes:
errors.append(
"product changes under `crates/` require at least one categorized "
"release note"
)
if any(
category is None or not text
for category, text in (categorize_note(note) for note in notes)
):
errors.append(MALFORMED_CATEGORY_ERROR)
return errors


def marker_count(bodies: list[str]) -> int:
"""Count marker lines, including empty or otherwise malformed notes."""
return sum(
1
for body in bodies
for line in body.splitlines()
if MARKER_RE.match(line) is not None
)


def write_pr_summary(path: Path, rendered: str) -> None:
"""Append the exact PR release-note preview to a GitHub step summary."""
preview = rendered or "No release notes in this PR."
with path.open("a", encoding="utf-8") as summary:
summary.write(f"## Release notes preview (this PR)\n\n{preview}\n")


def write_check_pr_failure(range_spec: str, errors: list[str]) -> None:
"""Write an actionable category-and-shape guide for a failed PR check."""
sys.stderr.write(f"release notes check failed for {range_spec}:\n")
for error in errors:
sys.stderr.write(f"- {error}\n")
sys.stderr.write(
"\nAdd a categorized note to a commit body in this PR.\n"
"Accepted categories: Breaking, Added, Fixed, Changed, Internal.\n"
"Use Internal for a product change with no public-facing release note.\n"
"\nAccepted inline shape:\n"
"Release-Notes: Fixed: Describe the user-visible change.\n"
"\nAccepted block shape:\n"
"Release-Notes:\n"
"Fixed: Describe the user-visible change.\n"
)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)

collect = sub.add_parser("collect", help="collect notes for a release range")
collect.add_argument(
"--end",
Expand All @@ -133,10 +267,42 @@ def main() -> int:
action="store_true",
help="fail when the range contains no Release-Notes sections",
)

check_pr = sub.add_parser(
"check-pr", help="validate and preview notes for an arbitrary PR range"
)
check_pr.add_argument(
"--range",
dest="range_spec",
required=True,
help="git range from the PR merge base through its head",
)
check_pr.add_argument(
"--summary",
type=Path,
help="append the rendered preview to this GitHub step-summary file",
)

args = parser.parse_args()

if args.command == "check-pr":
bodies = commit_bodies(args.range_spec)
notes = [note for body in bodies for note in extract_notes(body)]
rendered = render_notes(notes)
if args.summary:
write_pr_summary(args.summary, rendered)
errors = validate_pr_notes(changed_paths(args.range_spec), notes)
if marker_count(bodies) != len(notes) and MALFORMED_CATEGORY_ERROR not in errors:
errors.append(MALFORMED_CATEGORY_ERROR)
if errors:
write_check_pr_failure(args.range_spec, errors)
return 2
if rendered and not args.summary:
sys.stdout.write(rendered + "\n")
return 0

notes = collect_notes(args.end)
rendered = "\n\n".join(notes).strip()
rendered = render_notes(notes)
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(rendered + "\n" if rendered else "", encoding="utf-8")
Expand All @@ -148,8 +314,7 @@ def main() -> int:
sys.stderr.write(
"no release notes found in "
f"{prev or 'history start'}..{args.end}.\n"
"Add a `Release-Notes:` section to at least one commit body\n"
"(Markdown, everything after the marker line).\n"
"Add a `Release-Notes:` section to at least one commit body.\n"
)
return 2
if rendered and not args.out:
Expand Down
10 changes: 10 additions & 0 deletions scripts/test_confidence_gate_ci_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,16 @@ def test_lint_job_runs_clippy_fmt_and_boundary_guards(self) -> None:
self.assertIn("bash scripts/check-workflow-graph-model.sh", lint)
self.assertIn("bash scripts/check-production-file-size.sh", lint)

def test_lint_job_checks_and_previews_pr_release_notes(self) -> None:
workflow = WORKFLOW.read_text(encoding="utf-8")
lint = workflow_job_block(workflow, "lint")

self.assertIn("fetch-depth: 0", lint)
self.assertIn("if: github.event_name == 'pull_request'", lint)
self.assertIn('git merge-base "origin/${{ github.base_ref }}" HEAD', lint)
self.assertIn("python3 scripts/release_notes.py check-pr", lint)
self.assertIn('--summary "$GITHUB_STEP_SUMMARY"', lint)

def test_workflow_graph_example_is_in_functional_matrix(self) -> None:
workflow = WORKFLOW.read_text(encoding="utf-8")
justfile = JUSTFILE.read_text(encoding="utf-8")
Expand Down
Loading
Loading