Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions .claude/rules/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<PR>.<type>.md`: `<PR>` is the pull request that adds it, `<type>` is `added`, `changed`, `deprecated`, `removed`, `fixed` or `security`. A second entry of the same type in one PR adds a counter, as `towncrier create` does: `3452.fixed.1.md`, then `.2`. The counter does not order entries.
- **One line, at most 120 characters, ending with a period.** Added and Changed name the new thing (`Open in Window for row inspector text fields.`); Fixed names the bug, not the fix (`Empty context menu when right-clicking a read-only inspector field.`). No file paths or type names.
- **No refs, no credit, no `- ` prefix.** towncrier links the PR named by the file and adds the bullet; `notes.py` adds ` by @login` when the PR's author is not a maintainer.
- **One entry per thing a user would notice.** Defects with the same visible symptom share an entry.
- **No fragment** for a fix to something still unreleased (edit that fragment instead) or for a docs-only change.
- **Only a `chore(release): vX.Y.Z` PR edits `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/<PR>.<type>.md` or `linux/changelog.d/<PR>.<type>.md`, where `<PR>` is the pull request that adds it. A second entry of one type adds a counter: `3452.fixed.1.md`.
- `<type>` 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`.
6 changes: 3 additions & 3 deletions .claude/rules/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand All @@ -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`;
Expand All @@ -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

Expand Down
18 changes: 11 additions & 7 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<PR>.<type>.md fragment instead."
exit 1
fi
3 changes: 1 addition & 2 deletions .github/workflows/repo-hygiene.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions apple/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<X.Y>` 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/<X.Y>` 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/<X.Y>` 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/<X.Y>` 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.
Expand Down
50 changes: 50 additions & 0 deletions apple/scripts/release/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
25 changes: 24 additions & 1 deletion apple/scripts/release/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]*))?")
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion linux/docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading