From 234feb4d09ae8e6c2d7e5ddd783acbead304ccc9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 18 Oct 2026 17:01:10 +0700 Subject: [PATCH] ci(changelog): let towncrier own fragment names and the release section --- .claude/rules/changelog.md | 7 +- .claude/rules/ci.md | 2 +- .claude/skills/release/SKILL.md | 6 +- .github/workflows/pr-title.yml | 7 +- RELEASING.md | 10 +- scripts/ci/check-changelog.py | 139 ++++++++------------ scripts/ci/test_check_changelog.py | 200 +++++++++++++++-------------- scripts/release/notes.py | 108 +++------------- scripts/release/test_notes.py | 87 +++---------- towncrier.toml | 15 +-- 10 files changed, 209 insertions(+), 372 deletions(-) diff --git a/.claude/rules/changelog.md b/.claude/rules/changelog.md index 06f688bd2a..25734f6f74 100644 --- a/.claude/rules/changelog.md +++ b/.claude/rules/changelog.md @@ -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/..md`: `` is the pull request that adds it, `` is `added`, `changed`, `deprecated`, `removed`, `fixed` or `security`. A second entry of the same type in one PR adds `.`: `3452.fixed.2.md`. +- **One file per user-visible change**, named `changelog.d/..md`: `` is the pull request that adds it, `` 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. diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index 7b78a38646..5d568f5d62 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -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`) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index e1ab0c8136..57e10f2cf8 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -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 @@ -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:>=" --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. diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 60fe33b74a..51f06489d9 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -12,7 +12,6 @@ concurrency: permissions: contents: read - pull-requests: read jobs: title: @@ -20,6 +19,7 @@ jobs: 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 @@ -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 }} diff --git a/RELEASING.md b/RELEASING.md index c49e71642e..9cfc878d76 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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`. It refuses a beta, a branch with commits beyond `origin/release/` 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`. 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 @@ -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 diff --git a/scripts/ci/check-changelog.py b/scripts/ci/check-changelog.py index a197d28acc..191feb2133 100755 --- a/scripts/ci/check-changelog.py +++ b/scripts/ci/check-changelog.py @@ -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") @@ -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[0-9]{4}-[0-9]{2}-[0-9]{2})" OLD_HEADING = re.compile(rf"## \[(?P{VERSION})\] - {DATE}") @@ -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[0-9]+|\+[a-z0-9][a-z0-9-]*)\.(?P{'|'.join(types)})(?:\.(?P[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 = [] @@ -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 ..md or ...md, with type one of " - + ", ".join(types)) try: text = path.read_text(encoding="utf-8") except UnicodeDecodeError: @@ -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}..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}..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 ".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: diff --git a/scripts/ci/test_check_changelog.py b/scripts/ci/test_check_changelog.py index eb27db3f36..8821d945bc 100644 --- a/scripts/ci/test_check_changelog.py +++ b/scripts/ci/test_check_changelog.py @@ -2,31 +2,30 @@ import importlib.util import io -import json +import os import subprocess import tempfile import unittest from contextlib import redirect_stdout from pathlib import Path +from unittest import mock SPEC = importlib.util.spec_from_file_location("check_changelog", Path(__file__).with_name("check-changelog.py")) check = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(check) -TYPES = ["added", "changed", "deprecated", "removed", "fixed", "security"] -TOWNCRIER = "[tool.towncrier]\ndirectory = \"changelog.d\"\n" + "".join( - f"\n[[tool.towncrier.type]]\ndirectory = \"{kind}\"\nname = \"{kind.title()}\"\nshowcontent = true\n" - for kind in TYPES -) LINK = "https://github.com/TableProApp/TablePro/releases/tag" HEADER = "# Changelog\n\nUnreleased changes are files in `changelog.d/`.\n\n\n\n" NEW = f"## [0.82.0]({LINK}/v0.82.0) - 2026-10-25\n\nOne lead line.\n\n### Added\n\n- A thing. ([#1](x))\n\n" OLD = "## [0.81.0] - 2026-10-16\n\n### Fixed\n\n- A bug.\n\n## [0.80.0] - 2026-10-11\n\n### Added\n\n- Another.\n" - - -class TypesTests(unittest.TestCase): - def test_reads_the_fragment_types_and_not_the_directory(self): - self.assertEqual(check.fragment_types(TOWNCRIER), TYPES) +GIT_ENV = { + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "Fixture", + "GIT_AUTHOR_EMAIL": "fixture@example.com", + "GIT_COMMITTER_NAME": "Fixture", + "GIT_COMMITTER_EMAIL": "fixture@example.com", +} class FragmentTests(unittest.TestCase): @@ -39,27 +38,18 @@ def setUp(self): def problems(self, name, text): (self.directory / name).write_text(text, encoding="utf-8") - return check.fragment_problems(self.directory, TYPES) + return check.fragment_problems(self.directory) def test_a_one_line_fragment_passes(self): self.assertEqual(self.problems("3446.added.md", "Generate UUID in Set Value.\n"), []) def test_counters_and_orphans_pass(self): - self.assertEqual(self.problems("3452.fixed.2.md", "A bug.\n"), []) + self.assertEqual(self.problems("3452.fixed.1.md", "A bug.\n"), []) self.assertEqual(self.problems("+theme-import.changed.md", "A change.\n"), []) def test_an_empty_directory_and_a_missing_one_pass(self): - self.assertEqual(check.fragment_problems(self.directory, TYPES), []) - self.assertEqual(check.fragment_problems(self.directory / "missing", TYPES), []) - - def test_a_bad_name_is_refused(self): - for name in ("3446.feature.md", "3446.added.txt", "3446.added", "abc.added.md", "3446.md", - "3446.added.0.md", "+Theme.added.md"): - with self.subTest(name=name): - problems = self.problems(name, "A thing.\n") - (self.directory / name).unlink() - self.assertEqual(len(problems), 1) - self.assertIn("is not named", problems[0]) + self.assertEqual(check.fragment_problems(self.directory), []) + self.assertEqual(check.fragment_problems(self.directory / "missing"), []) def test_more_than_one_line_is_refused(self): self.assertIn("2 non-blank lines", self.problems("1.added.md", "One.\nTwo.\n")[0]) @@ -82,7 +72,7 @@ def test_a_credit_is_refused(self): def test_a_directory_inside_is_refused(self): (self.directory / "added").mkdir() - self.assertEqual(check.fragment_problems(self.directory, TYPES), ["changelog.d/added is not a file"]) + self.assertEqual(check.fragment_problems(self.directory), ["changelog.d/added is not a file"]) class ChangelogTests(unittest.TestCase): @@ -115,7 +105,7 @@ def test_a_heading_above_the_marker_is_refused(self): def test_a_heading_in_neither_grammar_is_refused(self): for heading in ("## [Unreleased]", "## [0.82.0] 2026-10-25", "## [0.82.0-beta.1] - 2026-10-25", f"## [0.82.0]({LINK}/v0.82.1) - 2026-10-25", - f"## [0.82.0](https://example.com/v0.82.0) - 2026-10-25", + "## [0.82.0](https://example.com/v0.82.0) - 2026-10-25", "## [0.82.0] - 2026-10-25 [CRITICAL]"): with self.subTest(heading=heading): problems = check.changelog_problems(HEADER + heading + "\n\n" + OLD) @@ -154,6 +144,21 @@ def test_a_new_lead_block_takes_at_most_six_lines(self): self.assertEqual(len(problems), 1) self.assertIn("7 lines", problems[0]) + def test_a_patch_takes_no_lead_block(self): + patch = f"## [0.82.1]({LINK}/v0.82.1) - 2026-10-26\n\n### Fixed\n\n- A bug. ([#2](x))\n\n" + self.assertEqual(check.changelog_problems(HEADER + patch + NEW + OLD), []) + with_lead = patch.replace("### Fixed", "A lead.\n\n### Fixed") + problems = check.changelog_problems(HEADER + with_lead + NEW + OLD) + self.assertEqual(len(problems), 1) + self.assertIn("0.82.1 has a lead block", problems[0]) + + def test_a_lead_line_is_plain_text(self): + for line in ("- A list item.", "#### A heading"): + with self.subTest(line=line): + problems = check.changelog_problems(HEADER + NEW.replace("One lead line.", line) + OLD) + self.assertEqual(len(problems), 1) + self.assertIn("is not plain text", problems[0]) + def test_an_old_lead_block_is_not_limited(self): lead = "\n".join(f"Lead {index}." for index in range(9)) self.assertEqual(check.changelog_problems(HEADER + OLD.replace("### Fixed", lead + "\n\n### Fixed")), []) @@ -164,7 +169,6 @@ def setUp(self): self.scratch = tempfile.TemporaryDirectory() self.addCleanup(self.scratch.cleanup) self.root = Path(self.scratch.name) - (self.root / "towncrier.toml").write_text(TOWNCRIER, encoding="utf-8") (self.root / "CHANGELOG.md").write_text(HEADER + NEW + OLD, encoding="utf-8") (self.root / "changelog.d").mkdir() (self.root / "changelog.d" / "3446.added.md").write_text("A thing.\n", encoding="utf-8") @@ -185,23 +189,17 @@ def test_problems_from_both_files_are_annotations(self): self.assertEqual(status, 1) self.assertEqual(out.count("::error title=Changelog::"), 2) - def test_a_config_with_no_types_fails(self): - (self.root / "towncrier.toml").write_text("[tool.towncrier]\n", encoding="utf-8") - status, out = self.run_main() - self.assertEqual(status, 1) - self.assertIn("no [[tool.towncrier.type]]", out) - -def changed(filename, status="added", previous=None): - return check.ChangedFile(filename, status, previous) +def changed(filename, status="added"): + return check.ChangedFile(filename, status) class PullRequestTests(unittest.TestCase): def problems(self, files, title="fix(editor): keep the caret", base="main", number=3600): - return check.pull_request_problems(number, title, base, files, TYPES) + return check.pull_request_problems(number, title, base, files) def test_a_fragment_named_for_the_pull_request_passes(self): - files = [changed("changelog.d/3600.fixed.md"), changed("changelog.d/3600.fixed.2.md"), + files = [changed("changelog.d/3600.fixed.md"), changed("changelog.d/3600.fixed.1.md"), changed("TablePro/Editor.swift", "modified")] self.assertEqual(self.problems(files), []) @@ -215,6 +213,13 @@ def test_a_release_pull_request_edits_changelog(self): self.assertEqual(self.problems(files, "chore(release): v0.82.0", "release/0.82"), []) self.assertEqual(self.problems(files, "chore(release): v0.82.1 (#3601)"), []) + def test_a_release_pull_request_changes_nothing_else(self): + files = [changed("CHANGELOG.md", "modified"), changed("TablePro/x.swift", "modified"), + changed("changelog.d/3446.added.md", "removed"), changed("project.yml", "modified")] + problems = self.problems(files, "chore(release): v0.82.0", "release/0.82") + self.assertEqual(problems, ["a release pull request changes only CHANGELOG.md and changelog.d/; " + "this one also changes TablePro/x.swift and 1 more"]) + def test_titles_that_are_not_a_stable_release_cannot_edit_changelog(self): for title in ("chore(release): v0.82.0-beta.1", "chore(release): 0.82.0", "chore: v0.82.0", "chore(release): v0.82.0 and more", "chore(release): v0.82"): @@ -222,7 +227,7 @@ def test_titles_that_are_not_a_stable_release_cannot_edit_changelog(self): self.assertEqual(len(self.problems([changed("CHANGELOG.md", "modified")], title)), 1) def test_renaming_changelog_away_counts_as_editing_it(self): - self.assertEqual(len(self.problems([changed("CHANGES.md", "renamed", "CHANGELOG.md")])), 1) + self.assertEqual(len(self.problems([changed("CHANGELOG.md", "removed"), changed("CHANGES.md")])), 1) def test_a_fragment_named_for_another_pull_request_prints_the_expected_name(self): problems = self.problems([changed("changelog.d/3599.fixed.2.md")]) @@ -234,87 +239,92 @@ def test_an_orphan_fragment_on_main_is_refused(self): self.assertIn("Name it changelog.d/3600.fixed.md", problems[0]) def test_a_file_that_is_not_a_fragment_is_refused(self): - problems = self.problems([changed("changelog.d/notes.txt")]) - self.assertIn("Name it changelog.d/3600..md", problems[0]) + self.assertEqual(len(self.problems([changed("changelog.d/notes.txt")])), 1) + self.assertIn("Name it changelog.d/3600..md", self.problems([changed("changelog.d/notes")])[0]) def test_gitkeep_is_not_a_fragment(self): self.assertEqual(self.problems([changed("changelog.d/.gitkeep")]), []) - def test_the_pull_request_that_creates_the_directory_moves_the_old_entries(self): - files = [changed("changelog.d/.gitkeep"), changed("changelog.d/3446.fixed.md"), - changed("CHANGELOG.md", "modified")] - self.assertEqual(self.problems(files, "ci(release): release from tags through release.yml"), []) - self.assertEqual(len(self.problems(files[1:], "ci(release): release from tags through release.yml")), 2) - def test_a_pick_into_a_release_line_keeps_the_original_name(self): self.assertEqual(self.problems([changed("changelog.d/3446.fixed.md")], "fix: keep it (#3446)", "release/0.82"), []) - def test_editing_or_renaming_another_pull_requests_fragment_passes(self): - files = [changed("changelog.d/3446.fixed.md", "modified"), - changed("changelog.d/3452.changed.md", "renamed", "changelog.d/3452.fixed.md")] - self.assertEqual(self.problems(files), []) + def test_editing_another_pull_requests_fragment_passes(self): + self.assertEqual(self.problems([changed("changelog.d/3446.fixed.md", "modified")]), []) def test_retyping_another_pull_requests_fragment_keeps_its_number(self): files = [changed("changelog.d/3446.fixed.md", "removed"), changed("changelog.d/3446.changed.md")] self.assertEqual(self.problems(files), []) -class FakeRun: - def __init__(self, *results): - self.results = list(results) - self.commands = [] - - def __call__(self, command, **_): - self.commands.append(command) - return self.results.pop(0) - - -def completed(stdout="", returncode=0, stderr=""): - return subprocess.CompletedProcess([], returncode, stdout, stderr) - - -class FetchTests(unittest.TestCase): +class MergeCommitTests(unittest.TestCase): def setUp(self): - self.scratch = tempfile.TemporaryDirectory() - self.addCleanup(self.scratch.cleanup) - self.root = Path(self.scratch.name) - (self.root / "towncrier.toml").write_text(TOWNCRIER, encoding="utf-8") - - def run_main(self, run): + environment = mock.patch.dict(os.environ, GIT_ENV) + environment.start() + self.addCleanup(environment.stop) + scratch = tempfile.TemporaryDirectory() + self.addCleanup(scratch.cleanup) + self.root = Path(scratch.name) + self.git("init", "--quiet", "--initial-branch=main") + self.write("CHANGELOG.md", HEADER + OLD) + self.write("changelog.d/3446.fixed.md", "A bug.\n") + self.write("TablePro/x.swift", "let x = 1\n") + self.commit("base") + + def git(self, *args): + subprocess.run(["git", "-C", str(self.root), *args], check=True, capture_output=True) + + def write(self, path, text): + (self.root / path).parent.mkdir(parents=True, exist_ok=True) + (self.root / path).write_text(text, encoding="utf-8") + + def commit(self, message): + self.git("add", "--all") + self.git("commit", "--quiet", "--allow-empty", "-m", message) + + def run_main(self, title="fix: x", base="main"): captured = io.StringIO() with redirect_stdout(captured): - status = check.main(["--pr", "3600", "--root", str(self.root)], run) + status = check.main(["--pr", "3600", "--title", title, "--base", base, "--root", str(self.root)]) return status, captured.getvalue() - def test_reads_the_title_base_and_every_page_of_files(self): - files = "\n".join(json.dumps(entry) for entry in ( - {"filename": "CHANGELOG.md", "status": "modified", "previous_filename": None}, - {"filename": "changelog.d/1.added.md", "status": "renamed", "previous_filename": "changelog.d/1.fixed.md"}, - )) - run = FakeRun(completed(json.dumps({"title": "chore(release): v0.82.0", "base": "release/0.82"}) + "\n"), - completed(files + "\n")) - title, base, found = check.fetch_pull_request(3600, "TableProApp/TablePro", run) - self.assertEqual((title, base), ("chore(release): v0.82.0", "release/0.82")) - self.assertEqual(found, [changed("CHANGELOG.md", "modified"), - changed("changelog.d/1.added.md", "renamed", "changelog.d/1.fixed.md")]) - self.assertEqual(run.commands[0][:3], ["gh", "api", "repos/TableProApp/TablePro/pulls/3600"]) - self.assertEqual(run.commands[1][:4], ["gh", "api", "--paginate", "repos/TableProApp/TablePro/pulls/3600/files"]) - - def test_a_gh_failure_fails_the_check(self): - status, out = self.run_main(FakeRun(completed(returncode=1, stderr="HTTP 404"))) + def test_reads_the_changes_against_the_first_parent(self): + self.write("changelog.d/3600.fixed.md", "Another bug.\n") + (self.root / "changelog.d/3446.fixed.md").rename(self.root / "changelog.d/3446.changed.md") + (self.root / "CHANGELOG.md").unlink() + self.write("TablePro/x.swift", "let x = 2\n") + self.commit("the pull request") + self.assertEqual(sorted(check.changed_files(self.root)), [ + changed("CHANGELOG.md", "removed"), + changed("TablePro/x.swift", "modified"), + changed("changelog.d/3446.changed.md"), + changed("changelog.d/3446.fixed.md", "removed"), + changed("changelog.d/3600.fixed.md"), + ]) + status, out = self.run_main() self.assertEqual(status, 1) - self.assertIn("HTTP 404", out) + self.assertEqual(out.count("::error title=Changelog::"), 1) + self.assertIn("edits CHANGELOG.md", out) def test_a_clean_pull_request_passes(self): - files = json.dumps({"filename": "changelog.d/3600.fixed.md", "status": "added"}) - run = FakeRun(completed(json.dumps({"title": "fix: x", "base": "main"})), completed(files)) - self.assertEqual(self.run_main(run), (0, "OK: the changelog files of #3600\n")) + self.write("changelog.d/3600.fixed.md", "Another bug.\n") + self.commit("the pull request") + self.assertEqual(self.run_main(), (0, "OK: the changelog files of #3600\n")) + + def test_a_missing_parent_fails_the_check(self): + status, out = self.run_main() + self.assertEqual(status, 1) + self.assertIn("git diff HEAD^1 HEAD", out) + + def test_pr_needs_a_title_and_a_base(self): + with redirect_stdout(io.StringIO()), mock.patch("sys.stderr", io.StringIO()), \ + self.assertRaises(SystemExit): + check.main(["--pr", "3600", "--root", str(self.root)]) def test_an_annotation_cannot_inject_a_workflow_command(self): - files = json.dumps({"filename": "changelog.d/x\n::add-mask::secret", "status": "added"}) - run = FakeRun(completed(json.dumps({"title": "fix: x", "base": "main"})), completed(files)) - status, out = self.run_main(run) + self.write("changelog.d/x\n::add-mask::secret", "A bug.\n") + self.commit("the pull request") + status, out = self.run_main() self.assertEqual(status, 1) self.assertEqual(len(out.splitlines()), 1) self.assertIn("%0A::add-mask::secret", out) diff --git a/scripts/release/notes.py b/scripts/release/notes.py index f3ccb1d1d8..bdd49e242b 100755 --- a/scripts/release/notes.py +++ b/scripts/release/notes.py @@ -14,24 +14,16 @@ ROOT = Path(__file__).resolve().parent.parent.parent FRAGMENTS = "changelog.d" -MAX_LEAD_LINES = 6 -CRITICAL = " [CRITICAL]" -NO_LEAD = "This release is fixes and refinements. Every entry is in the changelog." DOCS_CHANGELOG = "https://docs.tablepro.app/changelog" -VENV_COMMAND = "python3 -m venv .venv && .venv/bin/pip install --require-hashes -r scripts/release/requirements.txt" -FRAGMENT_NAME = re.compile(r"([0-9]+)\.[a-z]+(?:\.[0-9]+)?\.md") SECTION_START = re.compile(r"## \[([^\]]+)\]") HEADING = re.compile( r"## \[(?P[^\]]+)\](?:\([^)]*\))?" r"(?: - (?P[0-9]{4}-[0-9]{2}-[0-9]{2}))?(?P \[CRITICAL\])?\s*" ) -FENCE = re.compile(r" {0,3}(`{3,}|~{3,})") DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}") MAINTAINER_ASSOCIATIONS = {"OWNER", "MEMBER"} DOCS_SECTIONS = {"Added": "New Features", "Changed": "Improvements", "Fixed": "Bug Fixes"} -MONTHS = ("January", "February", "March", "April", "May", "June", "July", "August", "September", - "October", "November", "December") UPDATE_BLOCK = re.compile(r'^\n.*?^$', re.MULTILINE | re.DOTALL) Author = Tuple[str, str] @@ -58,28 +50,18 @@ def body(self) -> str: return "\n\n".join(parts) -def _closes(fence: str, line: str) -> bool: - return re.fullmatch(r" {0,3}" + re.escape(fence[0]) + "{" + str(len(fence)) + r",}\s*", line) is not None - - def find_section(changelog: str, version: str) -> Optional[Section]: heading = None lines: List[str] = [] first_subsection = None - fence = None for line in changelog.splitlines(): - if fence is not None: - if _closes(fence, line): - fence = None - elif FENCE.match(line): - fence = FENCE.match(line).group(1) - elif SECTION_START.match(line): + if SECTION_START.match(line): if heading is not None: break if SECTION_START.match(line).group(1) == version: heading = line continue - elif heading is not None and first_subsection is None and line.startswith("### "): + if heading is not None and first_subsection is None and line.startswith("### "): first_subsection = len(lines) if heading is not None: lines.append(line.rstrip()) @@ -103,9 +85,10 @@ def whats_new(version: str, changelog: Path, out: Path) -> bool: if out.exists(): out.unlink() return False - lead = section.lead[:MAX_LEAD_LINES] - body = "\n\n".join(lead) if lead else NO_LEAD - out.write_text(f"# TablePro {version}\n\n{body}\n", encoding="utf-8") + text = f"# TablePro {version}\n" + if section.lead: + text += "\n" + "\n\n".join(section.lead) + "\n" + out.write_text(text, encoding="utf-8") return True @@ -124,11 +107,11 @@ def credit_fragments(directory: Path, author_of: Callable[[int], Author]) -> Non if not directory.is_dir(): return for path in sorted(directory.iterdir()): - match = FRAGMENT_NAME.fullmatch(path.name) - if not match: + issue = path.name.partition(".")[0] + if not issue.isdecimal(): continue text = path.read_text(encoding="utf-8") - credited = credit(text, author_of(int(match.group(1)))) + credited = credit(text, author_of(int(issue))) if credited != text: path.write_text(credited, encoding="utf-8") @@ -162,12 +145,9 @@ def fragment_files(root: Path) -> List[Path]: def towncrier_command() -> List[str]: - if importlib.util.find_spec("towncrier") is not None: - return [sys.executable, "-m", "towncrier"] - local = ROOT / ".venv" / "bin" / "towncrier" - if local.exists(): - return [str(local)] - raise NotesError(f"towncrier is not installed. Run: {VENV_COMMAND}") + if importlib.util.find_spec("towncrier") is None: + raise NotesError("towncrier is not installed; run notes.py with .venv/bin/python (RELEASING.md, Notes)") + return [sys.executable, "-m", "towncrier"] def towncrier(root: Path, *args: str, run: Callable = subprocess.run) -> str: @@ -212,15 +192,14 @@ def feed(root: Path, version: str, author_of: Callable[[int], Author]) -> str: link = f"[View release notes](https://github.com/{_repository()}/releases/tag/{parsed.tag})" return f"{title}\n\n{draft(root, version, author_of)}\n\n{link}\n" section = stable_section(root, version) - lead = section.lead[:MAX_LEAD_LINES] - body = "\n\n".join(lead) if lead else section.body + body = "\n\n".join(section.lead) if section.lead else section.body return f"{title}\n\n{body}\n\n[View full changelog]({DOCS_CHANGELOG})\n" def lead(root: Path, version: str) -> str: if _parse(version).is_beta: return "" - lines = stable_section(root, version).lead[:MAX_LEAD_LINES] + lines = stable_section(root, version).lead return "\n".join(lines) + "\n" if lines else "" @@ -230,44 +209,16 @@ def critical(root: Path, version: str) -> bool: return stable_section(root, version).is_critical -def read_lead(path: Optional[Path]) -> List[str]: - if path is None: - return [] - lines = [line.rstrip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] - if len(lines) > MAX_LEAD_LINES: - raise NotesError(f"{path} has {len(lines)} lines; a lead block has at most {MAX_LEAD_LINES}") - for line in lines: - if line.startswith("#") or line.startswith("- "): - raise NotesError(f"a lead line is plain text, not a heading or a list item: {line}") - return lines - - def git(root: Path, *args: str, run: Callable = subprocess.run) -> subprocess.CompletedProcess: return run(["git", "-C", str(root), *args], capture_output=True, text=True) -def assemble(root: Path, version: str, author_of: Callable[[int], Author], lead_lines: List[str], - is_critical: bool, date: str, run: Callable = subprocess.run) -> None: - # towncrier compares the fragment paths it lists with the ones git reports, which are resolved. - root = root.resolve() - parsed = _parse(version) - if parsed.is_beta: +def assemble(root: Path, version: str, author_of: Callable[[int], Author], date: str, + run: Callable = subprocess.run) -> None: + if _parse(version).is_beta: raise NotesError(f"{version} is a beta; a beta is a tag with nothing committed") - if lead_lines and parsed.patch != 0: - raise NotesError("a lead block is for X.Y.0 only") if not DATE.fullmatch(date): raise NotesError(f"--date takes YYYY-MM-DD, got {date}") - - branch = f"origin/release/{parsed.line}" - if git(root, "rev-parse", "--quiet", "--verify", f"{branch}^{{commit}}", run=run).returncode != 0: - raise NotesError(f"{branch} does not exist; fetch it first") - if git(root, "merge-base", "--is-ancestor", branch, "HEAD", run=run).returncode != 0: - raise NotesError(f"HEAD does not contain {branch}; branch prep/{parsed.tag} from it") - subjects = git(root, "log", "--format=%s", f"{branch}..HEAD", run=run).stdout.splitlines() - # Reverting a stable that never published is the one commit allowed before the notes. - extra = [subject for subject in subjects if not subject.startswith('Revert "')] - if extra: - raise NotesError(f"HEAD has commits that are not on {branch}: {extra[0]}") if not fragment_files(root): raise NotesError(f"{FRAGMENTS}/ has no fragments") @@ -276,21 +227,7 @@ def assemble(root: Path, version: str, author_of: Callable[[int], Author], lead_ raise NotesError(f"CHANGELOG.md already has ## [{version}]") credit_fragments(root / FRAGMENTS, author_of) - towncrier(root, "build", "--yes", "--config", str(root / "towncrier.toml"), "--dir", str(root), - "--version", version, "--date", date, run=run) - - lines = changelog_path.read_text(encoding="utf-8").split("\n") - index = next((i for i, line in enumerate(lines) if line.startswith(f"## [{version}]")), None) - if index is None: - raise NotesError(f"towncrier wrote no ## [{version}] heading; check start_string in towncrier.toml") - if is_critical: - lines[index] += CRITICAL - if lead_lines: - lines[index + 1:index + 1] = ["", *lead_lines] - changelog_path.write_text("\n".join(lines), encoding="utf-8") - added = git(root, "add", "CHANGELOG.md", run=run) - if added.returncode != 0: - raise NotesError(f"git add CHANGELOG.md: {added.stderr.strip()}") + towncrier(root, "build", "--yes", "--version", version, "--date", date, run=run) def _mdx(text: str, repository: str) -> str: @@ -306,8 +243,8 @@ def _mdx(text: str, repository: str) -> str: def docs_date(date: str) -> str: - year, month, day = (int(part) for part in date.split("-")) - return f"{MONTHS[month - 1]} {day}, {year}" + day = datetime.date.fromisoformat(date) + return f"{day:%B} {day.day}, {day.year}" def render_update(section: Section, repository: str) -> str: @@ -382,8 +319,6 @@ def main(argv: Optional[List[str]] = None) -> int: command = commands.add_parser("assemble", help="consume the fragments into a CHANGELOG.md section") command.add_argument("version") - command.add_argument("--critical", action="store_true") - command.add_argument("--lead", type=Path, help="a file of at most 6 lines, X.Y.0 only") command.add_argument("--date", default=datetime.date.today().isoformat()) for name, text in (("body", "the GitHub release body"), ("feed", "the Sparkle release notes"), ("lead", "the lead lines"), ("critical", "true or false")): @@ -402,8 +337,7 @@ def main(argv: Optional[List[str]] = None) -> int: if args.command == "whats-new": whats_new(args.version, args.changelog, args.out) elif args.command == "assemble": - assemble(ROOT, args.version, PullRequestAuthors(_repository()), read_lead(args.lead), - args.critical, args.date) + assemble(ROOT, args.version, PullRequestAuthors(_repository()), args.date) print(f"CHANGELOG.md has ## [{args.version}]; review it with git diff --cached") elif args.command == "body": sys.stdout.write(release_body(ROOT, args.version, PullRequestAuthors(_repository()))) diff --git a/scripts/release/test_notes.py b/scripts/release/test_notes.py index 58a862ea78..e1d0a0327d 100644 --- a/scripts/release/test_notes.py +++ b/scripts/release/test_notes.py @@ -48,11 +48,6 @@ - Hang on connect. -``` -## [9.9.9] inside a fence -### Not a subsection -``` - ## [0.81.0] - 2026-10-16 Old lead line. @@ -116,12 +111,6 @@ def test_an_old_heading_still_parses(self): section = notes.find_section(CHANGELOG, "0.81.0") self.assertEqual((section.date, section.is_critical, section.lead), ("2026-10-16", False, ["Old lead line."])) - def test_a_fence_neither_ends_the_section_nor_starts_a_subsection(self): - section = notes.find_section(CHANGELOG, "0.81.1") - self.assertEqual(section.lead, []) - self.assertIn("## [9.9.9] inside a fence", section.entries) - self.assertIsNone(notes.find_section(CHANGELOG, "9.9.9")) - def test_the_body_drops_only_the_heading(self): body = notes.find_section(CHANGELOG, "0.82.0").body self.assertTrue(body.startswith( @@ -183,9 +172,9 @@ def test_the_lead_becomes_the_window(self): self.assertEqual(self.out.read_text(encoding="utf-8"), "# TablePro 0.82.0\n\nBeta updates from a signed feed.\n\nPlugins update with the app.\n") - def test_no_lead_writes_a_pointer(self): + def test_no_lead_writes_only_the_heading(self): notes.whats_new("0.81.1", self.changelog, self.out) - self.assertEqual(self.out.read_text(encoding="utf-8"), f"# TablePro 0.81.1\n\n{notes.NO_LEAD}\n") + self.assertEqual(self.out.read_text(encoding="utf-8"), "# TablePro 0.81.1\n") def test_no_section_writes_nothing_and_removes_a_stale_file(self): self.out.write_text("stale", encoding="utf-8") @@ -253,26 +242,6 @@ def run(command, **_): notes.PullRequestAuthors("Owner/Repo", run=run)(12) -class LeadFileTests(unittest.TestCase): - def read(self, text): - with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as handle: - handle.write(text) - self.addCleanup(os.unlink, handle.name) - return notes.read_lead(Path(handle.name)) - - def test_blank_lines_are_dropped(self): - self.assertEqual(self.read("One.\n\nTwo.\n"), ["One.", "Two."]) - - def test_more_than_six_lines_are_refused(self): - with self.assertRaisesRegex(notes.NotesError, "at most 6"): - self.read("".join(f"Line {n}.\n" for n in range(7))) - - def test_headings_and_list_items_are_refused(self): - for text in ("### Added\n", "- An entry.\n"): - with self.subTest(text=text), self.assertRaises(notes.NotesError): - self.read(text) - - class DocsTests(unittest.TestCase): MDX = ( '---\ntitle: "Changelog"\n---\n\n' @@ -372,45 +341,26 @@ def setUp(self): self.repo = FixtureRepository(Path(directory.name)) self.repo.fragment("3500.added.md", "Beta channel.") self.repo.commit("feat: beta channel") - self.repo.git("update-ref", "refs/remotes/origin/release/0.82", "HEAD") - def assemble(self, version="0.82.0", lead_lines=(), date="2026-10-20"): - notes.assemble(self.repo.path, version, authors({}), list(lead_lines), False, date) + def assemble(self, version="0.82.0", date="2026-10-20"): + notes.assemble(self.repo.path, version, authors({}), date) def test_refusals(self): cases = [ ({"version": "0.82.0-beta.1"}, "a beta is a tag"), - ({"version": "0.82.1", "lead_lines": ["A lead."]}, "X.Y.0 only"), ({"date": "20 Oct"}, "YYYY-MM-DD"), - ({"version": "0.83.0"}, "origin/release/0.83 does not exist"), + ({"version": "0.81.0"}, "already has ## \\[0.81.0\\]"), ] for arguments, message in cases: with self.subTest(arguments=arguments), self.assertRaisesRegex(notes.NotesError, message): self.assemble(**arguments) - def test_a_commit_beyond_the_line_is_refused(self): - self.repo.commit("fix: not picked") - with self.assertRaisesRegex(notes.NotesError, "not on origin/release/0.82: fix: not picked"): - self.assemble() - - def test_a_head_without_the_line_is_refused(self): - self.repo.git("checkout", "--quiet", "--orphan", "elsewhere") - self.repo.commit("unrelated") - with self.assertRaisesRegex(notes.NotesError, "HEAD does not contain origin/release/0.82"): - self.assemble() - def test_no_fragments_is_refused(self): self.repo.git("rm", "--quiet", f"{notes.FRAGMENTS}/3500.added.md") self.repo.commit("chore: drop") - self.repo.git("update-ref", "refs/remotes/origin/release/0.82", "HEAD") with self.assertRaisesRegex(notes.NotesError, "no fragments"): self.assemble() - def test_an_assembled_version_is_refused(self): - self.repo.git("update-ref", "refs/remotes/origin/release/0.81", "HEAD") - with self.assertRaisesRegex(notes.NotesError, "already has ## \\[0.81.0\\]"): - notes.assemble(self.repo.path, "0.81.0", authors({}), [], False, "2026-10-20") - @unittest.skipIf(towncrier_missing(), "towncrier is not installed") class TowncrierTests(unittest.TestCase): @@ -423,7 +373,6 @@ def setUp(self): self.repo.fragment("3501.fixed.1.md", "Hang on close.") self.repo.fragment("+tidy.changed.md", "Quieter logs.") self.repo.commit("feat: fragments") - self.repo.git("update-ref", "refs/remotes/origin/release/0.82", "HEAD") self.author_of = authors({3500: ("i87ce", "CONTRIBUTOR"), 3501: ("datlechin", "OWNER")}) def test_a_beta_body_is_the_credited_draft_without_a_heading(self): @@ -455,39 +404,33 @@ def test_a_beta_feed_links_the_github_release(self): "\n\n[View release notes](https://github.com/TableProApp/TablePro/releases/tag/v0.82.0-beta.1)\n")) def test_a_bad_fragment_name_fails_the_draft(self): - self.repo.fragment("3502.feature.md", "Wrong type.") self.author_of = authors({3500: ("i87ce", "CONTRIBUTOR"), 3501: ("a", "OWNER"), 3502: ("a", "OWNER")}) - with self.assertRaisesRegex(notes.NotesError, "3502.feature.md"): - notes.release_body(self.repo.path, "0.82.0-beta.1", self.author_of) + for name, message in (("3502.feature.md", "3502.feature.md"), ("abc.fixed.md", "'abc' does not match")): + with self.subTest(name=name): + self.repo.fragment(name, "Wrong name.") + with self.assertRaisesRegex(notes.NotesError, message): + notes.release_body(self.repo.path, "0.82.0-beta.1", self.author_of) + (self.repo.path / notes.FRAGMENTS / name).unlink() def test_assemble_writes_the_section_and_consumes_the_fragments(self): - notes.assemble(self.repo.path, "0.82.0", self.author_of, ["Beta updates.", "Plugins follow the app."], - True, "2026-10-20") + notes.assemble(self.repo.path, "0.82.0", self.author_of, "2026-10-20") changelog = (self.repo.path / "CHANGELOG.md").read_text(encoding="utf-8") self.assertIn( f"{MARKER}\n\n" - "## [0.82.0](https://github.com/TableProApp/TablePro/releases/tag/v0.82.0) - 2026-10-20 [CRITICAL]\n\n" - "Beta updates.\nPlugins follow the app.\n\n### Added\n\n" + "## [0.82.0](https://github.com/TableProApp/TablePro/releases/tag/v0.82.0) - 2026-10-20\n\n" + "### Added\n\n" "- Beta channel by @i87ce. ([#3500](https://github.com/TableProApp/TablePro/pull/3500))\n", changelog, ) self.assertIn("\n## [0.81.0] - 2026-10-16\n", changelog) section = notes.find_section(changelog, "0.82.0") - self.assertTrue(section.is_critical) - self.assertEqual(section.lead, ["Beta updates.", "Plugins follow the app."]) + self.assertEqual((section.date, section.is_critical, section.lead), ("2026-10-20", False, [])) self.assertEqual(sorted(path.name for path in (self.repo.path / notes.FRAGMENTS).iterdir()), [".gitkeep"]) staged = self.repo.git("diff", "--cached", "--name-status").splitlines() self.assertIn("M\tCHANGELOG.md", staged) self.assertIn(f"D\t{notes.FRAGMENTS}/3500.added.md", staged) self.assertEqual(self.repo.git("diff", "--name-only"), "") - def test_assemble_after_a_revert_of_an_unpublished_stable(self): - self.repo.commit('Revert "chore(release): v0.82.0"') - notes.assemble(self.repo.path, "0.82.1", self.author_of, [], False, "2026-10-21") - changelog = (self.repo.path / "CHANGELOG.md").read_text(encoding="utf-8") - section = notes.find_section(changelog, "0.82.1") - self.assertEqual((section.date, section.is_critical, section.lead), ("2026-10-21", False, [])) - if __name__ == "__main__": unittest.main() diff --git a/towncrier.toml b/towncrier.toml index 01bcd5d993..68c66d313f 100644 --- a/towncrier.toml +++ b/towncrier.toml @@ -5,34 +5,23 @@ start_string = "\n" title_format = "## [{version}](https://github.com/TableProApp/TablePro/releases/tag/v{version}) - {project_date}" issue_format = "[#{issue}](https://github.com/TableProApp/TablePro/pull/{issue})" underlines = ["", "", ""] -ignore = [".gitkeep"] +ignore = [] +issue_pattern = "\\d+" [[tool.towncrier.type]] -directory = "added" name = "Added" -showcontent = true [[tool.towncrier.type]] -directory = "changed" name = "Changed" -showcontent = true [[tool.towncrier.type]] -directory = "deprecated" name = "Deprecated" -showcontent = true [[tool.towncrier.type]] -directory = "removed" name = "Removed" -showcontent = true [[tool.towncrier.type]] -directory = "fixed" name = "Fixed" -showcontent = true [[tool.towncrier.type]] -directory = "security" name = "Security" -showcontent = true