From f4fbff7bee4c6ce251d2ec18abab8fb0596ced2e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 18 Oct 2026 00:11:25 +0700 Subject: [PATCH 1/2] build(release): derive versions and notes from tags and fragments --- .claude/rules/ci.md | 1 + .github/workflows/repo-hygiene.yml | 21 ++ scripts/release/notes.py | 423 +++++++++++++++++++++++++ scripts/release/requirements.txt | 255 +++++++++++++++ scripts/release/test_notes.py | 482 +++++++++++++++++++++++++++++ scripts/release/test_version.py | 315 +++++++++++++++++++ scripts/release/version.py | 314 +++++++++++++++++++ towncrier.toml | 38 +++ 8 files changed, 1849 insertions(+) create mode 100755 scripts/release/notes.py create mode 100644 scripts/release/requirements.txt create mode 100644 scripts/release/test_notes.py create mode 100644 scripts/release/test_version.py create mode 100755 scripts/release/version.py create mode 100644 towncrier.toml diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index f1d243c3fd..188ddc7824 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -75,6 +75,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - actionlint, which also shellchecks every inline `run:` block; - `shellcheck --severity=warning` on every `*.sh` under `scripts` and `.claude/skills`; - the tooling tests: `scripts/ci/test_*.py`, `scripts/lib/test_*.py`, `scripts/test_localization.py`, `.github/scripts/test_update_registry.py` and two shell tests; + - `scripts/release/test_*.py` on Python 3.9 from `actions/setup-python`, in a venv that installs `scripts/release/requirements.txt` with `pip --require-hashes`; - `check-lib-sourcing.py`, `check-plugin-manifest.py`, `check-deployment-target.sh`, `check-ios-shared-isolation.py`; - `localization.py plugins` and `plugins --target ios`; - `check-string-catalogs.py`, `check-license-inventory.py`, `check-entitlements.py`, `check-test-plans.py`; diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index b74ab2acba..6606550f8d 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -54,6 +54,27 @@ jobs: bash scripts/ci/test_draft_release.sh bash scripts/ci/test_libs_release.sh + # 3.9 is the macOS /usr/bin/python3 that runs notes.py in the build. The other steps keep the + # runner's Python, which has PyYAML. + - name: Set up Python 3.9 for the release tooling + id: python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.9" + update-environment: false + + - name: Install the release tooling + env: + PYTHON: ${{ steps.python.outputs.python-path }} + run: | + set -euo pipefail + "$PYTHON" -m venv "$RUNNER_TEMP/release-venv" + "$RUNNER_TEMP/release-venv/bin/pip" install --quiet --disable-pip-version-check --require-hashes \ + -r scripts/release/requirements.txt + + - name: Test the release tooling + run: '"$RUNNER_TEMP/release-venv/bin/python" -m unittest discover -s scripts/release -p "test_*.py"' + # shellcheck cannot tell a library function from a command, so a missing source is exit 127 # at release time (v0.67.1). - name: Check every script sources the libraries it calls into diff --git a/scripts/release/notes.py b/scripts/release/notes.py new file mode 100755 index 0000000000..4ab38840f1 --- /dev/null +++ b/scripts/release/notes.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 + +import argparse +import datetime +import importlib.util +import json +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Callable, Dict, List, NamedTuple, Optional, Tuple + +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] + + +class NotesError(Exception): + pass + + +class Section(NamedTuple): + version: str + date: Optional[str] + is_critical: bool + lead: List[str] + entries: List[str] + + @property + def body(self) -> str: + parts = [] + if self.lead: + parts.append("\n".join(self.lead)) + if self.entries: + parts.append("\n".join(self.entries).strip("\n")) + 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 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("### "): + first_subsection = len(lines) + if heading is not None: + lines.append(line.rstrip()) + if heading is None: + return None + if first_subsection is None: + first_subsection = len(lines) + details = HEADING.fullmatch(heading) + return Section( + version=version, + date=details.group("date") if details else None, + is_critical=bool(details and details.group("critical")), + lead=[line for line in lines[:first_subsection] if line.strip()], + entries=lines[first_subsection:], + ) + + +def whats_new(version: str, changelog: Path, out: Path) -> bool: + section = find_section(changelog.read_text(encoding="utf-8"), version) + if section is None: + 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") + return True + + +def credit(text: str, author: Author) -> str: + login, association = author + if association in MAINTAINER_ASSOCIATIONS or " by @" in text: + return text + body = text.rstrip() + trailing = text[len(body):] + if body.endswith("."): + return f"{body[:-1]} by @{login}.{trailing}" + return f"{body} by @{login}{trailing}" + + +def credit_fragments(directory: Path, author_of: Callable[[int], Author]) -> None: + if not directory.is_dir(): + return + for path in sorted(directory.iterdir()): + match = FRAGMENT_NAME.fullmatch(path.name) + if not match: + continue + text = path.read_text(encoding="utf-8") + credited = credit(text, author_of(int(match.group(1)))) + if credited != text: + path.write_text(credited, encoding="utf-8") + + +class PullRequestAuthors: + def __init__(self, repository: str, run: Callable = subprocess.run): + self.repository = repository + self._run = run + self._cache: Dict[int, Author] = {} + + def __call__(self, number: int) -> Author: + if number not in self._cache: + result = self._run( + ["gh", "api", f"repos/{self.repository}/pulls/{number}", + "--jq", "{login: .user.login, association: .author_association}"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise NotesError(f"gh could not read pull request #{number}: {result.stderr.strip()}") + fields = json.loads(result.stdout) + self._cache[number] = (fields["login"], fields["association"]) + return self._cache[number] + + +def fragment_files(root: Path) -> List[Path]: + directory = root / FRAGMENTS + if not directory.is_dir(): + return [] + return sorted(path for path in directory.iterdir() if path.is_file() and path.name != ".gitkeep") + + +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}") + + +def towncrier(root: Path, *args: str, run: Callable = subprocess.run) -> str: + result = run([*towncrier_command(), *args], cwd=root, capture_output=True, text=True) + if result.returncode != 0: + raise NotesError(f"towncrier {args[0]} failed:\n{result.stderr.strip() or result.stdout.strip()}") + return result.stdout + + +def draft(root: Path, version: str, author_of: Callable[[int], Author]) -> str: + with tempfile.TemporaryDirectory() as scratch: + scratch_root = Path(scratch) + shutil.copy2(root / "towncrier.toml", scratch_root / "towncrier.toml") + if (root / FRAGMENTS).is_dir(): + shutil.copytree(root / FRAGMENTS, scratch_root / FRAGMENTS) + credit_fragments(scratch_root / FRAGMENTS, author_of) + rendered = towncrier(root, "build", "--draft", "--config", str(scratch_root / "towncrier.toml"), + "--dir", str(scratch_root), "--version", version) + lines = rendered.strip("\n").splitlines() + if lines and SECTION_START.match(lines[0]): + lines = lines[1:] + return "\n".join(lines).strip("\n") + + +def stable_section(root: Path, version: str) -> Section: + section = find_section((root / "CHANGELOG.md").read_text(encoding="utf-8"), version) + if section is None: + raise NotesError(f"CHANGELOG.md has no ## [{version}]") + return section + + +def release_body(root: Path, version: str, author_of: Callable[[int], Author]) -> str: + if _parse(version).is_beta: + return draft(root, version, author_of) + "\n" + return stable_section(root, version).body + "\n" + + +def feed(root: Path, version: str, author_of: Callable[[int], Author]) -> str: + parsed = _parse(version) + title = f"# What's New in TablePro {version}" + if parsed.is_beta: + 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 + 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] + return "\n".join(lines) + "\n" if lines else "" + + +def critical(root: Path, version: str) -> bool: + if _parse(version).is_beta: + return False + 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: + 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") + + changelog_path = root / "CHANGELOG.md" + if find_section(changelog_path.read_text(encoding="utf-8"), version) is not None: + 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()}") + + +def _link(text: str, repository: str) -> str: + pieces = re.split(r"(`+[^`]*`+)", text) + for index in range(0, len(pieces), 2): + piece = re.sub(r"(? str: + year, month, day = (int(part) for part in date.split("-")) + return f"{MONTHS[month - 1]} {day}, {year}" + + +def render_update(section: Section, repository: str) -> str: + if section.date is None: + raise NotesError(f"## [{section.version}] has no date") + lines = [f''] + for line in section.lead: + lines += [f" {_link(line, repository)}", ""] + for line in section.entries: + subsection = re.fullmatch(r"### (.+?)\s*", line) + if subsection: + name = subsection.group(1) + lines.append(f" ### {DOCS_SECTIONS.get(name, name)}") + elif line.strip(): + lines.append(f" {_link(line, repository)}") + else: + lines.append("") + while lines and not lines[-1]: + lines.pop() + lines.append("") + return "\n".join(lines) + + +def merge_update(mdx: str, version: str, block: str) -> str: + matches = list(UPDATE_BLOCK.finditer(mdx)) + if not matches: + return mdx.rstrip("\n") + "\n\n" + block + "\n" + for previous, following in zip(matches, matches[1:]): + if mdx[previous.end():following.start()].strip(): + raise NotesError(f"text between the v{previous.group(1)} and v{following.group(1)} blocks") + blocks: Dict[str, str] = {} + for match in matches: + if match.group(1) in blocks: + raise NotesError(f"two v{match.group(1)} blocks") + blocks[match.group(1)] = match.group(0) + blocks[version] = block + try: + ordered = sorted(blocks, key=lambda label: _parse(label).build, reverse=True) + except ValueError as error: + raise NotesError(f"an Update label is not a version: {error}") from error + return mdx[:matches[0].start()] + "\n\n".join(blocks[label] for label in ordered) + mdx[matches[-1].end():] + + +def docs(root: Path, version: str, ref: str, mdx: Path, run: Callable = subprocess.run) -> None: + if _parse(version).is_beta: + raise NotesError(f"{version} is a beta; the docs changelog lists stables only") + shown = git(root, "show", f"{ref}:CHANGELOG.md", run=run) + if shown.returncode != 0: + raise NotesError(f"git show {ref}:CHANGELOG.md: {shown.stderr.strip()}") + section = find_section(shown.stdout, version) + if section is None: + raise NotesError(f"CHANGELOG.md at {ref} has no ## [{version}]") + block = render_update(section, _repository()) + mdx.write_text(merge_update(mdx.read_text(encoding="utf-8"), version, block), encoding="utf-8") + + +# Imported on use: the What's New build phase runs this file in Xcode's script sandbox, which +# lets it read only its declared inputs, and version.py is not one of them. +def _parse(version: str): + from version import parse_version + return parse_version(version) + + +def _repository() -> str: + from version import REPOSITORY + return REPOSITORY + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Release notes from CHANGELOG.md and changelog.d/.") + commands = parser.add_subparsers(dest="command", required=True) + + 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")): + commands.add_parser(name, help=text).add_argument("version") + command = commands.add_parser("whats-new", help="write the bundle's WhatsNew.md") + command.add_argument("version") + command.add_argument("--changelog", type=Path, required=True) + command.add_argument("--out", type=Path, required=True) + command = commands.add_parser("docs", help="insert the version's block into the docs changelog.mdx") + command.add_argument("version") + command.add_argument("--ref", required=True) + command.add_argument("--mdx", type=Path, required=True) + args = parser.parse_args(argv) + + try: + 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) + 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()))) + elif args.command == "feed": + sys.stdout.write(feed(ROOT, args.version, PullRequestAuthors(_repository()))) + elif args.command == "lead": + sys.stdout.write(lead(ROOT, args.version)) + elif args.command == "critical": + print("true" if critical(ROOT, args.version) else "false") + elif args.command == "docs": + docs(ROOT, args.version, args.ref, args.mdx) + except (NotesError, ValueError, OSError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release/requirements.txt b/scripts/release/requirements.txt new file mode 100644 index 0000000000..d823c47b0a --- /dev/null +++ b/scripts/release/requirements.txt @@ -0,0 +1,255 @@ +# This file was autogenerated by uv via the following command: +# echo 'towncrier==26.9.0' | uv pip compile --universal --python-version 3.9 --generate-hashes -o scripts/release/requirements.txt - +click==8.1.8 ; python_full_version < '3.10' \ + --hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \ + --hash=sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a + # via towncrier +click==8.5.0 ; python_full_version >= '3.10' \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \ + --hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34 + # via towncrier +colorama==0.4.6 ; python_full_version < '3.10' and sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via click +importlib-metadata==8.7.1 ; python_full_version < '3.10' \ + --hash=sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb \ + --hash=sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151 + # via towncrier +importlib-resources==6.5.2 ; python_full_version < '3.10' \ + --hash=sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c \ + --hash=sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec + # via towncrier +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via towncrier +markupsafe==3.0.4 \ + --hash=sha256:007e1ffd9bf65bb6ee96df7b258fc632a4868dd5566037986c64781f35a36e98 \ + --hash=sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002 \ + --hash=sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b \ + --hash=sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653 \ + --hash=sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c \ + --hash=sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e \ + --hash=sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc \ + --hash=sha256:0764a13d34cae40db7bbf3a09b7e9b491bf4603e20b263a7a9d6b8e324975d0a \ + --hash=sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92 \ + --hash=sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f \ + --hash=sha256:0cee7cb0f9a1b6892ea482237d9403b3d1b4603aee057d0ff01f0fac2d019a97 \ + --hash=sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4 \ + --hash=sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7 \ + --hash=sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691 \ + --hash=sha256:14bd2d845d62ab678eaf81da89d7b621b51756c72346745c1a594c09d49207a2 \ + --hash=sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc \ + --hash=sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde \ + --hash=sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99 \ + --hash=sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9 \ + --hash=sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df \ + --hash=sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5 \ + --hash=sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17 \ + --hash=sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8 \ + --hash=sha256:2a6ef68ae94aed8721934072b27a3b654ea2100b97e4ab864cf1489c90926fbc \ + --hash=sha256:2b2b1e18af909b448bb3cf9e3433366f7a8726271fc214e8b10e0f62a78c724b \ + --hash=sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea \ + --hash=sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248 \ + --hash=sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741 \ + --hash=sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5 \ + --hash=sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6 \ + --hash=sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7 \ + --hash=sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1 \ + --hash=sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67 \ + --hash=sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f \ + --hash=sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9 \ + --hash=sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c \ + --hash=sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc \ + --hash=sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba \ + --hash=sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17 \ + --hash=sha256:3d23795802fc8bd72534836d64489bbf0f67c088959091bdb22e10735a5107bf \ + --hash=sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6 \ + --hash=sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2 \ + --hash=sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163 \ + --hash=sha256:4a540e2d3192792fc84eced57bef37851ccb2b41f73291bb17408eea77bcd278 \ + --hash=sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d \ + --hash=sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b \ + --hash=sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634 \ + --hash=sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38 \ + --hash=sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed \ + --hash=sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c \ + --hash=sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148 \ + --hash=sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a \ + --hash=sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7 \ + --hash=sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f \ + --hash=sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811 \ + --hash=sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e \ + --hash=sha256:57f9947a7e57a081c1e3e0a2dd0d2dcf290a4531450e6f611e30084c222a7295 \ + --hash=sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2 \ + --hash=sha256:5c22873ad1f0532ba40fa1727f3c0fc1bbbaab6d373d4cbe3f0dc74b2e2521c7 \ + --hash=sha256:5e8b3d0b18fd623afa12ecb2ce8d8becef69f9b5440c6330c7972200e0bb84b0 \ + --hash=sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6 \ + --hash=sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed \ + --hash=sha256:6669c1bf34080161ce49c589cc512ef24d4c704ac9d2b2d3667f519c60418378 \ + --hash=sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0 \ + --hash=sha256:6768d67d1bce64270e0fdc2e69309d68b9b18ae56ddf6c711d168e9d051c2cac \ + --hash=sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b \ + --hash=sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96 \ + --hash=sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59 \ + --hash=sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808 \ + --hash=sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2 \ + --hash=sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb \ + --hash=sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65 \ + --hash=sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72 \ + --hash=sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8 \ + --hash=sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e \ + --hash=sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91 \ + --hash=sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a \ + --hash=sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2 \ + --hash=sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e \ + --hash=sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707 \ + --hash=sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21 \ + --hash=sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef \ + --hash=sha256:8698d70a8081ee8c090dbb394768b5789a1da8b131b5499f89d071dd3cfaf6be \ + --hash=sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453 \ + --hash=sha256:88d59b473bfb03259722600839af9bbd7fa13a2eb514beefeedb95997882f69a \ + --hash=sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6 \ + --hash=sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977 \ + --hash=sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978 \ + --hash=sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581 \ + --hash=sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692 \ + --hash=sha256:9240187afb63d2f9ddc3e032c670356fe941f6e20662ea168a5dc3f1f317e1b3 \ + --hash=sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369 \ + --hash=sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a \ + --hash=sha256:9388003072b95f2f1e3fd908604194d653ba21330d811961a78b7da1a77e9e36 \ + --hash=sha256:9438a2648b2195980cb2dd8e53ed7b8df91319e2d0b70ae61a9e1d1bc8d3bec9 \ + --hash=sha256:94e4c421742086aeee4c32a506eec8859d7634aad943f7e6aacf70f813478768 \ + --hash=sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916 \ + --hash=sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b \ + --hash=sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f \ + --hash=sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346 \ + --hash=sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c \ + --hash=sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464 \ + --hash=sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9 \ + --hash=sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee \ + --hash=sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300 \ + --hash=sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6 \ + --hash=sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d \ + --hash=sha256:ac0c7c9f1609b0c4c114feb1d7a3409564c7fb77e360bed9e97e5d25dfeaf868 \ + --hash=sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46 \ + --hash=sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97 \ + --hash=sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733 \ + --hash=sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe \ + --hash=sha256:b61687d0828e72bf5cda24a2690188f37170bd31c9359ac97e4e66569f120a16 \ + --hash=sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429 \ + --hash=sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39 \ + --hash=sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894 \ + --hash=sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c \ + --hash=sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c \ + --hash=sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169 \ + --hash=sha256:bf053da3c97a4bc5ecfbb218cdd2983febd91c617be8367d139882aa11e490aa \ + --hash=sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77 \ + --hash=sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe \ + --hash=sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad \ + --hash=sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85 \ + --hash=sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e \ + --hash=sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34 \ + --hash=sha256:cf63c214fe879a65e69a386f915e36104fc84254ab141240f8854602d8e0be2a \ + --hash=sha256:d1aca03ede943eb80ab3d63bb082c84b7aab85ea83bd0fd0c200260945fb49d9 \ + --hash=sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c \ + --hash=sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749 \ + --hash=sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214 \ + --hash=sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932 \ + --hash=sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494 \ + --hash=sha256:dd8ea6ebee7aedbf7c749fa80521d9ccf1ba473e0d1e14805caafbaad281c889 \ + --hash=sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1 \ + --hash=sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0 \ + --hash=sha256:dff05cb7016dff1e9fd68f4122c127b65dfc59de5306cfb7ad92f956f230bee2 \ + --hash=sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786 \ + --hash=sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78 \ + --hash=sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e \ + --hash=sha256:e841068dc0be4cb6dfb5c890eb88cbdcff2f4a332393c7ec94e8e618bd32c1a8 \ + --hash=sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289 \ + --hash=sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c \ + --hash=sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe \ + --hash=sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237 \ + --hash=sha256:f291bcf42ae98eb5107edb162c3c998b4a89648fd8e99ed4cbd12705292788cd \ + --hash=sha256:f61efe1d2fe0de16158a5fe1d1cf3c14bdb6aecd54d8938fd26512c525c1f624 \ + --hash=sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19 \ + --hash=sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977 \ + --hash=sha256:fd9f8797427910198f95bced71ddfed61130d7e349213bfb8466c9c99e2c46a8 \ + --hash=sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3 + # via jinja2 +tomli==2.5.0 ; python_full_version < '3.11' \ + --hash=sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea \ + --hash=sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd \ + --hash=sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0 \ + --hash=sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391 \ + --hash=sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df \ + --hash=sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9 \ + --hash=sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066 \ + --hash=sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f \ + --hash=sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57 \ + --hash=sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6 \ + --hash=sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b \ + --hash=sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3 \ + --hash=sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043 \ + --hash=sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01 \ + --hash=sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646 \ + --hash=sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859 \ + --hash=sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b \ + --hash=sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e \ + --hash=sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc \ + --hash=sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5 \ + --hash=sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0 \ + --hash=sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb \ + --hash=sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84 \ + --hash=sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6 \ + --hash=sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b \ + --hash=sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b \ + --hash=sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52 \ + --hash=sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd \ + --hash=sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75 \ + --hash=sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1 \ + --hash=sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b \ + --hash=sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142 \ + --hash=sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03 \ + --hash=sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea \ + --hash=sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885 \ + --hash=sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374 \ + --hash=sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3 \ + --hash=sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276 \ + --hash=sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b \ + --hash=sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc \ + --hash=sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68 \ + --hash=sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a \ + --hash=sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f \ + --hash=sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b \ + --hash=sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7 \ + --hash=sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0 \ + --hash=sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb \ + --hash=sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7 \ + --hash=sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545 \ + --hash=sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8 \ + --hash=sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980 \ + --hash=sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7 \ + --hash=sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105 \ + --hash=sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5 \ + --hash=sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56 \ + --hash=sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d \ + --hash=sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2 \ + --hash=sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4 \ + --hash=sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7 \ + --hash=sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef \ + --hash=sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1 \ + --hash=sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571 \ + --hash=sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a \ + --hash=sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442 \ + --hash=sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc + # via towncrier +towncrier==26.9.0 \ + --hash=sha256:ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55 \ + --hash=sha256:ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660 +zipp==3.23.1 ; python_full_version < '3.10' \ + --hash=sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc \ + --hash=sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110 + # via + # importlib-metadata + # importlib-resources diff --git a/scripts/release/test_notes.py b/scripts/release/test_notes.py new file mode 100644 index 0000000000..a6057acdaf --- /dev/null +++ b/scripts/release/test_notes.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +import notes + +ROOT = Path(__file__).resolve().parent.parent.parent +GIT_ENV = { + **os.environ, + "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", +} +MARKER = "" +CHANGELOG = f"""# Changelog + +Unreleased changes are files in `changelog.d/`. + +{MARKER} + +## [0.82.0](https://github.com/TableProApp/TablePro/releases/tag/v0.82.0) - 2026-10-20 [CRITICAL] + +Beta updates from a signed feed. +Plugins update with the app. + +### Added + +- Beta channel by [@someone](https://github.com/someone). ([#3500](https://github.com/TableProApp/TablePro/pull/3500)) + +### Fixed + +- A crash with `#3` and `@MainActor` in code. ([#3501](https://github.com/TableProApp/TablePro/pull/3501)) + + +## [0.81.1](https://github.com/TableProApp/TablePro/releases/tag/v0.81.1) - 2026-10-05 + +### Fixed + +- Hang on connect. + +``` +## [9.9.9] inside a fence +### Not a subsection +``` + +## [0.81.0] - 2026-10-16 + +Old lead line. + +### Added + +- Old entry. (#3353 by @RaffeYang) +""" + + +def towncrier_missing(): + try: + notes.towncrier_command() + except notes.NotesError: + return True + return False + + +def authors(table): + calls = [] + + def author_of(number): + calls.append(number) + return table[number] + + author_of.calls = calls + return author_of + + +class FixtureRepository: + def __init__(self, path: Path): + self.path = path + self.git("init", "--quiet", "--initial-branch=main") + shutil.copy2(ROOT / "towncrier.toml", path / "towncrier.toml") + (path / "CHANGELOG.md").write_text( + f"# Changelog\n\n{MARKER}\n\n## [0.81.0] - 2026-10-16\n\n### Fixed\n\n- Old.\n", encoding="utf-8") + (path / notes.FRAGMENTS).mkdir() + (path / notes.FRAGMENTS / ".gitkeep").write_text("", encoding="utf-8") + + def git(self, *args: str) -> str: + return subprocess.run(["git", "-C", str(self.path), *args], env=GIT_ENV, check=True, + capture_output=True, text=True).stdout + + def fragment(self, name: str, text: str): + (self.path / notes.FRAGMENTS / name).write_text(text + "\n", encoding="utf-8") + + def commit(self, message: str) -> None: + self.git("add", "--all") + self.git("commit", "--quiet", "--allow-empty", "-m", message) + + +class SectionTests(unittest.TestCase): + def test_a_new_heading_gives_its_date_critical_marker_and_lead(self): + section = notes.find_section(CHANGELOG, "0.82.0") + self.assertEqual(section.date, "2026-10-20") + self.assertTrue(section.is_critical) + self.assertEqual(section.lead, ["Beta updates from a signed feed.", "Plugins update with the app."]) + self.assertEqual(section.entries[0], "### Added") + + 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( + "Beta updates from a signed feed.\nPlugins update with the app.\n\n### Added\n")) + self.assertTrue(body.endswith("([#3501](https://github.com/TableProApp/TablePro/pull/3501))")) + + def test_a_missing_version_is_none(self): + self.assertIsNone(notes.find_section(CHANGELOG, "0.0.0-dev")) + + +class ChangelogCommandTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + (self.root / "CHANGELOG.md").write_text(CHANGELOG, encoding="utf-8") + + def test_the_stable_feed_is_the_lead_and_a_docs_link(self): + self.assertEqual( + notes.feed(self.root, "0.82.0", None), + "# What's New in TablePro 0.82.0\n\nBeta updates from a signed feed.\n\nPlugins update with the app.\n\n" + "[View full changelog](https://docs.tablepro.app/changelog)\n", + ) + + def test_a_stable_feed_without_a_lead_carries_the_whole_section(self): + self.assertTrue(notes.feed(self.root, "0.81.1", None).startswith( + "# What's New in TablePro 0.81.1\n\n### Fixed\n\n- Hang on connect.\n")) + + def test_the_stable_body_is_the_section_without_its_heading(self): + body = notes.release_body(self.root, "0.82.0", None) + self.assertTrue(body.startswith("Beta updates from a signed feed.\n")) + self.assertNotIn("## [0.82.0]", body) + + def test_lead_and_critical(self): + self.assertEqual(notes.lead(self.root, "0.82.0"), + "Beta updates from a signed feed.\nPlugins update with the app.\n") + self.assertEqual(notes.lead(self.root, "0.81.1"), "") + self.assertEqual(notes.lead(self.root, "0.83.0-beta.1"), "") + self.assertTrue(notes.critical(self.root, "0.82.0")) + self.assertFalse(notes.critical(self.root, "0.81.1")) + self.assertFalse(notes.critical(self.root, "0.83.0-beta.1")) + + def test_a_stable_without_a_section_is_an_error(self): + for command in (notes.lead, notes.critical): + with self.subTest(command=command.__name__), self.assertRaisesRegex(notes.NotesError, "no ## \\[0.82.1\\]"): + command(self.root, "0.82.1") + + +class WhatsNewTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.changelog = Path(directory.name) / "CHANGELOG.md" + self.changelog.write_text(CHANGELOG, encoding="utf-8") + self.out = Path(directory.name) / "WhatsNew.md" + + def test_the_lead_becomes_the_window(self): + self.assertTrue(notes.whats_new("0.82.0", self.changelog, self.out)) + 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): + 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") + + def test_no_section_writes_nothing_and_removes_a_stale_file(self): + self.out.write_text("stale", encoding="utf-8") + self.assertFalse(notes.whats_new("0.0.0-dev", self.changelog, self.out)) + self.assertFalse(self.out.exists()) + self.assertFalse(notes.whats_new("0.0.0-dev", self.changelog, self.out)) + + def test_the_cli_runs_without_version_py(self): + directory = Path(self.out.parent) + shutil.copy2(Path(notes.__file__), directory / "notes.py") + result = subprocess.run( + [sys.executable, "-I", str(directory / "notes.py"), "whats-new", "0.82.0", + "--changelog", str(self.changelog), "--out", str(self.out)], + capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(self.out.read_text(encoding="utf-8").startswith("# TablePro 0.82.0\n")) + + +class CreditTests(unittest.TestCase): + def test_an_outside_author_goes_before_the_final_period(self): + self.assertEqual(notes.credit("Schema picker.\n", ("i87ce", "CONTRIBUTOR")), "Schema picker by @i87ce.\n") + + def test_a_line_without_a_period_gets_the_credit_at_the_end(self): + self.assertEqual(notes.credit("Schema picker", ("i87ce", "NONE")), "Schema picker by @i87ce") + + def test_owners_and_members_are_not_credited(self): + for association in ("OWNER", "MEMBER"): + with self.subTest(association=association): + self.assertEqual(notes.credit("Schema picker.\n", ("datlechin", association)), "Schema picker.\n") + + def test_a_credited_line_is_left_alone(self): + self.assertEqual(notes.credit("Picker by @i87ce.\n", ("i87ce", "CONTRIBUTOR")), "Picker by @i87ce.\n") + + def test_only_pull_request_fragments_are_credited(self): + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) + for name in ("3436.added.md", "3436.fixed.1.md", "+tidy.changed.md", ".gitkeep"): + (folder / name).write_text("An entry.\n", encoding="utf-8") + author_of = authors({3436: ("i87ce", "FIRST_TIME_CONTRIBUTOR")}) + notes.credit_fragments(folder, author_of) + self.assertEqual((folder / "3436.added.md").read_text(encoding="utf-8"), "An entry by @i87ce.\n") + self.assertEqual((folder / "3436.fixed.1.md").read_text(encoding="utf-8"), "An entry by @i87ce.\n") + self.assertEqual((folder / "+tidy.changed.md").read_text(encoding="utf-8"), "An entry.\n") + self.assertEqual(author_of.calls, [3436, 3436]) + + def test_gh_is_asked_once_per_pull_request(self): + calls = [] + + def run(command, **_): + calls.append(command) + return subprocess.CompletedProcess(command, 0, json.dumps({"login": "i87ce", "association": "NONE"}), "") + + lookup = notes.PullRequestAuthors("Owner/Repo", run=run) + self.assertEqual(lookup(12), ("i87ce", "NONE")) + self.assertEqual(lookup(12), ("i87ce", "NONE")) + self.assertEqual(len(calls), 1) + self.assertIn("repos/Owner/Repo/pulls/12", calls[0]) + + def test_a_gh_failure_names_the_pull_request(self): + def run(command, **_): + return subprocess.CompletedProcess(command, 1, "", "HTTP 404") + + with self.assertRaisesRegex(notes.NotesError, "#12: HTTP 404"): + 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' + '\n ### Bug Fixes\n\n - Old.\n\n\n' + '\n ### New Features\n\n - Older.\n\n' + ) + + def block(self, version): + return notes.render_update(notes.find_section(CHANGELOG, version), "TableProApp/TablePro") + + def test_a_block_renames_sections_and_links_credits(self): + self.assertEqual(self.block("0.82.0"), "\n".join([ + '', + " Beta updates from a signed feed.", + "", + " Plugins update with the app.", + "", + " ### New Features", + "", + " - Beta channel by [@someone](https://github.com/someone). " + "([#3500](https://github.com/TableProApp/TablePro/pull/3500))", + "", + " ### Bug Fixes", + "", + " - A crash with `#3` and `@MainActor` in code. " + "([#3501](https://github.com/TableProApp/TablePro/pull/3501))", + "", + ])) + + def test_old_references_become_links(self): + self.assertIn( + " - Old entry. ([#3353](https://github.com/TableProApp/TablePro/issues/3353) by " + "[@RaffeYang](https://github.com/RaffeYang))", + self.block("0.81.0"), + ) + + def test_the_date_has_no_leading_zero(self): + self.assertEqual(notes.docs_date("2026-10-05"), "October 5, 2026") + + def test_a_new_block_lands_in_version_order_and_a_rerun_changes_nothing(self): + merged = notes.merge_update(self.MDX, "0.82.0", self.block("0.82.0")) + self.assertLess(merged.index('label="v0.82.0"'), merged.index('label="v0.81.0"')) + self.assertTrue(merged.startswith('---\ntitle: "Changelog"\n---\n\n\n")) + self.assertEqual(notes.merge_update(merged, "0.82.0", self.block("0.82.0")), merged) + + patch = notes.merge_update(merged, "0.81.1", self.block("0.81.1")) + labels = [match.group(1) for match in notes.UPDATE_BLOCK.finditer(patch)] + self.assertEqual(labels, ["0.82.0", "0.81.1", "0.81.0", "0.9.0"]) + + def test_an_existing_block_is_replaced(self): + merged = notes.merge_update(self.MDX, "0.81.0", self.block("0.81.0")) + self.assertNotIn(" - Old.\n", merged) + self.assertEqual(merged.count('label="v0.81.0"'), 1) + + def test_an_unchanged_file_round_trips(self): + first = next(notes.UPDATE_BLOCK.finditer(self.MDX)).group(0) + self.assertEqual(notes.merge_update(self.MDX, "0.81.0", first), self.MDX) + + def test_stray_text_and_duplicates_are_refused(self): + stray = self.MDX.replace("\n\n\n\nStray.\n\n', mdx.read_text(encoding="utf-8")) + with self.assertRaisesRegex(notes.NotesError, "beta"): + notes.docs(repo.path, "0.83.0-beta.1", "v0.82.0", mdx) + + +class AssembleRefusalTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + 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 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"), + ] + 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): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.repo = FixtureRepository(Path(directory.name)) + self.repo.fragment("3500.added.md", "Beta channel.") + self.repo.fragment("3501.fixed.md", "Crash on open.") + 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): + body = notes.release_body(self.repo.path, "0.82.0-beta.1", self.author_of) + self.assertTrue(body.startswith("\n".join([ + "### Added", + "", + "- Beta channel by @i87ce. ([#3500](https://github.com/TableProApp/TablePro/pull/3500))", + "", + "### Changed", + "", + "- Quieter logs.", + "", + "### Fixed", + "", + ]))) + self.assertEqual(sorted(body.splitlines()[-2:]), [ + "- Crash on open. ([#3501](https://github.com/TableProApp/TablePro/pull/3501))", + "- Hang on close. ([#3501](https://github.com/TableProApp/TablePro/pull/3501))", + ]) + self.assertTrue(body.endswith("))\n")) + self.assertEqual((self.repo.path / notes.FRAGMENTS / "3500.added.md").read_text(encoding="utf-8"), + "Beta channel.\n") + + def test_a_beta_feed_links_the_github_release(self): + text = notes.feed(self.repo.path, "0.82.0-beta.1", self.author_of) + self.assertTrue(text.startswith("# What's New in TablePro 0.82.0-beta.1\n\n### Added\n")) + self.assertTrue(text.endswith( + "\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) + + 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") + 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" + "- 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(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/scripts/release/test_version.py b/scripts/release/test_version.py new file mode 100644 index 0000000000..2449392dbc --- /dev/null +++ b/scripts/release/test_version.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 + +import io +import os +import subprocess +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +import version + +GIT_ENV = { + **os.environ, + "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", +} +MAIN_CHANGELOG = "# Changelog\n\n## [0.81.0] - 2026-10-16\n\n### Fixed\n\n- A bug. (#1)\n" + + +class GrammarTests(unittest.TestCase): + def test_the_versions_table(self): + cases = [ + ("v0.82.0-beta.1", "0.82.0-beta.1", 820001, "beta", "0.82"), + ("v0.82.0-beta.2", "0.82.0-beta.2", 820002, "beta", "0.82"), + ("v0.82.0", "0.82.0", 820099, "stable", "0.82"), + ("v0.82.1", "0.82.1", 820199, "stable", "0.82"), + ("v0.83.0-beta.1", "0.83.0-beta.1", 830001, "beta", "0.83"), + ] + for tag, text, build, channel, line in cases: + with self.subTest(tag=tag): + parsed = version.parse_tag(tag) + self.assertEqual((str(parsed), parsed.build, parsed.channel, parsed.line, parsed.tag), + (text, build, channel, line, tag)) + + def test_the_bounds_are_accepted(self): + for tag in ("v0.999.99", "v0.82.0-beta.98", "v1.0.0", "v0.0.0"): + with self.subTest(tag=tag): + version.parse_tag(tag) + + def test_anything_else_is_refused(self): + refused = [ + "0.82.0", "v0.82", "v0.82.0.1", "v0.82.0-rc.1", "v0.82.0-beta", "v0.82.1-beta.1", + "v0.82.0-beta.0", "v0.82.0-beta.99", "v0.1000.0", "v0.82.100", "v01.82.0", + "v0.82.0-beta.01", "v0.82.0 ", "v0.82.0-beta.1+1", "v0.٨٢.0", "plugin-oracle-v1.0.0", + ] + for tag in refused: + with self.subTest(tag=tag), self.assertRaises(ValueError): + version.parse_tag(tag) + + def test_semver_order_is_build_order(self): + ordered = ["v0.81.0", "v0.81.1", "v0.82.0-beta.1", "v0.82.0-beta.2", "v0.82.0-beta.98", "v0.82.0", + "v0.82.1", "v0.82.99", "v0.83.0-beta.1", "v0.999.0", "v1.0.0-beta.1", "v1.0.0"] + builds = [version.parse_tag(tag).build for tag in ordered] + self.assertEqual(builds, sorted(builds)) + self.assertEqual(len(set(builds)), len(builds)) + + def test_parse_prints_the_step_outputs(self): + output = io.StringIO() + with redirect_stdout(output): + self.assertEqual(version.main(["parse", "v0.82.0-beta.2"]), 0) + self.assertEqual(output.getvalue(), "version=0.82.0-beta.2\nbuild=820002\nchannel=beta\nline=0.82\n") + + def test_parse_refuses_a_bad_tag(self): + with redirect_stderr(io.StringIO()) as errors: + self.assertEqual(version.main(["parse", "v0.82.1-beta.1"]), 1) + self.assertIn("PATCH 0", errors.getvalue()) + + +class FakeGitHub: + def __init__(self, releases=(), runs=None, jobs=None): + self._releases = list(releases) + self._runs = runs or {} + self._jobs = jobs or {} + self.run_queries = [] + + def releases(self): + return self._releases + + def push_runs(self, workflow, sha): + self.run_queries.append((workflow, sha)) + snapshots = self._runs.get((workflow, sha), [[]]) + return snapshots.pop(0) if len(snapshots) > 1 else snapshots[0] + + def job_conclusions(self, run_id, job): + return self._jobs.get((run_id, job), []) + + +def green_on(sha, prefix="run"): + runs, jobs = {}, {} + for workflow, job in version.REQUIRED_RUNS: + run_id = f"{prefix}-{workflow}" + runs[(workflow, sha)] = [[(run_id, "completed", "success")]] + jobs[(run_id, job)] = ["success"] + return runs, jobs + + +class FixtureRepository: + def __init__(self, path: Path): + self.path = path + self.git("init", "--quiet", "--initial-branch=main") + + def git(self, *args: str) -> str: + return subprocess.run(["git", "-C", str(self.path), *args], env=GIT_ENV, check=True, + capture_output=True, text=True).stdout.strip() + + def commit(self, message: str, changelog: str = None) -> str: + if changelog is not None: + (self.path / "CHANGELOG.md").write_text(changelog, encoding="utf-8") + self.git("add", "CHANGELOG.md") + self.git("commit", "--quiet", "--allow-empty", "-m", message) + return self.git("rev-parse", "HEAD") + + def line(self, name: str, sha: str): + self.git("update-ref", f"refs/remotes/origin/release/{name}", sha) + + +class CheckTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.repo = FixtureRepository(Path(directory.name)) + self.git = version.Git(self.repo.path) + self.slept = [] + self.logged = [] + self.base = self.repo.commit("0.81.0", MAIN_CHANGELOG) + self.repo.git("tag", "v0.81.0") + self.cut = self.repo.commit("feat: something") + self.repo.line("0.82", self.cut) + + def check(self, tag, github): + return version.check(tag, self.git, github, sleep=self.slept.append, log=self.logged.append) + + def test_a_first_beta_on_a_green_cut_commit_builds(self): + self.repo.git("tag", "v0.82.0-beta.1", self.cut) + runs, jobs = green_on(self.cut) + github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) + self.assertEqual(self.check("v0.82.0-beta.1", github), []) + + def test_a_skipped_job_borrows_the_nearest_first_parent_verdict(self): + release_commit = self.repo.commit("chore(release): notes only") + self.repo.line("0.82", release_commit) + self.repo.git("tag", "v0.82.0-beta.1", release_commit) + runs, jobs = green_on(self.cut) + for workflow, job in version.REQUIRED_RUNS: + runs[(workflow, release_commit)] = [[("skip-" + workflow, "completed", "success")]] + jobs[("skip-" + workflow, job)] = ["skipped"] + github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) + self.assertEqual(self.check("v0.82.0-beta.1", github), []) + self.assertIn(f"macOS Tests: green on {self.cut}", self.logged) + + def test_a_pending_run_is_waited_for(self): + self.repo.git("tag", "v0.82.0-beta.1", self.cut) + runs, jobs = green_on(self.cut) + workflow = version.REQUIRED_RUNS[0][0] + runs[(workflow, self.cut)] = [[("run-" + workflow, "in_progress", "")], + [("run-" + workflow, "queued", "")], + [("run-" + workflow, "completed", "success")]] + github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) + self.assertEqual(self.check("v0.82.0-beta.1", github), []) + self.assertEqual(self.slept, [version.POLL_SECONDS, version.POLL_SECONDS]) + + def test_a_failed_job_refuses_the_tag(self): + self.repo.git("tag", "v0.82.0-beta.1", self.cut) + runs, jobs = green_on(self.cut) + jobs[("run-ios-tests.yml", "iOS and Package Tests")] = ["failure"] + github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) + self.assertEqual(self.check("v0.82.0-beta.1", github), + [f"iOS and Package Tests ended 'failure' on {self.cut} (run run-ios-tests.yml)"]) + + def test_a_run_that_failed_before_its_jobs_refuses_the_tag(self): + self.repo.git("tag", "v0.82.0-beta.1", self.cut) + runs, jobs = green_on(self.cut) + runs[("release-build.yml", self.cut)] = [[("broken", "completed", "startup_failure")]] + github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) + self.assertEqual(self.check("v0.82.0-beta.1", github), + [f"Release Build ended 'startup_failure' on {self.cut} (run broken)"]) + + def test_a_success_on_another_branch_run_of_the_same_commit_counts(self): + self.repo.git("tag", "v0.82.0-beta.1", self.cut) + runs, jobs = green_on(self.cut) + for workflow, job in version.REQUIRED_RUNS: + runs[(workflow, self.cut)][0].append(("branch-" + workflow, "completed", "success")) + jobs[("branch-" + workflow, job)] = ["skipped"] + github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) + self.assertEqual(self.check("v0.82.0-beta.1", github), []) + + def test_no_run_in_the_walk_refuses_the_tag(self): + self.repo.git("tag", "v0.82.0-beta.1", self.cut) + runs, jobs = green_on(self.cut) + del runs[("release-build.yml", self.cut)] + github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) + errors = self.check("v0.82.0-beta.1", github) + self.assertEqual(len(errors), 1) + self.assertIn("no Release Build run that was not skipped", errors[0]) + + def test_a_tag_off_the_release_line_is_refused_before_the_walk(self): + side = self.repo.commit("off the line") + self.repo.git("tag", "v0.82.0-beta.1", side) + self.repo.line("0.82", self.cut) + github = FakeGitHub(releases=[("v0.81.0", False)]) + self.assertEqual(self.check("v0.82.0-beta.1", github), + ["v0.82.0-beta.1 is not reachable from origin/release/0.82"]) + self.assertEqual(github.run_queries, []) + + def test_a_missing_release_branch_is_refused(self): + self.repo.git("tag", "v0.83.0-beta.1", self.cut) + errors = self.check("v0.83.0-beta.1", FakeGitHub(releases=[("v0.81.0", False)])) + self.assertIn("origin/release/0.83 does not exist", errors) + + def test_the_previous_tag_on_the_line_must_be_an_ancestor(self): + self.repo.git("tag", "v0.82.0-beta.1", self.cut) + self.repo.git("checkout", "--quiet", "-b", "other", self.base) + other = self.repo.commit("rewritten line") + self.repo.line("0.82", other) + self.repo.git("tag", "v0.82.0-beta.2", other) + errors = self.check("v0.82.0-beta.2", FakeGitHub(releases=[("v0.81.0", False)])) + self.assertEqual(errors, ["v0.82.0-beta.1, the previous tag on 0.82, is not an ancestor of v0.82.0-beta.2"]) + + def test_a_stable_needs_a_published_beta(self): + release_commit = self.repo.commit("chore(release): v0.82.0", MAIN_CHANGELOG.replace( + "## [0.81.0]", "## [0.82.0](https://example.com) - 2026-10-20\n\n### Added\n\n- New.\n\n## [0.81.0]")) + self.repo.line("0.82", release_commit) + self.repo.git("tag", "v0.82.0", release_commit) + for releases in ([("v0.81.0", False)], [("v0.81.0", False), ("v0.82.0-beta.1", True)]): + with self.subTest(releases=releases): + self.assertEqual(self.check("v0.82.0", FakeGitHub(releases=releases)), + ["v0.82.0 needs a published v0.82.0-beta.N release first"]) + runs, jobs = green_on(release_commit) + published = FakeGitHub(releases=[("v0.81.0", False), ("v0.82.0-beta.1", False)], runs=runs, jobs=jobs) + self.assertEqual(self.check("v0.82.0", published), []) + + def test_a_patch_needs_no_beta(self): + changelog = MAIN_CHANGELOG.replace( + "## [0.81.0]", "## [0.82.1] - 2026-10-25\n\n## [0.82.0] - 2026-10-20\n\n## [0.81.0]") + release_commit = self.repo.commit("chore(release): v0.82.1", changelog) + self.repo.line("0.82", release_commit) + self.repo.git("tag", "v0.82.1", release_commit) + runs, jobs = green_on(release_commit) + github = FakeGitHub(releases=[("v0.81.0", False), ("v0.82.0", False)], runs=runs, jobs=jobs) + self.assertEqual(self.check("v0.82.1", github), []) + + def test_no_beta_follows_the_stable_tag(self): + self.repo.git("tag", "v0.82.0", self.cut) + later = self.repo.commit("fix: later") + self.repo.line("0.82", later) + self.repo.git("tag", "v0.82.0-beta.3", later) + errors = self.check("v0.82.0-beta.3", FakeGitHub(releases=[("v0.81.0", False)])) + self.assertIn("v0.82.0 is already tagged, so no beta of it can follow", errors) + + def test_a_stable_needs_its_heading(self): + self.repo.git("tag", "v0.82.1", self.cut) + errors = self.check("v0.82.1", FakeGitHub(releases=[("v0.81.0", False)])) + self.assertEqual(errors, ["CHANGELOG.md at v0.82.1 has no ## [0.82.1]"]) + + def test_a_beta_refuses_a_line_whose_stable_is_assembled(self): + assembled = self.repo.commit("chore(release): v0.82.0", MAIN_CHANGELOG.replace( + "## [0.81.0]", "## [0.82.0] - 2026-10-20\n\n## [0.81.0]")) + self.repo.line("0.82", assembled) + self.repo.git("tag", "v0.82.0-beta.4", assembled) + errors = self.check("v0.82.0-beta.4", FakeGitHub(releases=[("v0.81.0", False)])) + self.assertEqual(errors, ["CHANGELOG.md at v0.82.0-beta.4 already has ## [0.82.0]"]) + + def test_a_missing_forward_port_is_refused(self): + self.repo.git("tag", "v0.83.0-beta.1", self.cut) + self.repo.line("0.83", self.cut) + releases = [("v0.80.3", False), ("v0.81.0", False), ("v0.81.1", False), ("v0.81.2", True), + ("v0.82.0-beta.1", False)] + errors = self.check("v0.83.0-beta.1", FakeGitHub(releases=releases)) + self.assertEqual(errors, ["CHANGELOG.md at v0.83.0-beta.1 has no ## [0.81.1]: forward-port that release first"]) + + def test_a_bad_tag_stops_at_the_grammar(self): + github = FakeGitHub() + self.assertEqual(self.check("v0.82.1-beta.1", github), ["0.82.1-beta.1: a beta exists only on PATCH 0"]) + self.assertEqual(github.run_queries, []) + + +class GitHubTests(unittest.TestCase): + def test_gh_output_is_read_as_rows(self): + calls = [] + + def run(command, **_): + calls.append(command) + path = command[command.index("--jq") - 1] + outputs = { + "/releases": "v0.82.0\tfalse\nplugin-oracle-v1.0.0\ttrue\n", + "/runs?": "11\tcompleted\tsuccess\n12\tin_progress\t\n", + "/jobs?": "success\n\n", + } + output = next(text for key, text in outputs.items() if key in path) + return subprocess.CompletedProcess(command, 0, output, "") + + github = version.GitHub("Owner/Repo", run=run) + self.assertEqual(github.releases(), [("v0.82.0", False), ("plugin-oracle-v1.0.0", True)]) + self.assertEqual(github.push_runs("macos-tests.yml", "abc"), + [("11", "completed", "success"), ("12", "in_progress", "")]) + self.assertEqual(github.job_conclusions("11", "macOS Tests"), ["success", ""]) + self.assertIn("--paginate", calls[0]) + self.assertIn("repos/Owner/Repo/actions/workflows/macos-tests.yml/runs?head_sha=abc&event=push&per_page=100", + calls[1]) + + def test_a_gh_failure_is_raised(self): + def run(command, **_): + return subprocess.CompletedProcess(command, 1, "", "HTTP 404") + + with self.assertRaisesRegex(RuntimeError, "HTTP 404"): + version.GitHub("Owner/Repo", run=run).releases() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release/version.py b/scripts/release/version.py new file mode 100755 index 0000000000..c541d2cff2 --- /dev/null +++ b/scripts/release/version.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 + +import argparse +import os +import re +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, List, Optional, Tuple + +REPOSITORY = "TableProApp/TablePro" +MAX_MINOR = 999 +MAX_PATCH = 99 +MAX_BETA = 98 +STABLE_RANK = 99 + +# [0-9], not \d: \d also matches non-ASCII digits. +GRAMMAR = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-beta\.(0|[1-9][0-9]*))?") + +REQUIRED_RUNS = ( + ("macos-tests.yml", "macOS Tests"), + ("ios-tests.yml", "iOS and Package Tests"), + ("release-build.yml", "Release Build"), +) +WALK_DEPTH = 50 +POLL_SECONDS = 60 +PENDING_STATUSES = {"queued", "in_progress", "waiting", "requested", "pending"} + + +@dataclass(frozen=True) +class Version: + major: int + minor: int + patch: int + beta: Optional[int] = None + + @property + def is_beta(self) -> bool: + return self.beta is not None + + @property + def channel(self) -> str: + return "beta" if self.is_beta else "stable" + + @property + def line(self) -> str: + return f"{self.major}.{self.minor}" + + @property + def build(self) -> int: + rank = self.beta if self.beta is not None else STABLE_RANK + return ((self.major * 1000 + self.minor) * 100 + self.patch) * 100 + rank + + @property + def tag(self) -> str: + return f"v{self}" + + def __str__(self) -> str: + text = f"{self.major}.{self.minor}.{self.patch}" + return f"{text}-beta.{self.beta}" if self.beta is not None else text + + +def parse_version(text: str) -> Version: + match = GRAMMAR.fullmatch(text) + if not match: + raise ValueError(f"{text!r} is not X.Y.Z or X.Y.0-beta.N") + major, minor, patch = (int(group) for group in match.group(1, 2, 3)) + beta = int(match.group(4)) if match.group(4) is not None else None + if minor > MAX_MINOR: + raise ValueError(f"{text}: MINOR is above {MAX_MINOR}") + if patch > MAX_PATCH: + raise ValueError(f"{text}: PATCH is above {MAX_PATCH}") + if beta is not None and not 1 <= beta <= MAX_BETA: + raise ValueError(f"{text}: the beta number is outside 1 to {MAX_BETA}") + if beta is not None and patch != 0: + raise ValueError(f"{text}: a beta exists only on PATCH 0") + return Version(major, minor, patch, beta) + + +def parse_tag(tag: str) -> Version: + if not tag.startswith("v"): + raise ValueError(f"{tag!r} does not start with v") + return parse_version(tag[1:]) + + +def try_parse_tag(tag: str) -> Optional[Version]: + try: + return parse_tag(tag) + except ValueError: + return None + + +def has_heading(changelog: str, version: Version) -> bool: + return re.search(rf"^## \[{re.escape(str(version))}\]", changelog, re.MULTILINE) is not None + + +class Git: + def __init__(self, root: Path, run: Callable = subprocess.run): + self.root = root + self._run = run + + def _git(self, *args: str, check: bool = True) -> subprocess.CompletedProcess: + result = self._run(["git", "-C", str(self.root), *args], capture_output=True, text=True) + if check and result.returncode != 0: + raise RuntimeError(f"git {' '.join(args)}: {result.stderr.strip()}") + return result + + def exists(self, ref: str) -> bool: + return self._git("rev-parse", "--quiet", "--verify", f"{ref}^{{commit}}", check=False).returncode == 0 + + def is_ancestor(self, ancestor: str, descendant: str) -> bool: + result = self._git("merge-base", "--is-ancestor", ancestor, descendant, check=False) + if result.returncode not in (0, 1): + raise RuntimeError(f"git merge-base {ancestor} {descendant}: {result.stderr.strip()}") + return result.returncode == 0 + + def tags(self, pattern: str) -> List[str]: + return self._git("tag", "--list", pattern).stdout.split() + + def show(self, ref: str, path: str) -> Optional[str]: + result = self._git("show", f"{ref}:{path}", check=False) + return result.stdout if result.returncode == 0 else None + + def first_parents(self, ref: str, limit: int) -> List[str]: + return self._git("rev-list", "--first-parent", f"--max-count={limit}", f"{ref}^{{commit}}").stdout.split() + + +class GitHub: + def __init__(self, repository: str = REPOSITORY, run: Callable = subprocess.run): + self.repository = repository + self._run = run + + def _api(self, path: str, jq: str, paginate: bool = False) -> List[str]: + command = ["gh", "api", *(["--paginate"] if paginate else []), path, "--jq", jq] + result = self._run(command, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"gh api {path}: {result.stderr.strip()}") + return result.stdout.splitlines() + + def releases(self) -> List[Tuple[str, bool]]: + lines = self._api(f"repos/{self.repository}/releases?per_page=100", + ".[] | [.tag_name, .draft] | @tsv", paginate=True) + return [(tag, draft == "true") for tag, draft in (line.split("\t") for line in lines if line)] + + def push_runs(self, workflow: str, sha: str) -> List[Tuple[str, str, str]]: + lines = self._api(f"repos/{self.repository}/actions/workflows/{workflow}/runs" + f"?head_sha={sha}&event=push&per_page=100", + ".workflow_runs[] | [.id, .status, .conclusion // \"\"] | @tsv") + return [(run_id, status, conclusion) + for run_id, status, conclusion in (line.split("\t") for line in lines if line)] + + def job_conclusions(self, run_id: str, job: str) -> List[str]: + return self._api(f"repos/{self.repository}/actions/runs/{run_id}/jobs?filter=latest&per_page=100", + f".jobs[] | select(.name == \"{job}\") | .conclusion // \"\"") + + +class CheckFailure(Exception): + pass + + +def published_stables(releases: List[Tuple[str, bool]]) -> List[Version]: + versions = [try_parse_tag(tag) for tag, draft in releases if not draft] + return [version for version in versions if version is not None and not version.is_beta] + + +def previous_on_line(git: Git, version: Version) -> Optional[Version]: + earlier = [ + candidate for candidate in (try_parse_tag(tag) for tag in git.tags(f"v{version.line}.*")) + if candidate is not None and candidate.line == version.line and candidate.build < version.build + ] + return max(earlier, key=lambda candidate: candidate.build) if earlier else None + + +def branch_errors(git: Git, version: Version) -> List[str]: + tag = version.tag + branch = f"origin/release/{version.line}" + errors = [] + if not git.exists(branch): + errors.append(f"{branch} does not exist") + elif not git.is_ancestor(tag, branch): + errors.append(f"{tag} is not reachable from {branch}") + previous = previous_on_line(git, version) + if previous is not None and not git.is_ancestor(previous.tag, tag): + errors.append(f"{previous.tag}, the previous tag on {version.line}, is not an ancestor of {tag}") + return errors + + +def beta_errors(git: Git, version: Version, releases: List[Tuple[str, bool]]) -> List[str]: + stable_zero = Version(version.major, version.minor, 0) + if version.is_beta: + if git.exists(f"refs/tags/{stable_zero.tag}"): + return [f"{stable_zero.tag} is already tagged, so no beta of it can follow"] + return [] + if version.patch != 0: + return [] + for tag, draft in releases: + candidate = try_parse_tag(tag) + if not draft and candidate is not None and candidate.is_beta and candidate.line == version.line: + return [] + return [f"{version.tag} needs a published {version.tag}-beta.N release first"] + + +def changelog_errors(changelog: Optional[str], version: Version, stables: List[Version]) -> List[str]: + if changelog is None: + return [f"CHANGELOG.md is missing at {version.tag}"] + errors = [] + if version.is_beta: + stable_zero = Version(version.major, version.minor, 0) + if has_heading(changelog, stable_zero): + errors.append(f"CHANGELOG.md at {version.tag} already has ## [{stable_zero}]") + elif not has_heading(changelog, version): + errors.append(f"CHANGELOG.md at {version.tag} has no ## [{version}]") + + lines = sorted({(stable.major, stable.minor) for stable in stables}) + own = (version.major, version.minor) + below = [line for line in lines if line < own] + covered = {own, below[-1]} if below else {own} + for stable in sorted(stables, key=lambda candidate: candidate.build): + if (stable.major, stable.minor) in covered and stable.build < version.build \ + and not has_heading(changelog, stable): + errors.append(f"CHANGELOG.md at {version.tag} has no ## [{stable}]: forward-port that release first") + return errors + + +def is_green(github: GitHub, workflow: str, job: str, sha: str, + sleep: Callable[[float], None], log: Callable[[str], None]) -> bool: + while True: + runs = github.push_runs(workflow, sha) + pending = [run_id for run_id, status, _ in runs if status in PENDING_STATUSES] + if not pending: + break + log(f"waiting for {workflow} run {pending[0]} on {sha[:9]}") + sleep(POLL_SECONDS) + conclusions = [] + for run_id, _, run_conclusion in runs: + found = github.job_conclusions(run_id, job) + # A run that failed before it created any job has only its own conclusion. + if not found and run_conclusion not in ("success", "skipped", ""): + found = [run_conclusion] + conclusions.extend((run_id, conclusion) for conclusion in found) + for run_id, conclusion in conclusions: + if conclusion not in ("success", "skipped"): + raise CheckFailure(f"{job} ended '{conclusion}' on {sha} (run {run_id})") + return any(conclusion == "success" for _, conclusion in conclusions) + + +def ci_errors(git: Git, github: GitHub, version: Version, + sleep: Callable[[float], None] = time.sleep, log: Callable[[str], None] = print) -> List[str]: + errors = [] + shas = git.first_parents(version.tag, WALK_DEPTH) + for workflow, job in REQUIRED_RUNS: + try: + for sha in shas: + if is_green(github, workflow, job, sha, sleep, log): + log(f"{job}: green on {sha}") + break + else: + errors.append(f"no {job} run that was not skipped among the {len(shas)} first-parent commits " + f"of {version.tag}") + except CheckFailure as failure: + errors.append(str(failure)) + return errors + + +def check(tag: str, git: Git, github: GitHub, + sleep: Callable[[float], None] = time.sleep, log: Callable[[str], None] = print) -> List[str]: + try: + version = parse_tag(tag) + except ValueError as error: + return [str(error)] + if not git.exists(f"refs/tags/{tag}"): + return [f"tag {tag} does not exist here"] + releases = github.releases() + errors = branch_errors(git, version) + errors += beta_errors(git, version, releases) + errors += changelog_errors(git.show(tag, "CHANGELOG.md"), version, published_stables(releases)) + if errors: + return errors + return ci_errors(git, github, version, sleep, log) + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Release tag grammar, build number and pre-build checks.") + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("parse", help="print version, build, channel and line as key=value").add_argument("tag") + commands.add_parser("check", help="refuse a tag that must not build").add_argument("tag") + args = parser.parse_args(argv) + + if args.command == "parse": + try: + version = parse_tag(args.tag) + except ValueError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + print(f"version={version}\nbuild={version.build}\nchannel={version.channel}\nline={version.line}") + return 0 + + root = Path(__file__).resolve().parent.parent.parent + repository = os.environ.get("GITHUB_REPOSITORY", REPOSITORY) + try: + errors = check(args.tag, Git(root), GitHub(repository)) + except RuntimeError as error: + errors = [str(error)] + for error in errors: + print(f"error: {error}", file=sys.stderr) + if not errors: + print(f"{args.tag} can build") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/towncrier.toml b/towncrier.toml new file mode 100644 index 0000000000..01bcd5d993 --- /dev/null +++ b/towncrier.toml @@ -0,0 +1,38 @@ +[tool.towncrier] +filename = "CHANGELOG.md" +directory = "changelog.d" +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"] + +[[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 From 580ee7ab603374cbfc35aeb9c6c2b05443f25411 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 18 Oct 2026 00:17:19 +0700 Subject: [PATCH 2/2] build(release): check suite coverage on a borrowed verdict and escape MDX --- scripts/release/notes.py | 10 +++--- scripts/release/test_notes.py | 11 ++++++ scripts/release/test_version.py | 59 ++++++++++++++++++++++++++++++--- scripts/release/version.py | 50 +++++++++++++++++++--------- 4 files changed, 107 insertions(+), 23 deletions(-) diff --git a/scripts/release/notes.py b/scripts/release/notes.py index 4ab38840f1..f3ccb1d1d8 100755 --- a/scripts/release/notes.py +++ b/scripts/release/notes.py @@ -293,11 +293,13 @@ def assemble(root: Path, version: str, author_of: Callable[[int], Author], lead_ raise NotesError(f"git add CHANGELOG.md: {added.stderr.strip()}") -def _link(text: str, repository: str) -> str: +def _mdx(text: str, repository: str) -> str: pieces = re.split(r"(`+[^`]*`+)", text) for index in range(0, len(pieces), 2): + # MDX reads { as an expression and < as a tag. + piece = re.sub(r"([{}<])", r"\\\1", pieces[index]) piece = re.sub(r"(? str: raise NotesError(f"## [{section.version}] has no date") lines = [f''] for line in section.lead: - lines += [f" {_link(line, repository)}", ""] + lines += [f" {_mdx(line, repository)}", ""] for line in section.entries: subsection = re.fullmatch(r"### (.+?)\s*", line) if subsection: name = subsection.group(1) lines.append(f" ### {DOCS_SECTIONS.get(name, name)}") elif line.strip(): - lines.append(f" {_link(line, repository)}") + lines.append(f" {_mdx(line, repository)}") else: lines.append("") while lines and not lines[-1]: diff --git a/scripts/release/test_notes.py b/scripts/release/test_notes.py index a6057acdaf..58a862ea78 100644 --- a/scripts/release/test_notes.py +++ b/scripts/release/test_notes.py @@ -309,6 +309,17 @@ def test_old_references_become_links(self): self.block("0.81.0"), ) + def test_mdx_syntax_outside_code_is_escaped(self): + section = notes.Section("0.80.0", "2026-10-11", False, [], [ + "### Changed", + "", + '- "{1 keys}" for one key, and `{1 keys}` or `` in code.', + "- tablepro://settings/ links.", + ]) + block = notes.render_update(section, "TableProApp/TablePro") + self.assertIn(' - "\\{1 keys\\}" for one key, and `{1 keys}` or `` in code.', block) + self.assertIn(" - tablepro://settings/\\ links.", block) + def test_the_date_has_no_leading_zero(self): self.assertEqual(notes.docs_date("2026-10-05"), "October 5, 2026") diff --git a/scripts/release/test_version.py b/scripts/release/test_version.py index 2449392dbc..354e1a45ee 100644 --- a/scripts/release/test_version.py +++ b/scripts/release/test_version.py @@ -92,7 +92,7 @@ def job_conclusions(self, run_id, job): def green_on(sha, prefix="run"): runs, jobs = {}, {} - for workflow, job in version.REQUIRED_RUNS: + for workflow, job, _ in version.REQUIRED_RUNS: run_id = f"{prefix}-{workflow}" runs[(workflow, sha)] = [[(run_id, "completed", "success")]] jobs[(run_id, job)] = ["success"] @@ -127,13 +127,22 @@ def setUp(self): self.git = version.Git(self.repo.path) self.slept = [] self.logged = [] + self.diffs = [] + self.changed_suites = set() self.base = self.repo.commit("0.81.0", MAIN_CHANGELOG) self.repo.git("tag", "v0.81.0") self.cut = self.repo.commit("feat: something") self.repo.line("0.82", self.cut) + def changed(self, base, suite): + self.diffs.append((base, suite)) + return suite in self.changed_suites + def check(self, tag, github): - return version.check(tag, self.git, github, sleep=self.slept.append, log=self.logged.append) + if self.git.exists(f"refs/tags/{tag}"): + self.repo.git("checkout", "--quiet", "--detach", tag) + return version.check(tag, self.git, github, changed=self.changed, sleep=self.slept.append, + log=self.logged.append) def test_a_first_beta_on_a_green_cut_commit_builds(self): self.repo.git("tag", "v0.82.0-beta.1", self.cut) @@ -146,12 +155,38 @@ def test_a_skipped_job_borrows_the_nearest_first_parent_verdict(self): self.repo.line("0.82", release_commit) self.repo.git("tag", "v0.82.0-beta.1", release_commit) runs, jobs = green_on(self.cut) - for workflow, job in version.REQUIRED_RUNS: + for workflow, job, _ in version.REQUIRED_RUNS: runs[(workflow, release_commit)] = [[("skip-" + workflow, "completed", "success")]] jobs[("skip-" + workflow, job)] = ["skipped"] github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) self.assertEqual(self.check("v0.82.0-beta.1", github), []) self.assertIn(f"macOS Tests: green on {self.cut}", self.logged) + self.assertEqual(self.diffs, [(self.cut, "macos"), (self.cut, "ios"), (self.cut, "release")]) + + def test_a_borrowed_verdict_must_still_cover_the_tag(self): + unrun = self.repo.commit("fix: pushed with no run") + self.repo.line("0.82", unrun) + self.repo.git("tag", "v0.82.0-beta.1", unrun) + self.changed_suites = {"macos"} + runs, jobs = green_on(self.cut) + github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) + self.assertEqual(self.check("v0.82.0-beta.1", github), + [f"macOS Tests passed on {self.cut}, but files it reads changed before v0.82.0-beta.1"]) + + def test_a_green_tag_commit_needs_no_diff(self): + self.repo.git("tag", "v0.82.0-beta.1", self.cut) + runs, jobs = green_on(self.cut) + self.changed_suites = {"macos", "ios", "release"} + github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) + self.assertEqual(self.check("v0.82.0-beta.1", github), []) + self.assertEqual(self.diffs, []) + + def test_the_walk_needs_the_tag_checked_out(self): + self.repo.git("tag", "v0.82.0-beta.1", self.base) + self.repo.line("0.82", self.cut) + errors = version.check("v0.82.0-beta.1", self.git, FakeGitHub(releases=[("v0.81.0", False)]), + changed=self.changed, sleep=self.slept.append, log=self.logged.append) + self.assertEqual(errors, ["check out v0.82.0-beta.1 first: the coverage check diffs against HEAD"]) def test_a_pending_run_is_waited_for(self): self.repo.git("tag", "v0.82.0-beta.1", self.cut) @@ -183,7 +218,7 @@ def test_a_run_that_failed_before_its_jobs_refuses_the_tag(self): def test_a_success_on_another_branch_run_of_the_same_commit_counts(self): self.repo.git("tag", "v0.82.0-beta.1", self.cut) runs, jobs = green_on(self.cut) - for workflow, job in version.REQUIRED_RUNS: + for workflow, job, _ in version.REQUIRED_RUNS: runs[(workflow, self.cut)][0].append(("branch-" + workflow, "completed", "success")) jobs[("branch-" + workflow, job)] = ["skipped"] github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) @@ -279,6 +314,22 @@ def test_a_bad_tag_stops_at_the_grammar(self): self.assertEqual(github.run_queries, []) +class DetectorTests(unittest.TestCase): + def test_the_detector_answer_is_read(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + script = root / version.DETECTOR + script.parent.mkdir(parents=True) + script.write_text('#!/bin/sh\n[ "$3" = macos ] && echo true || echo false\n', encoding="utf-8") + script.chmod(0o755) + changed = version.detector(root) + self.assertTrue(changed("abc", "macos")) + self.assertFalse(changed("abc", "ios")) + script.write_text("#!/bin/sh\necho broken >&2\nexit 2\n", encoding="utf-8") + with self.assertRaisesRegex(RuntimeError, "broken"): + changed("abc", "ios") + + class GitHubTests(unittest.TestCase): def test_gh_output_is_read_as_rows(self): calls = [] diff --git a/scripts/release/version.py b/scripts/release/version.py index c541d2cff2..bcdbdcd2d0 100755 --- a/scripts/release/version.py +++ b/scripts/release/version.py @@ -20,10 +20,11 @@ GRAMMAR = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-beta\.(0|[1-9][0-9]*))?") REQUIRED_RUNS = ( - ("macos-tests.yml", "macOS Tests"), - ("ios-tests.yml", "iOS and Package Tests"), - ("release-build.yml", "Release Build"), + ("macos-tests.yml", "macOS Tests", "macos"), + ("ios-tests.yml", "iOS and Package Tests", "ios"), + ("release-build.yml", "Release Build", "release"), ) +DETECTOR = "scripts/ci/detect-changed-paths.sh" WALK_DEPTH = 50 POLL_SECONDS = 60 PENDING_STATUSES = {"queued", "in_progress", "waiting", "requested", "pending"} @@ -107,6 +108,9 @@ def _git(self, *args: str, check: bool = True) -> subprocess.CompletedProcess: raise RuntimeError(f"git {' '.join(args)}: {result.stderr.strip()}") return result + def commit(self, ref: str) -> str: + return self._git("rev-parse", "--verify", f"{ref}^{{commit}}").stdout.strip() + def exists(self, ref: str) -> bool: return self._git("rev-parse", "--quiet", "--verify", f"{ref}^{{commit}}", check=False).returncode == 0 @@ -160,6 +164,16 @@ class CheckFailure(Exception): pass +def detector(root: Path, run: Callable = subprocess.run) -> Callable[[str, str], bool]: + def changed(base: str, suite: str) -> bool: + result = run([str(root / DETECTOR), "--base", base, suite], cwd=root, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"{DETECTOR} --base {base} {suite}: {result.stderr.strip()}") + return result.stdout.strip() != "false" + + return changed + + def published_stables(releases: List[Tuple[str, bool]]) -> List[Version]: versions = [try_parse_tag(tag) for tag, draft in releases if not draft] return [version for version in versions if version is not None and not version.is_beta] @@ -246,25 +260,31 @@ def is_green(github: GitHub, workflow: str, job: str, sha: str, return any(conclusion == "success" for _, conclusion in conclusions) -def ci_errors(git: Git, github: GitHub, version: Version, +def ci_errors(git: Git, github: GitHub, version: Version, changed: Callable[[str, str], bool], sleep: Callable[[float], None] = time.sleep, log: Callable[[str], None] = print) -> List[str]: - errors = [] + tag_commit = git.commit(version.tag) + if git.commit("HEAD") != tag_commit: + return [f"check out {version.tag} first: the coverage check diffs against HEAD"] shas = git.first_parents(version.tag, WALK_DEPTH) - for workflow, job in REQUIRED_RUNS: + errors = [] + for workflow, job, suite in REQUIRED_RUNS: try: - for sha in shas: - if is_green(github, workflow, job, sha, sleep, log): - log(f"{job}: green on {sha}") - break - else: - errors.append(f"no {job} run that was not skipped among the {len(shas)} first-parent commits " - f"of {version.tag}") + green = next((sha for sha in shas if is_green(github, workflow, job, sha, sleep, log)), None) except CheckFailure as failure: errors.append(str(failure)) + continue + if green is None: + errors.append(f"no {job} run that was not skipped among the {len(shas)} first-parent commits " + f"of {version.tag}") + # A commit with no run of its own would otherwise borrow a verdict that never covered it. + elif green != tag_commit and changed(green, suite): + errors.append(f"{job} passed on {green}, but files it reads changed before {version.tag}") + else: + log(f"{job}: green on {green}") return errors -def check(tag: str, git: Git, github: GitHub, +def check(tag: str, git: Git, github: GitHub, changed: Optional[Callable[[str, str], bool]] = None, sleep: Callable[[float], None] = time.sleep, log: Callable[[str], None] = print) -> List[str]: try: version = parse_tag(tag) @@ -278,7 +298,7 @@ def check(tag: str, git: Git, github: GitHub, errors += changelog_errors(git.show(tag, "CHANGELOG.md"), version, published_stables(releases)) if errors: return errors - return ci_errors(git, github, version, sleep, log) + return ci_errors(git, github, version, changed or detector(git.root), sleep, log) def main(argv: Optional[List[str]] = None) -> int: