From 385b65a6addbf72c17c99c4a1313d0b37af03ff3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 03:30:30 +0700 Subject: [PATCH] ci(release): refuse a stable that differs from its newest beta --- RELEASING.md | 2 +- scripts/release/test_version.py | 54 ++++++++++++++++++++++++++++++++- scripts/release/version.py | 29 +++++++++++++++--- 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 5c75b09107..ae4a3ed5c9 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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/` 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/` 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. diff --git a/scripts/release/test_version.py b/scripts/release/test_version.py index aa2521bb79..a6d955a095 100644 --- a/scripts/release/test_version.py +++ b/scripts/release/test_version.py @@ -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") @@ -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) @@ -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]") diff --git a/scripts/release/version.py b/scripts/release/version.py index dbffba5563..cc2ae88e6b 100755 --- a/scripts/release/version.py +++ b/scripts/release/version.py @@ -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() @@ -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}.*")) @@ -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}"] @@ -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: