diff --git a/.claude/rules/changelog.md b/.claude/rules/changelog.md index f47624fa28..73e5f43f5e 100644 --- a/.claude/rules/changelog.md +++ b/.claude/rules/changelog.md @@ -7,13 +7,8 @@ paths: # Changelog -Unreleased changes are towncrier fragments in `apple/changelog.d/`. A release turns them into an `apple/CHANGELOG.md` section with `apple/scripts/release/notes.py assemble` (`apple/RELEASING.md`, Notes). The Linux app is a second towncrier project under the same rules, in `linux/changelog.d/` and `linux/CHANGELOG.md`; its release runs `towncrier build --dir linux` (`linux/docs/releasing.md`). - -- **One file per user-visible change**, named `apple/changelog.d/..md`: `` is the pull request that adds it, `` is `added`, `changed`, `deprecated`, `removed`, `fixed` or `security`. A second entry of the same type in one PR adds a counter, as `towncrier create` does: `3452.fixed.1.md`, then `.2`. The counter does not order entries. -- **One line, at most 120 characters, ending with a period.** Added and Changed name the new thing (`Open in Window for row inspector text fields.`); Fixed names the bug, not the fix (`Empty context menu when right-clicking a read-only inspector field.`). No file paths or type names. -- **No refs, no credit, no `- ` prefix.** towncrier links the PR named by the file and adds the bullet; `notes.py` adds ` by @login` when the PR's author is not a maintainer. -- **One entry per thing a user would notice.** Defects with the same visible symptom share an entry. -- **No fragment** for a fix to something still unreleased (edit that fragment instead) or for a docs-only change. -- **Only a `chore(release): vX.Y.Z` PR edits `apple/CHANGELOG.md`**, and only a `chore(release): linux-vX.Y.Z` PR edits `linux/CHANGELOG.md`. -- **The lead block and `[CRITICAL]`**: `apple/RELEASING.md`, Notes and Channels. -- `scripts/ci/check-changelog.py` checks both projects' fragment text, headings and lead blocks in Repo Hygiene, then `towncrier build --draft --version 0.0.0` renders the fragments of each, with `--dir apple` and `--dir linux`. towncrier refuses an unknown type and a non-numeric PR number (`ignore`, `issue_pattern`). In PR title, `--pr` checks that a PR into main names each new fragment after itself, that only a release PR edits a `CHANGELOG.md`, and that a release PR changes nothing else; a Linux one also sets the version. +- A user-visible change adds one towncrier fragment to its app, `apple/changelog.d/..md` or `linux/changelog.d/..md`, where `` is the pull request that adds it. A second entry of one type adds a counter: `3452.fixed.1.md`. +- `` is `added`, `changed`, `deprecated`, `removed`, `fixed` or `security`. +- One sentence per entry, in the user's terms, at most 120 characters, ending with a period. Fixed names the bug, not the fix. No bullet, PR link or credit: towncrier and `notes.py` add them. +- Only a `chore(release)` pull request edits `apple/CHANGELOG.md` or `linux/CHANGELOG.md`; PR title refuses any other. +- The release, the lead and `[CRITICAL]`: `apple/RELEASING.md`, Notes, and `linux/docs/releasing.md`. diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index 7c1ac2d94f..b96dfeac0a 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -99,7 +99,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ ## PR title (`pr-title.yml`) - Runs on opened, edited, reopened and synchronize, so every head commit gets the check. -- Runs `scripts/ci/check-pr-title.py`, then `scripts/ci/check-changelog.py --pr` on the merge commit's diff to its first parent, which also refuses a release PR that changes more than `apple/CHANGELOG.md` and `apple/changelog.d/`. +- Runs `scripts/ci/check-pr-title.py`, then refuses an edit to `apple/CHANGELOG.md` or `linux/CHANGELOG.md` in the merge commit's diff to its first parent unless the title starts with `chore(release)`. ## Repo Hygiene (`repo-hygiene.yml`) @@ -109,7 +109,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - `shellcheck --severity=warning` on every `*.sh` under `scripts`, `apple/scripts` and `.claude/skills`; - the tooling tests: `scripts/ci/test_*.py`, `apple/scripts/ci/test_*.py`, `apple/scripts/lib/test_*.py`, `apple/scripts/test_localization.py` and `apple/scripts/ci/test_libs_release.sh`; - `apple/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`; - - in the same venv, `check-changelog.py` and `towncrier build --draft --version 0.0.0` with `--dir apple` and `--dir linux`, which fails on a fragment name it cannot parse; + - in the same venv, `towncrier build --draft --version 0.0.0` with `--dir apple` and `--dir linux`, which fails on a fragment name it cannot parse; - `check-lib-sourcing.py`, `check-plugin-manifest.py`, `check-plugin-provides.py`, `check-deployment-target.sh`; - `localization.py plugins` and `plugins --target ios`; - `check-string-catalogs.py`, `check-license-inventory.py`, `check-entitlements.py`, `check-test-plans.py`; @@ -128,7 +128,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - `shipped` is `release` without its `.github/workflows/` and `.github/actions/` entries. `version.py check` reads it for a stable's diff from its newest beta (`apple/RELEASING.md`, What a tag runs). - `linux` watches `linux/`, the detector itself and the three Linux workflows. - A path a suite newly reads joins that suite's pattern in the same change, or the suite skips on it. -- `apple/CHANGELOG.md` and `apple/changelog.d/` are on no suite's pattern: the What's New phase writes nothing at `0.0.0-dev`, and Repo Hygiene renders every fragment and section instead. +- `apple/CHANGELOG.md` and `apple/changelog.d/` are on no suite's pattern: the What's New phase writes nothing at `0.0.0-dev`, Repo Hygiene renders every fragment instead, and `version.py check` reads the section at the tag. ## What runs where diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index fbfd7e8616..3f1a0f71f9 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -23,18 +23,22 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 2 - # check-changelog.py imports apple/scripts/release/notes.py. - sparse-checkout: | - scripts - apple/scripts/release + sparse-checkout: scripts - name: Check the title is a Conventional Commits subject env: TITLE: ${{ github.event.pull_request.title }} run: python3 scripts/ci/check-pr-title.py "$TITLE" - - name: Check CHANGELOG.md and the changelog fragments the pull request changes + # A version heading added outside a release PR makes version.py refuse its betas and notes.py assemble refuse it. + - name: Check only a release pull request edits a CHANGELOG.md env: - PR: ${{ github.event.pull_request.number }} TITLE: ${{ github.event.pull_request.title }} - run: python3 scripts/ci/check-changelog.py --pr "$PR" --title "$TITLE" --base "$GITHUB_BASE_REF" + run: | + set -euo pipefail + case "$TITLE" in 'chore(release)'*) exit 0 ;; esac + edited="$(git diff --no-renames --name-only HEAD^1 HEAD -- apple/CHANGELOG.md linux/CHANGELOG.md)" + if [ -n "$edited" ]; then + echo "::error title=Changelog::Only a chore(release) pull request edits ${edited//$'\n'/ or }. Add a changelog.d/..md fragment instead." + exit 1 + fi diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index a17c71e436..b77d552099 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -75,10 +75,9 @@ jobs: run: '"$RUNNER_TEMP/release-venv/bin/python" -m unittest discover -s apple/scripts/release -p "test_*.py"' # towncrier refuses a fragment name it cannot parse once its config sets ignore. - - name: Check the changelog fragments and CHANGELOG.md + - name: Render the changelog fragments run: | set -euo pipefail - "$RUNNER_TEMP/release-venv/bin/python" scripts/ci/check-changelog.py "$RUNNER_TEMP/release-venv/bin/towncrier" build --draft --dir apple --version 0.0.0 "$RUNNER_TEMP/release-venv/bin/towncrier" build --draft --dir linux --version 0.0.0 diff --git a/apple/RELEASING.md b/apple/RELEASING.md index be48db3bfe..a2c79e580c 100644 --- a/apple/RELEASING.md +++ b/apple/RELEASING.md @@ -73,7 +73,7 @@ git push -u origin prep/vX.Y.Z gh pr create --base release/X.Y --title "chore(release): vX.Y.Z" --body "Release notes for vX.Y.Z." ``` -The PR changes only `CHANGELOG.md` and `changelog.d/`; PR title refuses anything else. Squash-merge it, then tag its merge commit once that commit's push runs are green (What a tag runs, `verify`): +The PR changes only `CHANGELOG.md` and `changelog.d/`. Squash-merge it, then tag its merge commit once that commit's push runs are green (What a tag runs, `verify`): ```bash git fetch origin @@ -100,7 +100,7 @@ python3 apple/scripts/release/notes.py docs X.Y.Z --ref vX.Y.Z --mdx ../tablepro A `v*` tag runs `release.yml` and nothing else. A run is never cancelled, and every job reads the version, build and channel from `verify`. -- `verify`: `version.py check` refuses a tag that breaks the grammar or the bounds; that `origin/release/` does not contain; whose previous tag on the line is not its ancestor; on a commit that already carries another `v*` tag; a stable X.Y.0 with no published beta, or a beta after its X.Y.0; a stable that changes shipped source (the `shipped` suite of `scripts/ci/detect-changed-paths.sh`) since its line's newest published beta, until a stable tag on the line has matched that beta, so a patch after a matching X.Y.0 passes; a `CHANGELOG.md` missing the stable's heading, or a published stable of this or the previous line below it; a stable whose tag still holds fragments in `changelog.d/`. Then it reads the tag commit's push runs on `release/` once: macOS Tests and iOS and Package Tests must each have completed with success, their test job run and not skipped. A `release/**` push runs every suite, so the line head always gets both (`.claude/rules/ci.md`, Change detector). It never waits: a run still queued or in progress fails it. Release Build is no gate. Last, it writes the notes. +- `verify`: `version.py check` refuses a tag that breaks the grammar or the bounds; that `origin/release/` does not contain; whose previous tag on the line is not its ancestor; on a commit that already carries another `v*` tag; a stable X.Y.0 with no published beta, or a beta after its X.Y.0; a stable that changes shipped source (the `shipped` suite of `scripts/ci/detect-changed-paths.sh`) since its line's newest published beta, until a stable tag on the line has matched that beta, so a patch after a matching X.Y.0 passes; a `CHANGELOG.md` missing the stable's heading, or a published stable of this or the previous line below it; a lead on a patch, of more than 6 lines, or with a line starting with `#` or `- `; a stable whose tag still holds fragments in `changelog.d/`. Then it reads the tag commit's push runs on `release/` once: macOS Tests and iOS and Package Tests must each have completed with success, their test job run and not skipped. A `release/**` push runs every suite, so the line head always gets both (`.claude/rules/ci.md`, Change detector). It never waits: a run still queued or in progress fails it. Release Build is no gate. Last, it writes the notes. - `app` (`signing`): a GA Xcode for a stable (`setup-xcode` `require-release`), the CloudKit Production schema check, `build.sh app` (archive, export with `scripts/release/ExportOptions.plist`, universal and plugin load checks), `notarize.sh` on the app, `package.sh app`, `notarize.sh` on the DMG. - `plugins` (`signing`): the Dameng and HANA bridges, `build.sh plugins` (each target `registry.py targets` prints, universal, signed with Developer ID, the hardened runtime and a timestamp), one notary submission for all, `package.sh plugins` (the zips, described as the manifest will read them). - `github-release`: `actions/attest` attests `dist/*`; `gh release create` drafts, uploads and publishes; `gh release verify` and `verify-asset` check the published release. diff --git a/apple/scripts/release/test_version.py b/apple/scripts/release/test_version.py index e50f05dbb8..df52173944 100644 --- a/apple/scripts/release/test_version.py +++ b/apple/scripts/release/test_version.py @@ -408,6 +408,56 @@ def test_a_bad_tag_stops_at_the_grammar(self): 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, []) + def test_a_lead_on_a_patch_refuses_the_tag_before_the_query(self): + release_commit = self.repo.commit("chore(release): v0.82.1", with_section("0.82.1", "A lead.")) + self.repo.line("0.82", release_commit) + self.repo.git("tag", "v0.82.1", release_commit) + github = FakeGitHub(releases=[("v0.81.0", False)]) + self.assertEqual(self.check("v0.82.1", github), + ["CHANGELOG.md at v0.82.1: ## [0.82.1] has a lead, and only an X.Y.0 takes one"]) + self.assertEqual(github.run_queries, []) + + +def with_section(heading, lead=None): + body = f"{lead}\n\n### Fixed\n\n- A fix.\n\n" if lead is not None else "### Fixed\n\n- A fix.\n\n" + return MAIN_CHANGELOG.replace("## [0.81.0]", f"## [{heading}] - 2026-10-26\n\n{body}## [0.81.0]") + + +class LeadTests(unittest.TestCase): + def errors(self, changelog, tag): + return version.lead_errors(changelog, version.parse_tag(tag)) + + def test_an_x_y_0_lead_of_six_plain_lines_passes(self): + lead = "\n".join(f"Line {index}." for index in range(6)) + self.assertEqual(self.errors(with_section("0.82.0", lead), "v0.82.0"), []) + + def test_a_section_without_a_lead_passes(self): + self.assertEqual(self.errors(with_section("0.82.1"), "v0.82.1"), []) + + def test_a_patch_takes_no_lead(self): + self.assertEqual(self.errors(with_section("0.82.1", "A lead."), "v0.82.1"), + ["CHANGELOG.md at v0.82.1: ## [0.82.1] has a lead, and only an X.Y.0 takes one"]) + + def test_a_lead_takes_at_most_six_lines(self): + lead = "\n".join(f"Line {index}." for index in range(7)) + self.assertEqual(self.errors(with_section("0.82.0", lead), "v0.82.0"), + ["CHANGELOG.md at v0.82.0: the 0.82.0 lead has 7 lines, over 6"]) + + def test_a_lead_line_is_plain_text(self): + for line in ("# Highlights", "## Highlights", "#### Highlights", "- A bullet."): + with self.subTest(line=line): + self.assertEqual(self.errors(with_section("0.82.0", f"A lead.\n{line}"), "v0.82.0"), + [f"CHANGELOG.md at v0.82.0: the 0.82.0 lead line `{line}` is not plain text"]) + + def test_only_the_tags_own_section_is_read(self): + old_lead = "\n".join(f"- Line {index}." for index in range(9)) + changelog = with_section("0.82.0", old_lead).replace( + "## [0.82.0]", "## [0.82.1] - 2026-10-27\n\n### Fixed\n\n- A fix.\n\n## [0.82.0]") + self.assertEqual(self.errors(changelog, "v0.82.1"), []) + + def test_a_beta_reads_no_section(self): + self.assertEqual(self.errors(with_section("0.82.0", "- A bullet."), "v0.82.0-beta.1"), []) + class AppleTreeTests(unittest.TestCase): def test_the_changelog_and_fragments_come_from_the_tree_git_runs_in(self): diff --git a/apple/scripts/release/version.py b/apple/scripts/release/version.py index df7c835cd1..9a9406fc5e 100755 --- a/apple/scripts/release/version.py +++ b/apple/scripts/release/version.py @@ -9,11 +9,14 @@ from pathlib import Path from typing import Callable, List, Optional, Tuple +import notes + REPOSITORY = "TableProApp/TablePro" MAX_MINOR = 999 MAX_PATCH = 99 MAX_BETA = 98 STABLE_RANK = 99 +MAX_LEAD_LINES = 6 # [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]*))?") @@ -273,6 +276,24 @@ def changelog_errors(changelog: Optional[str], version: Version, stables: List[V return errors +# With a lead, the Sparkle notes, What's New and the Telegram post show only the lead. +def lead_errors(changelog: Optional[str], version: Version) -> List[str]: + if changelog is None or version.is_beta: + return [] + section = notes.find_section(changelog, str(version)) + if section is None or not section.lead: + return [] + where = f"CHANGELOG.md at {version.tag}" + errors = [] + if version.patch != 0: + errors.append(f"{where}: ## [{version}] has a lead, and only an X.Y.0 takes one") + if len(section.lead) > MAX_LEAD_LINES: + errors.append(f"{where}: the {version} lead has {len(section.lead)} lines, over {MAX_LEAD_LINES}") + errors += [f"{where}: the {version} lead line `{line}` is not plain text" + for line in section.lead if line.startswith(("#", "- "))] + return errors + + def fragment_errors(paths: List[str], version: Version) -> List[str]: if version.is_beta: return [] @@ -317,7 +338,9 @@ def check(tag: str, git: Git, github: GitHub, changed: Callable[[str, str, str], errors += shared_commit_errors(git, version) errors += beta_errors(git, version, releases) errors += stable_tree_errors(git, version, releases, changed) - errors += changelog_errors(git.show(tag, "./CHANGELOG.md"), version, published_stables(releases)) + changelog = git.show(tag, "./CHANGELOG.md") + errors += changelog_errors(changelog, version, published_stables(releases)) + errors += lead_errors(changelog, version) errors += fragment_errors(git.list_tree(tag, FRAGMENTS), version) if errors: return errors diff --git a/linux/docs/releasing.md b/linux/docs/releasing.md index a31a0ea750..f56c31946f 100644 --- a/linux/docs/releasing.md +++ b/linux/docs/releasing.md @@ -24,7 +24,7 @@ A repository admin does these once. The workflow's header names the same secrets - The version's section in `CHANGELOG.md`, from the fragments in `changelog.d/`. From the repository root, with the towncrier venv of the root `RELEASING.md`, Notes: `.venv/bin/towncrier build --dir linux --version X.Y.Z --date YYYY-MM-DD --yes`. It deletes the fragments and stages `CHANGELOG.md`. An X.Y.0 types its lead under the heading, at most 6 plain lines naming what a reader would change their behavior over. - The `.TH` line of `data/tablepro.1`: the date and `TablePro X.Y.Z`. - The pull request changes only these files and `changelog.d/`; PR title refuses anything else. The `release-metadata` Meson test checks that they agree once the changelog heading exists, so the pull request fails before the tag does. + The pull request changes only these files and `changelog.d/`. The `release-metadata` Meson test checks that they agree once the changelog heading exists, so the pull request fails before the tag does. 2. **The tag**: `linux-vX.Y.Z` on the merged commit, pushed the same UTC day. The workflow compares the metainfo date with the commit date. 3. **The workflow** checks the tag, builds and tests both packages on amd64 and arm64, publishes the GitHub release with the tarballs and the version's changelog section as notes, uploads the signed source package to the PPA, and posts the tag to COPR. A reviewer approves each step that uses the environment. 4. **Afterwards**: watch the Launchpad build page of each series and the COPR build the workflow prints. A failed build there does not fail the workflow. diff --git a/scripts/ci/check-changelog.py b/scripts/ci/check-changelog.py deleted file mode 100755 index 3c4e805955..0000000000 --- a/scripts/ci/check-changelog.py +++ /dev/null @@ -1,290 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import datetime -import importlib.util -import re -import subprocess -import sys -from pathlib import Path -from typing import Dict, List, NamedTuple, Optional, Tuple - -ROOT = Path(__file__).resolve().parent.parent.parent -NOTES_SPEC = importlib.util.spec_from_file_location("notes", ROOT / "apple" / "scripts" / "release" / "notes.py") -notes = importlib.util.module_from_spec(NOTES_SPEC) -NOTES_SPEC.loader.exec_module(notes) - -REPOSITORY = "TableProApp/TablePro" -MARKER = "" -CHANGELOG = "CHANGELOG.md" -CONFIG = "towncrier.toml" -FRAGMENTS = "changelog.d" -IGNORED = {".gitkeep"} -MAX_LINE = 120 -MAX_LEAD_LINES = 6 -VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" -DATE = r"(?P[0-9]{4}-[0-9]{2}-[0-9]{2})" -OLD_HEADING = re.compile(rf"## \[(?P{VERSION})\] - {DATE}") -GIT_STATUSES = {"A": "added", "D": "removed"} - - -class Project(NamedTuple): - directory: str - tag: str - release_files: Tuple[str, ...] = () - - def path(self, name: str) -> str: - return f"{self.directory}/{name}" if self.directory else name - - @property - def changelog(self) -> str: - return self.path(CHANGELOG) - - @property - def fragments(self) -> str: - return self.path(FRAGMENTS) - - @property - def new_heading(self) -> "re.Pattern[str]": - return re.compile( - rf"## \[(?P{VERSION})\]\(https://github\.com/{re.escape(REPOSITORY)}/releases/tag/" - rf"{re.escape(self.tag)}(?P=version)\) - {DATE}(?: \[CRITICAL\])?" - ) - - @property - def release_title(self) -> "re.Pattern[str]": - # The forward-port keeps the release commit's subject, which ends in its (#N). - return re.compile(rf"chore\(release\): {re.escape(self.tag)}{VERSION}(?: \(#[0-9]+\))?") - - -# Each directory with a towncrier.toml, and the tag its title_format links. The Linux release -# commit also sets the version and date that linux/build-aux/packaging/check_packaging.py compares. -PROJECTS = ( - Project("apple", "v"), - Project("linux", "linux-v", ("meson.build", "data/app.tablepro.TablePro.metainfo.xml.in.in", - "build-aux/rpm/tablepro.spec", "data/tablepro.1")), -) - - -class ChangedFile(NamedTuple): - filename: str - status: str - - -class CheckError(Exception): - pass - - -def fragment_problems(directory: Path, fragments: str = FRAGMENTS) -> List[str]: - if not directory.is_dir(): - return [] - problems = [] - for path in sorted(directory.iterdir()): - name = f"{fragments}/{path.name}" - if path.name in IGNORED: - continue - if not path.is_file(): - problems.append(f"{name} is not a file") - continue - try: - text = path.read_text(encoding="utf-8") - except UnicodeDecodeError: - problems.append(f"{name} is not UTF-8") - continue - lines = [line.strip() for line in text.splitlines() if line.strip()] - if len(lines) != 1: - problems.append(f"{name} has {len(lines)} non-blank lines; a fragment is one line") - continue - line = lines[0] - if len(line) > MAX_LINE: - problems.append(f"{name} is {len(line)} characters, over the {MAX_LINE} limit") - if line.startswith("- "): - problems.append(f"{name} starts with `- `; towncrier adds the bullet") - if "by @" in line: - problems.append(f"{name} credits an author; notes.py adds the credit at release") - return problems - - -def _is_date(text: str) -> bool: - try: - datetime.date.fromisoformat(text) - except ValueError: - return False - return True - - -def changelog_problems(changelog: str, project: Project = PROJECTS[0]) -> List[str]: - name = project.changelog - lines = changelog.split("\n") - markers = [index for index, line in enumerate(lines) if line == MARKER] - if len(markers) != 1: - return [f"{name} has {len(markers)} `{MARKER}` lines, expected 1"] - marker = markers[0] - problems = [] - - first = next((index for index in range(marker + 1, len(lines)) if lines[index].startswith("## [")), None) - if first is not None and lines[marker + 1:first] != [""]: - problems.append(f"{name}:{marker + 1}: only one blank line goes between the marker and the first ## [") - - new_heading = project.new_heading - versions: List[Tuple[int, ...]] = [] - seen: Dict[str, int] = {} - for index, line in enumerate(lines): - if not line.startswith("## "): - continue - number = index + 1 - if index < marker: - problems.append(f"{name}:{number}: `{line}` is above the marker") - continue - old, new = OLD_HEADING.fullmatch(line), new_heading.fullmatch(line) - heading = old or new - if heading is None: - problems.append(f"{name}:{number}: `{line}` is not `## [X.Y.Z]() - YYYY-MM-DD`") - continue - if not _is_date(heading.group("date")): - problems.append(f"{name}:{number}: {heading.group('date')} is not a date") - version = heading.group("version") - if version in seen: - problems.append(f"{name}:{number}: ## [{version}] repeats line {seen[version]}") - continue - seen[version] = number - parsed = tuple(int(part) for part in version.split(".")) - if versions and parsed >= versions[-1]: - problems.append(f"{name}:{number}: ## [{version}] is not below the version above it") - versions.append(parsed) - if new is not None: - problems += _new_section_problems(changelog, version, f"{name}:{number}") - return problems - - -def _new_section_problems(changelog: str, version: str, where: str) -> List[str]: - section = notes.find_section(changelog, version) - if section is None: - return [f"{where}: notes.py cannot find ## [{version}]"] - if not section.lead: - return [] - problems = [] - if not version.endswith(".0"): - problems.append(f"{where}: {version} has a lead block; only an X.Y.0 takes one") - if len(section.lead) > MAX_LEAD_LINES: - problems.append(f"{where}: the {version} lead block has {len(section.lead)} lines, over {MAX_LEAD_LINES}") - for line in section.lead: - if line.startswith(("#", "- ")): - problems.append(f"{where}: the {version} lead line `{line}` is not plain text") - return problems - - -def repository_problems(root: Path, projects: Tuple[Project, ...] = PROJECTS) -> List[str]: - problems = [] - for project in projects: - problems += changelog_problems((root / project.changelog).read_text(encoding="utf-8"), project) - problems += fragment_problems(root / project.fragments, project.fragments) - return problems - - -def _fragment_name(project: Project, path: str) -> Optional[str]: - prefix = f"{project.fragments}/" - return path[len(prefix):] if path.startswith(prefix) else None - - -def _owner(project: Project, path: str) -> Optional[str]: - name = _fragment_name(project, path) - return None if name is None else name.partition(".")[0] - - -def _listing(items: List[str]) -> str: - return items[0] if len(items) == 1 else f"{', '.join(items[:-1])} and {items[-1]}" - - -def pull_request_problems(number: int, title: str, base: str, files: List[ChangedFile], - projects: Tuple[Project, ...] = PROJECTS) -> List[str]: - problems = [] - for project in projects: - problems += _project_pull_request_problems(project, number, title, base, files) - return problems - - -def _project_pull_request_problems(project: Project, number: int, title: str, base: str, - files: List[ChangedFile]) -> List[str]: - # The pull request that adds a project's towncrier.toml brings its changelog and fragments as they are. - if ChangedFile(project.path(CONFIG), "added") in files: - return [] - problems = [] - if project.release_title.fullmatch(title): - release_files = [project.changelog, *(project.path(name) for name in project.release_files)] - others = [changed.filename for changed in files - if changed.filename not in release_files and _fragment_name(project, changed.filename) is None] - if others: - more = f" and {len(others) - 1} more" if len(others) > 1 else "" - allowed = _listing([release_files[0], f"{project.fragments}/", *release_files[1:]]) - problems.append(f"a release pull request changes only {allowed}; " - f"this one also changes {others[0]}{more}") - elif any(changed.filename == project.changelog for changed in files): - problems.append(f"only a `chore(release): {project.tag}X.Y.Z` pull request edits {project.changelog}. " - f"Put the entry in {project.fragments}/{number}..md (.claude/rules/changelog.md)") - if base != "main": - return problems - - # A fragment deleted and added again under a new type keeps the pull request it is named for. - removed = {_owner(project, changed.filename) for changed in files if changed.status == "removed"} - for changed in files: - name = _fragment_name(project, changed.filename) - if changed.status != "added" or name is None or name in IGNORED: - continue - owner = name.partition(".")[0] - if owner != str(number) and owner not in removed: - rest = name.partition(".")[2] - # A rest with no dot holds no type, so towncrier would refuse it as a suffix. - if "." not in rest: - rest = ".md" - problems.append(f"{changed.filename} is not named for this pull request. " - f"Name it {project.fragments}/{number}.{rest}") - return problems - - -def changed_files(root: Path) -> List[ChangedFile]: - result = subprocess.run(["git", "diff", "--name-status", "--no-renames", "-z", "HEAD^1", "HEAD"], - cwd=root, capture_output=True, text=True) - if result.returncode != 0: - raise CheckError(f"git diff HEAD^1 HEAD: {result.stderr.strip()}") - fields = result.stdout.split("\0")[:-1] - return [ChangedFile(path, GIT_STATUSES.get(status, "modified")) - for status, path in zip(fields[0::2], fields[1::2])] - - -def workflow_command_data(text: str) -> str: - return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") - - -def main(argv: Optional[List[str]] = None) -> int: - parser = argparse.ArgumentParser(description="Check each towncrier project's changelog.d/ and CHANGELOG.md.") - parser.add_argument("--pr", type=int, - help="check the files this pull request changes instead, from its merge commit at HEAD") - parser.add_argument("--title", help="the pull request's title, with --pr") - parser.add_argument("--base", help="the branch the pull request merges into, with --pr") - parser.add_argument("--root", type=Path, default=ROOT) - args = parser.parse_args(argv) - if args.pr is not None and (args.title is None or args.base is None): - parser.error("--pr needs --title and --base") - - try: - if args.pr is None: - problems = repository_problems(args.root) - else: - problems = pull_request_problems(args.pr, args.title, args.base, changed_files(args.root)) - except (CheckError, OSError, ValueError) as error: - problems = [str(error)] - - for problem in problems: - print(f"::error title=Changelog::{workflow_command_data(problem)}") - if problems: - return 1 - if args.pr is None: - print("OK: " + ", ".join(f"{project.fragments}/ and {project.changelog}" for project in PROJECTS)) - else: - print(f"OK: the changelog files of #{args.pr}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/ci/test_check_changelog.py b/scripts/ci/test_check_changelog.py deleted file mode 100644 index 0659345364..0000000000 --- a/scripts/ci/test_check_changelog.py +++ /dev/null @@ -1,457 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import io -import os -import subprocess -import tempfile -import unittest -from contextlib import redirect_stdout -from pathlib import Path -from unittest import mock - -SPEC = importlib.util.spec_from_file_location("check_changelog", Path(__file__).with_name("check-changelog.py")) -check = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(check) - -LINK = "https://github.com/TableProApp/TablePro/releases/tag" -HEADER = "# Changelog\n\nUnreleased changes are files in `changelog.d/`.\n\n\n\n" -NEW = f"## [0.82.0]({LINK}/v0.82.0) - 2026-10-25\n\nOne lead line.\n\n### Added\n\n- A thing. ([#1](x))\n\n" -OLD = "## [0.81.0] - 2026-10-16\n\n### Fixed\n\n- A bug.\n\n## [0.80.0] - 2026-10-11\n\n### Added\n\n- Another.\n" -LINUX = check.PROJECTS[1] -LINUX_NEW = f"## [0.1.0]({LINK}/linux-v0.1.0) - 2026-10-25\n\nOne lead line.\n\n### Added\n\n- A thing. ([#1](x))\n" -GIT_ENV = { - "GIT_CONFIG_GLOBAL": os.devnull, - "GIT_CONFIG_NOSYSTEM": "1", - "GIT_AUTHOR_NAME": "Fixture", - "GIT_AUTHOR_EMAIL": "fixture@example.com", - "GIT_COMMITTER_NAME": "Fixture", - "GIT_COMMITTER_EMAIL": "fixture@example.com", -} - - -class FragmentTests(unittest.TestCase): - def setUp(self): - self.scratch = tempfile.TemporaryDirectory() - self.addCleanup(self.scratch.cleanup) - self.directory = Path(self.scratch.name, "changelog.d") - self.directory.mkdir() - (self.directory / ".gitkeep").write_text("", encoding="utf-8") - - def problems(self, name, text): - (self.directory / name).write_text(text, encoding="utf-8") - return check.fragment_problems(self.directory) - - def test_a_one_line_fragment_passes(self): - self.assertEqual(self.problems("3446.added.md", "Generate UUID in Set Value.\n"), []) - - def test_counters_and_orphans_pass(self): - self.assertEqual(self.problems("3452.fixed.1.md", "A bug.\n"), []) - self.assertEqual(self.problems("+theme-import.changed.md", "A change.\n"), []) - - def test_an_empty_directory_and_a_missing_one_pass(self): - self.assertEqual(check.fragment_problems(self.directory), []) - self.assertEqual(check.fragment_problems(self.directory / "missing"), []) - - def test_more_than_one_line_is_refused(self): - self.assertIn("2 non-blank lines", self.problems("1.added.md", "One.\nTwo.\n")[0]) - - def test_a_blank_fragment_is_refused(self): - self.assertIn("0 non-blank lines", self.problems("1.added.md", "\n\n")[0]) - - def test_blank_lines_around_the_entry_pass(self): - self.assertEqual(self.problems("1.added.md", "\nA thing.\n\n"), []) - - def test_the_line_limit_is_120_characters(self): - self.assertEqual(self.problems("1.added.md", "x" * 120 + "\n"), []) - self.assertIn("121 characters", self.problems("1.added.md", "x" * 121 + "\n")[0]) - - def test_a_bullet_is_refused(self): - self.assertIn("`- `", self.problems("1.added.md", "- A thing.\n")[0]) - - def test_a_credit_is_refused(self): - self.assertIn("credit", self.problems("1.added.md", "A thing by @someone.\n")[0]) - - def test_a_directory_inside_is_refused(self): - (self.directory / "added").mkdir() - self.assertEqual(check.fragment_problems(self.directory), ["changelog.d/added is not a file"]) - - def test_a_problem_names_the_project_directory(self): - (self.directory / "1.added.md").write_text("- A thing.\n", encoding="utf-8") - problems = check.fragment_problems(self.directory, "linux/changelog.d") - self.assertEqual(len(problems), 1) - self.assertTrue(problems[0].startswith("linux/changelog.d/1.added.md starts with")) - - -class ChangelogTests(unittest.TestCase): - def test_old_and_new_headings_pass(self): - self.assertEqual(check.changelog_problems(HEADER + NEW + OLD), []) - - def test_a_critical_new_heading_passes(self): - text = HEADER + NEW.replace("2026-10-25\n", "2026-10-25 [CRITICAL]\n", 1) + OLD - self.assertEqual(check.changelog_problems(text), []) - - def test_only_old_headings_pass(self): - self.assertEqual(check.changelog_problems(HEADER + OLD), []) - - def test_a_missing_or_repeated_marker_is_refused(self): - self.assertIn("0 `", check.changelog_problems(OLD)[0]) - self.assertIn("2 `", check.changelog_problems(HEADER + "\n" + OLD)[0]) - - def test_text_between_the_marker_and_the_first_version_is_refused(self): - text = HEADER + "- A stray entry.\n\n" + OLD - self.assertEqual(len(check.changelog_problems(text)), 1) - self.assertEqual(len(check.changelog_problems(HEADER + "\n" + OLD)), 1) - self.assertEqual(len(check.changelog_problems(HEADER.rstrip("\n") + "\n" + OLD)), 1) - - def test_a_heading_above_the_marker_is_refused(self): - text = "# Changelog\n\n## [Unreleased]\n\n" + HEADER.split("\n", 2)[2] + OLD - problems = check.changelog_problems(text) - self.assertEqual(len(problems), 1) - self.assertIn("above the marker", problems[0]) - - def test_a_heading_in_neither_grammar_is_refused(self): - for heading in ("## [Unreleased]", "## [0.82.0] 2026-10-25", "## [0.82.0-beta.1] - 2026-10-25", - f"## [0.82.0]({LINK}/v0.82.1) - 2026-10-25", - "## [0.82.0](https://example.com/v0.82.0) - 2026-10-25", - "## [0.82.0] - 2026-10-25 [CRITICAL]"): - with self.subTest(heading=heading): - problems = check.changelog_problems(HEADER + heading + "\n\n" + OLD) - self.assertEqual(len(problems), 1) - self.assertIn("is not `## [X.Y.Z]", problems[0]) - - def test_a_heading_without_a_version_is_refused(self): - problems = check.changelog_problems(HEADER + OLD + "\n## Notes\n") - self.assertEqual(len(problems), 1) - self.assertIn("`## Notes` is not", problems[0]) - - def test_an_impossible_date_is_refused(self): - problems = check.changelog_problems(HEADER + OLD.replace("2026-10-16", "2026-02-30")) - self.assertEqual(problems, ["apple/CHANGELOG.md:7: 2026-02-30 is not a date"]) - - def test_a_repeated_version_is_refused(self): - problems = check.changelog_problems(HEADER + OLD + "\n## [0.80.0] - 2026-10-01\n") - self.assertEqual(len(problems), 1) - self.assertIn("repeats", problems[0]) - - def test_versions_must_descend(self): - text = HEADER + "## [0.80.0] - 2026-10-11\n\n## [0.81.0] - 2026-10-16\n" - problems = check.changelog_problems(text) - self.assertEqual(len(problems), 1) - self.assertIn("not below", problems[0]) - - def test_versions_compare_as_numbers(self): - text = HEADER + "## [0.10.0] - 2026-10-11\n\n## [0.9.0] - 2026-10-01\n" - self.assertEqual(check.changelog_problems(text), []) - - def test_a_new_lead_block_takes_at_most_six_lines(self): - six = "\n".join(f"Lead {index}." for index in range(6)) - text = HEADER + NEW.replace("One lead line.", six) + OLD - self.assertEqual(check.changelog_problems(text), []) - problems = check.changelog_problems(HEADER + NEW.replace("One lead line.", six + "\nLead 6.") + OLD) - self.assertEqual(len(problems), 1) - self.assertIn("7 lines", problems[0]) - - def test_a_patch_takes_no_lead_block(self): - patch = f"## [0.82.1]({LINK}/v0.82.1) - 2026-10-26\n\n### Fixed\n\n- A bug. ([#2](x))\n\n" - self.assertEqual(check.changelog_problems(HEADER + patch + NEW + OLD), []) - with_lead = patch.replace("### Fixed", "A lead.\n\n### Fixed") - problems = check.changelog_problems(HEADER + with_lead + NEW + OLD) - self.assertEqual(len(problems), 1) - self.assertIn("0.82.1 has a lead block", problems[0]) - - def test_a_lead_line_is_plain_text(self): - for line in ("- A list item.", "#### A heading"): - with self.subTest(line=line): - problems = check.changelog_problems(HEADER + NEW.replace("One lead line.", line) + OLD) - self.assertEqual(len(problems), 1) - self.assertIn("is not plain text", problems[0]) - - def test_an_old_lead_block_is_not_limited(self): - lead = "\n".join(f"Lead {index}." for index in range(9)) - self.assertEqual(check.changelog_problems(HEADER + OLD.replace("### Fixed", lead + "\n\n### Fixed")), []) - - -class ProjectChangelogTests(unittest.TestCase): - def test_a_project_heading_links_its_own_tag(self): - self.assertEqual(check.changelog_problems(HEADER + LINUX_NEW, LINUX), []) - self.assertEqual(check.changelog_problems(HEADER, LINUX), []) - - def test_a_heading_linking_the_other_projects_tag_is_refused(self): - problems = check.changelog_problems(HEADER + LINUX_NEW.replace("linux-v0.1.0", "v0.1.0"), LINUX) - self.assertEqual(len(problems), 1) - self.assertTrue(problems[0].startswith("linux/CHANGELOG.md:7: `## [0.1.0]")) - self.assertEqual(len(check.changelog_problems(HEADER + LINUX_NEW)), 1) - - def test_a_project_keeps_the_lead_block_rules(self): - patch = LINUX_NEW.replace("0.1.0", "0.1.1") - problems = check.changelog_problems(HEADER + patch, LINUX) - self.assertEqual(problems, ["linux/CHANGELOG.md:7: 0.1.1 has a lead block; only an X.Y.0 takes one"]) - - def test_a_pre_release_takes_no_section(self): - problems = check.changelog_problems(HEADER + LINUX_NEW.replace("0.1.0", "0.1.0-rc.1"), LINUX) - self.assertEqual(len(problems), 1) - self.assertIn("is not `## [X.Y.Z]", problems[0]) - - -class RepositoryTests(unittest.TestCase): - def setUp(self): - self.scratch = tempfile.TemporaryDirectory() - self.addCleanup(self.scratch.cleanup) - self.root = Path(self.scratch.name) - (self.root / "apple" / "changelog.d").mkdir(parents=True) - (self.root / "apple" / "CHANGELOG.md").write_text(HEADER + NEW + OLD, encoding="utf-8") - (self.root / "apple" / "changelog.d" / "3446.added.md").write_text("A thing.\n", encoding="utf-8") - (self.root / "linux" / "changelog.d").mkdir(parents=True) - (self.root / "linux" / "CHANGELOG.md").write_text(HEADER + LINUX_NEW, encoding="utf-8") - (self.root / "linux" / "changelog.d" / "3464.added.md").write_text("A thing.\n", encoding="utf-8") - - def run_main(self, *argv): - captured = io.StringIO() - with redirect_stdout(captured): - status = check.main(["--root", str(self.root), *argv]) - return status, captured.getvalue() - - def test_a_good_tree_passes(self): - self.assertEqual(self.run_main(), - (0, "OK: apple/changelog.d/ and apple/CHANGELOG.md, linux/changelog.d/ and linux/CHANGELOG.md\n")) - - def test_every_project_is_checked(self): - (self.root / "linux" / "changelog.d" / "3464.added.md").write_text("- A thing.\n", encoding="utf-8") - (self.root / "linux" / "CHANGELOG.md").write_text(HEADER + "## [Unreleased]\n", encoding="utf-8") - status, out = self.run_main() - self.assertEqual(status, 1) - self.assertEqual(out.count("::error title=Changelog::linux/"), 2) - - def test_a_project_without_its_changelog_fails(self): - (self.root / "linux" / "CHANGELOG.md").unlink() - status, out = self.run_main() - self.assertEqual(status, 1) - self.assertIn("linux/CHANGELOG.md", out) - - def test_problems_from_both_files_are_annotations(self): - (self.root / "apple" / "changelog.d" / "3446.added.md").write_text("- A thing.\n", encoding="utf-8") - (self.root / "apple/CHANGELOG.md").write_text(HEADER + "## [Unreleased]\n\n" + OLD, encoding="utf-8") - status, out = self.run_main() - self.assertEqual(status, 1) - self.assertEqual(out.count("::error title=Changelog::"), 2) - - -def changed(filename, status="added"): - return check.ChangedFile(filename, status) - - -class PullRequestTests(unittest.TestCase): - def problems(self, files, title="fix(editor): keep the caret", base="main", number=3600): - return check.pull_request_problems(number, title, base, files) - - def test_a_fragment_named_for_the_pull_request_passes(self): - files = [changed("apple/changelog.d/3600.fixed.md"), changed("apple/changelog.d/3600.fixed.1.md"), - changed("apple/TablePro/Editor.swift", "modified")] - self.assertEqual(self.problems(files), []) - - def test_editing_changelog_outside_a_release_names_the_fragment_to_add(self): - problems = self.problems([changed("apple/CHANGELOG.md", "modified")]) - self.assertEqual(len(problems), 1) - self.assertIn("apple/changelog.d/3600..md", problems[0]) - - def test_a_release_pull_request_edits_changelog(self): - files = [changed("apple/CHANGELOG.md", "modified"), changed("apple/changelog.d/3446.added.md", "removed")] - self.assertEqual(self.problems(files, "chore(release): v0.82.0", "release/0.82"), []) - self.assertEqual(self.problems(files, "chore(release): v0.82.1 (#3601)"), []) - - def test_a_release_pull_request_changes_nothing_else(self): - files = [changed("apple/CHANGELOG.md", "modified"), changed("apple/TablePro/x.swift", "modified"), - changed("apple/changelog.d/3446.added.md", "removed"), changed("apple/project.yml", "modified")] - problems = self.problems(files, "chore(release): v0.82.0", "release/0.82") - self.assertEqual(problems, ["a release pull request changes only apple/CHANGELOG.md and apple/changelog.d/; " - "this one also changes apple/TablePro/x.swift and 1 more"]) - - def test_titles_that_are_not_a_stable_release_cannot_edit_changelog(self): - for title in ("chore(release): v0.82.0-beta.1", "chore(release): 0.82.0", "chore: v0.82.0", - "chore(release): v0.82.0 and more", "chore(release): v0.82"): - with self.subTest(title=title): - self.assertEqual(len(self.problems([changed("apple/CHANGELOG.md", "modified")], title)), 1) - - def test_renaming_changelog_away_counts_as_editing_it(self): - self.assertEqual(len(self.problems([changed("apple/CHANGELOG.md", "removed"), changed("apple/CHANGES.md")])), 1) - - def test_a_fragment_named_for_another_pull_request_prints_the_expected_name(self): - problems = self.problems([changed("apple/changelog.d/3599.fixed.2.md")]) - self.assertEqual(len(problems), 1) - self.assertIn("Name it apple/changelog.d/3600.fixed.2.md", problems[0]) - - def test_an_orphan_fragment_on_main_is_refused(self): - problems = self.problems([changed("apple/changelog.d/+theme.fixed.md")]) - self.assertIn("Name it apple/changelog.d/3600.fixed.md", problems[0]) - - def test_a_file_that_is_not_a_fragment_gets_the_type_placeholder(self): - for name in ("notes.txt", "README.md", "notes"): - with self.subTest(name=name): - problems = self.problems([changed(f"apple/changelog.d/{name}")]) - self.assertEqual(len(problems), 1) - self.assertIn("Name it apple/changelog.d/3600..md", problems[0]) - - def test_gitkeep_is_not_a_fragment(self): - self.assertEqual(self.problems([changed("apple/changelog.d/.gitkeep")]), []) - - def test_a_pick_into_a_release_line_keeps_the_original_name(self): - self.assertEqual(self.problems([changed("apple/changelog.d/3446.fixed.md")], "fix: keep it (#3446)", - "release/0.82"), []) - - def test_editing_another_pull_requests_fragment_passes(self): - self.assertEqual(self.problems([changed("apple/changelog.d/3446.fixed.md", "modified")]), []) - - def test_retyping_another_pull_requests_fragment_keeps_its_number(self): - files = [changed("apple/changelog.d/3446.fixed.md", "removed"), changed("apple/changelog.d/3446.changed.md")] - self.assertEqual(self.problems(files), []) - - -class ProjectPullRequestTests(unittest.TestCase): - VERSION_FILES = ["linux/meson.build", "linux/data/app.tablepro.TablePro.metainfo.xml.in.in", - "linux/build-aux/rpm/tablepro.spec", "linux/data/tablepro.1"] - - def problems(self, files, title="fix(linux): keep the caret", base="main", number=3600): - return check.pull_request_problems(number, title, base, files) - - def test_a_fragment_named_for_the_pull_request_passes(self): - files = [changed("linux/changelog.d/3600.fixed.md"), changed("linux/crates/app/src/x.rs", "modified")] - self.assertEqual(self.problems(files), []) - - def test_a_fragment_named_for_another_pull_request_prints_the_expected_name(self): - problems = self.problems([changed("linux/changelog.d/3599.fixed.1.md")]) - self.assertEqual(problems, ["linux/changelog.d/3599.fixed.1.md is not named for this pull request. " - "Name it linux/changelog.d/3600.fixed.1.md"]) - - def test_a_pick_into_a_maintenance_branch_keeps_the_original_name(self): - self.assertEqual(self.problems([changed("linux/changelog.d/3446.fixed.md")], base="linux-0.1"), []) - - def test_a_fragment_of_each_project_is_named_for_the_pull_request(self): - problems = self.problems([changed("apple/changelog.d/3599.fixed.md"), changed("linux/changelog.d/3599.added.md")]) - self.assertEqual(len(problems), 2) - self.assertIn("Name it apple/changelog.d/3600.fixed.md", problems[0]) - self.assertIn("Name it linux/changelog.d/3600.added.md", problems[1]) - - def test_editing_the_project_changelog_outside_a_release_names_its_title_and_fragment(self): - problems = self.problems([changed("linux/CHANGELOG.md", "modified")]) - self.assertEqual(problems, ["only a `chore(release): linux-vX.Y.Z` pull request edits linux/CHANGELOG.md. " - "Put the entry in linux/changelog.d/3600..md (.claude/rules/changelog.md)"]) - - def test_a_release_pull_request_sets_the_version_and_edits_the_changelog(self): - files = [changed("linux/CHANGELOG.md", "modified"), changed("linux/changelog.d/3464.added.md", "removed"), - *(changed(name, "modified") for name in self.VERSION_FILES)] - self.assertEqual(self.problems(files, "chore(release): linux-v0.1.0"), []) - self.assertEqual(self.problems(files, "chore(release): linux-v0.1.1 (#3601)", "linux-0.1"), []) - - def test_a_release_pull_request_changes_nothing_else(self): - files = [changed("linux/CHANGELOG.md", "modified"), changed("linux/crates/app/src/x.rs", "modified")] - problems = self.problems(files, "chore(release): linux-v0.1.0") - self.assertEqual(problems, ["a release pull request changes only linux/CHANGELOG.md, linux/changelog.d/, " - + ", ".join(self.VERSION_FILES[:-1]) + " and linux/data/tablepro.1; " - "this one also changes linux/crates/app/src/x.rs"]) - - def test_a_pre_release_pull_request_cannot_edit_the_changelog(self): - files = [changed("linux/CHANGELOG.md", "modified"), *(changed(name, "modified") for name in self.VERSION_FILES)] - self.assertEqual(len(self.problems(files, "chore(release): linux-v0.2.0-rc.1")), 1) - self.assertEqual(self.problems(files[1:], "chore(release): linux-v0.2.0-rc.1"), []) - - def test_the_pull_request_adding_a_project_brings_its_changelog_and_fragments(self): - files = [changed("linux/towncrier.toml"), changed("linux/CHANGELOG.md", "modified"), - changed("linux/changelog.d/3464.added.md"), changed("linux/changelog.d/3413.added.md")] - self.assertEqual(self.problems(files), []) - self.assertEqual(len(self.problems(files[1:])), 3) - self.assertEqual(len(self.problems([changed("linux/towncrier.toml", "modified"), *files[1:]])), 3) - - def test_adding_one_project_leaves_the_other_checked(self): - files = [changed("linux/towncrier.toml"), changed("apple/CHANGELOG.md", "modified")] - self.assertEqual(len(self.problems(files)), 1) - self.assertIn("edits apple/CHANGELOG.md", self.problems(files)[0]) - - def test_a_release_of_one_project_cannot_edit_the_other(self): - root = self.problems([changed("apple/CHANGELOG.md", "modified"), changed("linux/CHANGELOG.md", "modified")], - "chore(release): v0.82.0", "release/0.82") - self.assertEqual(len(root), 2) - self.assertIn("also changes linux/CHANGELOG.md", root[0]) - self.assertIn("edits linux/CHANGELOG.md", root[1]) - linux = self.problems([changed("apple/CHANGELOG.md", "modified"), changed("linux/CHANGELOG.md", "modified")], - "chore(release): linux-v0.1.0") - self.assertEqual(len(linux), 2) - self.assertIn("edits apple/CHANGELOG.md", linux[0]) - self.assertIn("also changes apple/CHANGELOG.md", linux[1]) - - -class MergeCommitTests(unittest.TestCase): - def setUp(self): - environment = mock.patch.dict(os.environ, GIT_ENV) - environment.start() - self.addCleanup(environment.stop) - scratch = tempfile.TemporaryDirectory() - self.addCleanup(scratch.cleanup) - self.root = Path(scratch.name) - self.git("init", "--quiet", "--initial-branch=main") - self.write("apple/CHANGELOG.md", HEADER + OLD) - self.write("apple/changelog.d/3446.fixed.md", "A bug.\n") - self.write("apple/TablePro/x.swift", "let x = 1\n") - self.commit("base") - - def git(self, *args): - subprocess.run(["git", "-C", str(self.root), *args], check=True, capture_output=True) - - def write(self, path, text): - (self.root / path).parent.mkdir(parents=True, exist_ok=True) - (self.root / path).write_text(text, encoding="utf-8") - - def commit(self, message): - self.git("add", "--all") - self.git("commit", "--quiet", "--allow-empty", "-m", message) - - def run_main(self, title="fix: x", base="main"): - captured = io.StringIO() - with redirect_stdout(captured): - status = check.main(["--pr", "3600", "--title", title, "--base", base, "--root", str(self.root)]) - return status, captured.getvalue() - - def test_reads_the_changes_against_the_first_parent(self): - self.write("apple/changelog.d/3600.fixed.md", "Another bug.\n") - (self.root / "apple/changelog.d/3446.fixed.md").rename(self.root / "apple/changelog.d/3446.changed.md") - (self.root / "apple/CHANGELOG.md").unlink() - self.write("apple/TablePro/x.swift", "let x = 2\n") - self.commit("the pull request") - self.assertEqual(sorted(check.changed_files(self.root)), [ - changed("apple/CHANGELOG.md", "removed"), - changed("apple/TablePro/x.swift", "modified"), - changed("apple/changelog.d/3446.changed.md"), - changed("apple/changelog.d/3446.fixed.md", "removed"), - changed("apple/changelog.d/3600.fixed.md"), - ]) - status, out = self.run_main() - self.assertEqual(status, 1) - self.assertEqual(out.count("::error title=Changelog::"), 1) - self.assertIn("edits apple/CHANGELOG.md", out) - - def test_a_clean_pull_request_passes(self): - self.write("apple/changelog.d/3600.fixed.md", "Another bug.\n") - self.commit("the pull request") - self.assertEqual(self.run_main(), (0, "OK: the changelog files of #3600\n")) - - def test_a_missing_parent_fails_the_check(self): - status, out = self.run_main() - self.assertEqual(status, 1) - self.assertIn("git diff HEAD^1 HEAD", out) - - def test_pr_needs_a_title_and_a_base(self): - with redirect_stdout(io.StringIO()), mock.patch("sys.stderr", io.StringIO()), \ - self.assertRaises(SystemExit): - check.main(["--pr", "3600", "--root", str(self.root)]) - - def test_an_annotation_cannot_inject_a_workflow_command(self): - self.write("apple/changelog.d/x\n::add-mask::secret", "A bug.\n") - self.commit("the pull request") - status, out = self.run_main() - self.assertEqual(status, 1) - self.assertEqual(len(out.splitlines()), 1) - self.assertIn("%0A::add-mask::secret", out) - - -if __name__ == "__main__": - unittest.main()