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
7 changes: 3 additions & 4 deletions .claude/rules/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@ paths:

Unreleased changes are towncrier fragments in `changelog.d/`. A release turns them into a `CHANGELOG.md` section with `scripts/release/notes.py assemble` (`RELEASING.md`, Notes).

- **One file per user-visible change**, named `changelog.d/<PR>.<type>.md`: `<PR>` is the pull request that adds it, `<type>` is `added`, `changed`, `deprecated`, `removed`, `fixed` or `security`. A second entry of the same type in one PR adds `.<n>`: `3452.fixed.2.md`.
- **One file per user-visible change**, named `changelog.d/<PR>.<type>.md`: `<PR>` is the pull request that adds it, `<type>` is `added`, `changed`, `deprecated`, `removed`, `fixed` or `security`. A second entry of the same type in one PR adds a counter, as `towncrier create` does: `3452.fixed.1.md`, then `.2`. The counter does not order entries.
- **One line, at most 120 characters, ending with a period.** Added and Changed name the new thing (`Open in Window for row inspector text fields.`); Fixed names the bug, not the fix (`Empty context menu when right-clicking a read-only inspector field.`). No file paths or type names.
- **No refs, no credit, no `- ` prefix.** towncrier links the PR named by the file and adds the bullet; `notes.py` adds ` by @login` when the PR's author is not a maintainer.
- **One entry per thing a user would notice.** Defects with the same visible symptom share an entry.
- **No fragment** for a fix to something still unreleased (edit that fragment instead) or for a docs-only change.
- **Only a `chore(release): vX.Y.Z` PR edits `CHANGELOG.md`.**
- **The lead block**: at most 6 lines, X.Y.0 only, passed as `notes.py assemble --lead`. The update window and the Sparkle feed show only that block.
- **`[CRITICAL]`** on a heading only for a security fix or data loss, never on a beta.
- `scripts/ci/check-changelog.py` checks the fragments and the headings in Repo Hygiene, then `towncrier build --draft --version 0.0.0` renders them. In PR title, `--pr` checks that a PR into main names each new fragment after itself and that only a release PR edits `CHANGELOG.md`.
- **The lead block and `[CRITICAL]`**: `RELEASING.md`, Notes and Channels.
- `scripts/ci/check-changelog.py` checks the fragments' text, the headings and the lead blocks in Repo Hygiene, then `towncrier build --draft --version 0.0.0` renders the fragments. towncrier refuses an unknown type and a non-numeric PR number (`ignore`, `issue_pattern`). In PR title, `--pr` checks that a PR into main names each new fragment after itself, that only a release PR edits `CHANGELOG.md`, and that a release PR changes nothing else.
2 changes: 1 addition & 1 deletion .claude/rules/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/
## PR title (`pr-title.yml`)

- Runs on opened, edited, reopened and synchronize, so every head commit gets the check.
- Runs `scripts/ci/check-pr-title.py`, then `scripts/ci/check-changelog.py --pr` with `GH_TOKEN` (`pull-requests: read`), then `scripts/check-banned-words.sh` on the PR's added lines and on its body. The banned-words step runs both and exits with the worse result.
- Runs `scripts/ci/check-pr-title.py`, then `scripts/ci/check-changelog.py --pr` on the merge commit's diff to its first parent, which also refuses a release PR that changes more than `CHANGELOG.md` and `changelog.d/`, then `scripts/check-banned-words.sh` on the PR's added lines and on its body. The banned-words step runs both and exits with the worse result.

## Repo Hygiene (`repo-hygiene.yml`)

Expand Down
6 changes: 3 additions & 3 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Before beta.1 and before a promotion, list the commits that touch user data sinc

## 5. Critical or not

Critical is only a security fix or data loss, never a beta. Name the fix and the reason, and let the maintainer confirm. A critical stable takes `--critical` on `notes.py assemble`. Run: `RELEASING.md`, Notes.
What counts as critical: `RELEASING.md`, Channels. Name the fix and the reason, and let the maintainer confirm. A critical stable gets ` [CRITICAL]` appended to its heading in the release PR. Run: `RELEASING.md`, Cutting a stable or patch.

## 6. Backport candidates

Expand All @@ -60,8 +60,8 @@ Run: `RELEASING.md`, Branches.
## 7. Curate

- Compare what merged with the fragments: `gh pr list --state merged --base main --search "merged:>=<date of the last X.Y.0 cut>" --limit 1000 --json number,title,files`. A user-visible PR with no fragment needs one; a fragment that says more than its PR did needs rewording.
- Add or reword fragments through a PR to `main`, then pick it to the line. Never edit `CHANGELOG.md` by hand.
- For X.Y.0, write the lead per `.claude/rules/changelog.md`: at most 6 plain lines, what a reader would change their behavior over, in the reader's words. A release of fixes only can go without one.
- Add or reword fragments through a PR to `main`, then pick it to the line. Entries change only through fragments; the lead and ` [CRITICAL]` are typed in the release PR.
- For X.Y.0, write the lead per `RELEASING.md`, Notes, in the reader's words. A release of fixes only can go without one.
- Check every fragment against `.claude/rules/changelog.md` and `scripts/check-banned-words.sh`.

Run: `RELEASING.md`, Notes, then Cutting a stable or patch.
Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ concurrency:

permissions:
contents: read
pull-requests: read

jobs:
title:
name: PR title
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# The checkout is the merge commit, so its first parent is the base the pull request lands on.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 2
Expand All @@ -32,11 +32,10 @@ jobs:

- name: Check CHANGELOG.md and the changelog fragments the pull request changes
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: python3 scripts/ci/check-changelog.py --pr "$PR"
TITLE: ${{ github.event.pull_request.title }}
run: python3 scripts/ci/check-changelog.py --pr "$PR" --title "$TITLE" --base "$GITHUB_BASE_REF"

# The checkout is the merge commit, so its first parent is the base the pull request lands on.
- name: Check the added lines and the description against the banned words
env:
BODY: ${{ github.event.pull_request.body }}
Expand Down
10 changes: 6 additions & 4 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ This runbook covers the macOS app, its registry plugins and the static libraries
## Notes

- Fragments in `changelog.d/` (`.claude/rules/changelog.md`) become a `CHANGELOG.md` section. The tooling needs towncrier: `python3 -m venv .venv && .venv/bin/pip install --require-hashes -r scripts/release/requirements.txt`.
- `python3 scripts/release/notes.py assemble X.Y.Z [--lead FILE] [--critical] [--date YYYY-MM-DD]` runs on `prep/v<X.Y.Z>`. It refuses a beta, a branch with commits beyond `origin/release/<X.Y>` other than a revert, an empty `changelog.d/`, and a version `CHANGELOG.md` already has. It credits each fragment ` by @login` when its PR's author is not an OWNER or MEMBER (read with `gh`), runs `towncrier build`, which deletes the fragments, adds the lead and the critical marker, and stages `CHANGELOG.md`.
- The lead: X.Y.0 only, at most 6 plain lines, naming what a reader would change their behavior over. The Sparkle notes, What's New and the Telegram post show only the lead.
- `.venv/bin/python scripts/release/notes.py assemble X.Y.Z [--date YYYY-MM-DD]` runs on `prep/v<X.Y.Z>`. It refuses a beta, an empty `changelog.d/`, and a version `CHANGELOG.md` already has. It credits each fragment ` by @login` when its PR's author is not an OWNER or MEMBER (read with `gh`), then runs `towncrier build`, which deletes the fragments and stages `CHANGELOG.md`.
- The lead: X.Y.0 only, at most 6 plain lines, typed under the heading in the release PR, naming what a reader would change their behavior over. With a lead, What's New, the Sparkle notes and the Telegram post show only the lead. Without one, the Sparkle notes carry the whole section and What's New points at the changelog.
- A beta gets no section. Its GitHub release body and feed notes are a towncrier draft of the fragments at the tag.

## Cutting a beta
Expand All @@ -51,13 +51,15 @@ git push origin vX.Y.0-beta.N
```bash
git fetch origin
git switch -c prep/vX.Y.Z origin/release/X.Y
python3 scripts/release/notes.py assemble X.Y.Z --lead lead.txt # --lead on X.Y.0 only, --critical when it is
.venv/bin/python scripts/release/notes.py assemble X.Y.Z
# X.Y.0: type the lead under the heading; critical: append ' [CRITICAL]' to it
git add CHANGELOG.md
git commit -m "chore(release): vX.Y.Z"
git push -u origin prep/vX.Y.Z
gh pr create --base release/X.Y --title "chore(release): vX.Y.Z" --body "Release notes for vX.Y.Z."
```

The PR changes only `CHANGELOG.md` and `changelog.d/`. Squash-merge it, then tag its merge commit:
The PR changes only `CHANGELOG.md` and `changelog.d/`; PR title refuses anything else. Squash-merge it, then tag its merge commit:

```bash
git fetch origin
Expand Down
139 changes: 50 additions & 89 deletions scripts/ci/check-changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,11 @@
import argparse
import datetime
import importlib.util
import json
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Callable, Dict, List, NamedTuple, Optional, Tuple
from typing import Dict, List, NamedTuple, Optional, Tuple

ROOT = Path(__file__).resolve().parent.parent.parent
NOTES_SPEC = importlib.util.spec_from_file_location("notes", ROOT / "scripts" / "release" / "notes.py")
Expand All @@ -22,7 +19,7 @@
FRAGMENTS = "changelog.d"
IGNORED = {".gitkeep"}
MAX_LINE = 120
TYPE_DIRECTORY = re.compile(r'^\[\[tool\.towncrier\.type\]\]\ndirectory = "([a-z]+)"$', re.MULTILINE)
MAX_LEAD_LINES = 6
VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)"
DATE = r"(?P<date>[0-9]{4}-[0-9]{2}-[0-9]{2})"
OLD_HEADING = re.compile(rf"## \[(?P<version>{VERSION})\] - {DATE}")
Expand All @@ -32,40 +29,19 @@
)
# The forward-port to main keeps the release commit's subject, which ends in its (#N).
RELEASE_TITLE = re.compile(rf"chore\(release\): v{VERSION}(?: \(#[0-9]+\))?")
NEW_STATUSES = {"added", "copied"}


class Fragment(NamedTuple):
owner: str
kind: str
counter: Optional[str]
GIT_STATUSES = {"A": "added", "D": "removed"}


class ChangedFile(NamedTuple):
filename: str
status: str
previous_filename: Optional[str] = None


class CheckError(Exception):
pass


def fragment_types(towncrier_toml: str) -> List[str]:
return TYPE_DIRECTORY.findall(towncrier_toml)


def parse_fragment_name(name: str, types: List[str]) -> Optional[Fragment]:
match = re.fullmatch(
rf"(?P<owner>[0-9]+|\+[a-z0-9][a-z0-9-]*)\.(?P<kind>{'|'.join(types)})(?:\.(?P<counter>[1-9][0-9]*))?\.md",
name,
)
if not match:
return None
return Fragment(match.group("owner"), match.group("kind"), match.group("counter"))


def fragment_problems(directory: Path, types: List[str]) -> List[str]:
def fragment_problems(directory: Path) -> List[str]:
if not directory.is_dir():
return []
problems = []
Expand All @@ -76,9 +52,6 @@ def fragment_problems(directory: Path, types: List[str]) -> List[str]:
if not path.is_file():
problems.append(f"{name} is not a file")
continue
if parse_fragment_name(path.name, types) is None:
problems.append(f"{name} is not named <PR>.<type>.md or <PR>.<type>.<n>.md, with type one of "
+ ", ".join(types))
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
Expand Down Expand Up @@ -152,102 +125,90 @@ def _new_section_problems(changelog: str, version: str, number: int) -> List[str
section = notes.find_section(changelog, version)
if section is None:
return [f"CHANGELOG.md:{number}: notes.py cannot find ## [{version}]"]
if not section.lead:
return []
problems = []
if len(section.lead) > notes.MAX_LEAD_LINES:
if not version.endswith(".0"):
problems.append(f"CHANGELOG.md:{number}: {version} has a lead block; only an X.Y.0 takes one")
if len(section.lead) > MAX_LEAD_LINES:
problems.append(f"CHANGELOG.md:{number}: the {version} lead block has {len(section.lead)} lines, "
f"over {notes.MAX_LEAD_LINES}")
with tempfile.TemporaryDirectory() as scratch:
source = Path(scratch, "CHANGELOG.md")
source.write_text(changelog, encoding="utf-8")
if not notes.whats_new(version, source, Path(scratch, "WhatsNew.md")):
problems.append(f"CHANGELOG.md:{number}: notes.py whats-new renders nothing for {version}")
f"over {MAX_LEAD_LINES}")
for line in section.lead:
if line.startswith(("#", "- ")):
problems.append(f"CHANGELOG.md:{number}: the {version} lead line `{line}` is not plain text")
return problems


def repository_problems(root: Path) -> List[str]:
types = fragment_types((root / "towncrier.toml").read_text(encoding="utf-8"))
if not types:
return ["towncrier.toml defines no [[tool.towncrier.type]]"]
return (changelog_problems((root / "CHANGELOG.md").read_text(encoding="utf-8"))
+ fragment_problems(root / FRAGMENTS, types))
+ fragment_problems(root / FRAGMENTS))


def _fragment_in(path: Optional[str], types: List[str]) -> Optional[Fragment]:
if path is None or not path.startswith(f"{FRAGMENTS}/"):
def _owner(path: str) -> Optional[str]:
if not path.startswith(f"{FRAGMENTS}/"):
return None
return parse_fragment_name(path[len(FRAGMENTS) + 1:], types)
return path[len(FRAGMENTS) + 1:].partition(".")[0]


def pull_request_problems(number: int, title: str, base: str, files: List[ChangedFile],
types: List[str]) -> List[str]:
# The pull request that creates changelog.d/ is the one that moves the old entries into it.
if any(changed.filename == f"{FRAGMENTS}/.gitkeep" and changed.status in NEW_STATUSES for changed in files):
return []
def pull_request_problems(number: int, title: str, base: str, files: List[ChangedFile]) -> List[str]:
problems = []
expected = f"{FRAGMENTS}/{number}.<type>.md"
if any("CHANGELOG.md" in (changed.filename, changed.previous_filename) for changed in files) \
and not RELEASE_TITLE.fullmatch(title):
if RELEASE_TITLE.fullmatch(title):
others = [changed.filename for changed in files
if changed.filename != "CHANGELOG.md" and _owner(changed.filename) is None]
if others:
more = f" and {len(others) - 1} more" if len(others) > 1 else ""
problems.append(f"a release pull request changes only CHANGELOG.md and {FRAGMENTS}/; "
f"this one also changes {others[0]}{more}")
elif any(changed.filename == "CHANGELOG.md" for changed in files):
problems.append(f"only a `chore(release): vX.Y.Z` pull request edits CHANGELOG.md. Put the entry in "
f"{expected}, with type one of {', '.join(types)} (.claude/rules/changelog.md)")
f"{FRAGMENTS}/{number}.<type>.md (.claude/rules/changelog.md)")
if base != "main":
return problems

# A fragment deleted and added again under a new type keeps the pull request it is named for.
removed = {fragment.owner for fragment in
(_fragment_in(changed.filename, types) for changed in files if changed.status == "removed")
if fragment is not None}
removed = {_owner(changed.filename) for changed in files if changed.status == "removed"}
for changed in files:
if changed.status not in NEW_STATUSES or not changed.filename.startswith(f"{FRAGMENTS}/"):
owner = _owner(changed.filename)
if changed.status != "added" or owner is None or changed.filename[len(FRAGMENTS) + 1:] in IGNORED:
continue
if changed.filename[len(FRAGMENTS) + 1:] in IGNORED:
continue
fragment = _fragment_in(changed.filename, types)
if fragment is None:
problems.append(f"{changed.filename} is not a fragment name. Name it {expected}")
elif fragment.owner != str(number) and fragment.owner not in removed:
counter = f".{fragment.counter}" if fragment.counter else ""
problems.append(f"{changed.filename} is named for another pull request. "
f"Name it {FRAGMENTS}/{number}.{fragment.kind}{counter}.md")
if owner != str(number) and owner not in removed:
rest = changed.filename[len(FRAGMENTS) + 1:].partition(".")[2] or "<type>.md"
problems.append(f"{changed.filename} is not named for this pull request. "
f"Name it {FRAGMENTS}/{number}.{rest}")
return problems


def _gh(run: Callable, endpoint: str, jq: str, paginate: bool = False) -> List[str]:
command = ["gh", "api", *(["--paginate"] if paginate else []), endpoint, "--jq", jq]
result = run(command, capture_output=True, text=True)
def changed_files(root: Path) -> List[ChangedFile]:
result = subprocess.run(["git", "diff", "--name-status", "--no-renames", "-z", "HEAD^1", "HEAD"],
cwd=root, capture_output=True, text=True)
if result.returncode != 0:
raise CheckError(f"gh api {endpoint}: {result.stderr.strip()}")
return [line for line in result.stdout.splitlines() if line.strip()]


def fetch_pull_request(number: int, repository: str,
run: Callable = subprocess.run) -> Tuple[str, str, List[ChangedFile]]:
endpoint = f"repos/{repository}/pulls/{number}"
details = json.loads(_gh(run, endpoint, "{title: .title, base: .base.ref} | @json")[0])
entries = _gh(run, f"{endpoint}/files", ".[] | {filename, status, previous_filename} | @json", paginate=True)
files = [ChangedFile(entry["filename"], entry["status"], entry.get("previous_filename"))
for entry in map(json.loads, entries)]
return details["title"], details["base"], files
raise CheckError(f"git diff HEAD^1 HEAD: {result.stderr.strip()}")
fields = result.stdout.split("\0")[:-1]
return [ChangedFile(path, GIT_STATUSES.get(status, "modified"))
for status, path in zip(fields[0::2], fields[1::2])]


def workflow_command_data(text: str) -> str:
return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")


def main(argv: Optional[List[str]] = None, run: Callable = subprocess.run) -> int:
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Check changelog.d/ and CHANGELOG.md.")
parser.add_argument("--pr", type=int, help="check the files this pull request changes instead")
parser.add_argument("--pr", type=int,
help="check the files this pull request changes instead, from its merge commit at HEAD")
parser.add_argument("--title", help="the pull request's title, with --pr")
parser.add_argument("--base", help="the branch the pull request merges into, with --pr")
parser.add_argument("--root", type=Path, default=ROOT)
args = parser.parse_args(argv)
if args.pr is not None and (args.title is None or args.base is None):
parser.error("--pr needs --title and --base")

try:
if args.pr is None:
problems = repository_problems(args.root)
else:
types = fragment_types((args.root / "towncrier.toml").read_text(encoding="utf-8"))
repository = os.environ.get("GITHUB_REPOSITORY", REPOSITORY)
title, base, files = fetch_pull_request(args.pr, repository, run)
problems = pull_request_problems(args.pr, title, base, files, types)
except (CheckError, OSError, ValueError, KeyError, IndexError) as error:
problems = pull_request_problems(args.pr, args.title, args.base, changed_files(args.root))
except (CheckError, OSError, ValueError) as error:
problems = [str(error)]

for problem in problems:
Expand Down
Loading
Loading