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
2 changes: 1 addition & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ python3 scripts/release/notes.py docs X.Y.Z --ref vX.Y.Z --mdx ../tablepro-docs/

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; a stable X.Y.0 with no published beta, or a beta after its X.Y.0; 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 needs macOS Tests, iOS and Package Tests and Release Build green on the tag commit or the nearest first-parent commit that ran them, with no change to the files they read after it, waiting on a pending run. 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; a stable X.Y.0 with no published beta, or a beta after its X.Y.0; a stable X.Y.0 whose tree differs from its newest published beta outside `CHANGELOG.md` and `changelog.d/`; 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 needs macOS Tests, iOS and Package Tests and Release Build green on the tag commit or the nearest first-parent commit that ran them, with no change to the files they read after it, waiting on a pending run. 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
54 changes: 53 additions & 1 deletion scripts/release/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@ 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, fragment: str = None) -> str:
def commit(self, message: str, changelog: str = None, fragment: str = None, source: str = None) -> str:
if source is not None:
path = self.path / source
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(message + "\n", encoding="utf-8")
self.git("add", str(path))
if changelog is not None:
(self.path / "CHANGELOG.md").write_text(changelog, encoding="utf-8")
self.git("add", "CHANGELOG.md")
Expand Down Expand Up @@ -288,6 +293,7 @@ def test_the_previous_tag_on_the_line_must_be_an_ancestor(self):
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):
self.repo.git("tag", "v0.82.0-beta.1", self.cut)
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)
Expand All @@ -300,6 +306,52 @@ def test_a_stable_needs_a_published_beta(self):
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_stable_refuses_a_change_after_its_newest_published_beta(self):
self.repo.git("tag", "v0.82.0-beta.1", self.cut)
beta = self.repo.commit("fix: first pick", source="TablePro/First.swift")
self.repo.git("tag", "v0.82.0-beta.2", beta)
unpublished = self.repo.commit("fix: second pick", source="TablePro/Second.swift")
self.repo.git("tag", "v0.82.0-beta.3", unpublished)
self.repo.commit("fix: third pick", source="Plugins/Third.swift")
release_commit = self.repo.commit("chore(release): v0.82.0", MAIN_CHANGELOG.replace(
"## [0.81.0]", "## [0.82.0] - 2026-10-26\n\n## [0.81.0]"))
self.repo.line("0.82", release_commit)
self.repo.git("tag", "v0.82.0", release_commit)
releases = [("v0.81.0", False), ("v0.82.0-beta.1", False), ("v0.82.0-beta.2", False),
("v0.82.0-beta.3", True)]
github = FakeGitHub(releases=releases)
self.assertEqual(self.check("v0.82.0", github),
["v0.82.0 differs from v0.82.0-beta.2, the newest published beta, in "
"Plugins/Third.swift, TablePro/Second.swift: ship that in a beta first"])
self.assertEqual(github.run_queries, [])

def test_a_stable_may_differ_from_its_newest_beta_in_the_changelog_only(self):
beta = self.repo.commit("fix: a fix", fragment="3610.fixed.md")
self.repo.git("tag", "v0.82.0-beta.1", beta)
self.repo.commit("docs(changelog): reword a fragment", fragment="3611.fixed.md")
self.repo.git("rm", "--quiet", "-r", "changelog.d")
release_commit = self.repo.commit("chore(release): v0.82.0", MAIN_CHANGELOG.replace(
"## [0.81.0]", "## [0.82.0] - 2026-10-26\n\n## [0.81.0]"))
self.repo.line("0.82", release_commit)
self.repo.git("tag", "v0.82.0", release_commit)
runs, jobs = green_on(release_commit)
github = FakeGitHub(releases=[("v0.81.0", False), ("v0.82.0-beta.1", False)], runs=runs, jobs=jobs)
self.assertEqual(self.check("v0.82.0", github), [])

def test_a_patch_is_not_held_to_the_beta_tree(self):
self.repo.git("tag", "v0.82.0-beta.1", self.cut)
stable = self.repo.commit("chore(release): v0.82.0", MAIN_CHANGELOG.replace(
"## [0.81.0]", "## [0.82.0] - 2026-10-26\n\n## [0.81.0]"))
self.repo.git("tag", "v0.82.0", stable)
self.repo.commit("fix: a crash", source="TablePro/Crash.swift")
release_commit = self.repo.commit("chore(release): v0.82.1", MAIN_CHANGELOG.replace(
"## [0.81.0]", "## [0.82.1] - 2026-10-27\n\n## [0.82.0] - 2026-10-26\n\n## [0.81.0]"))
self.repo.line("0.82", release_commit)
self.repo.git("tag", "v0.82.1", release_commit)
runs, jobs = green_on(release_commit)
releases = [("v0.81.0", False), ("v0.82.0-beta.1", False), ("v0.82.0", False)]
self.assertEqual(self.check("v0.82.1", FakeGitHub(releases=releases, runs=runs, jobs=jobs)), [])

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]")
Expand Down
29 changes: 24 additions & 5 deletions scripts/release/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ def show(self, ref: str, path: str) -> Optional[str]:
def list_tree(self, ref: str, path: str) -> List[str]:
return self._git("ls-tree", "--name-only", ref, "--", path).stdout.splitlines()

def changed_paths(self, base: str, head: str) -> List[str]:
output = self._git("diff-tree", "-r", "--name-only", "-z", base, head).stdout
return [path for path in output.split("\0") if path]

def first_parents(self, ref: str) -> List[str]:
return self._git("rev-list", "--first-parent", f"{ref}^{{commit}}").stdout.split()

Expand Down Expand Up @@ -182,6 +186,11 @@ def published_stables(releases: List[Tuple[str, bool]]) -> List[Version]:
return [version for version in versions if version is not None and not version.is_beta]


def published_betas(releases: List[Tuple[str, bool]], line: str) -> 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 version.is_beta and version.line == line]


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}.*"))
Expand Down Expand Up @@ -210,15 +219,24 @@ def beta_errors(git: Git, version: Version, releases: List[Tuple[str, bool]]) ->
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:
if version.patch != 0 or published_betas(releases, version.line):
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 stable_tree_errors(git: Git, version: Version, releases: List[Tuple[str, bool]]) -> List[str]:
betas = published_betas(releases, version.line)
if version.is_beta or version.patch != 0 or not betas:
return []
newest = max(betas, key=lambda beta: beta.build)
untested = [path for path in git.changed_paths(newest.tag, version.tag)
if path != "CHANGELOG.md" and not path.startswith(FRAGMENTS)]
if not untested:
return []
return [f"{version.tag} differs from {newest.tag}, the newest published beta, in {', '.join(untested)}: "
"ship that in a beta 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}"]
Expand Down Expand Up @@ -319,6 +337,7 @@ def check(tag: str, git: Git, github: GitHub, changed: Optional[Callable[[str, s
releases = github.releases()
errors = branch_errors(git, version)
errors += beta_errors(git, version, releases)
errors += stable_tree_errors(git, version, releases)
errors += changelog_errors(git.show(tag, "CHANGELOG.md"), version, published_stables(releases))
errors += fragment_errors(git.list_tree(tag, FRAGMENTS), version)
if errors:
Expand Down
Loading