diff --git a/RELEASING.md b/RELEASING.md index 0c20d8c3c2..316cbf5391 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -96,7 +96,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 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. +- `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 whose tree differs from its line's newest published beta outside `CHANGELOG.md` and `changelog.d/`, until a stable tag on the line has matched that beta; 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 a6d955a095..ef0e8e8248 100644 --- a/scripts/release/test_version.py +++ b/scripts/release/test_version.py @@ -322,9 +322,45 @@ def test_a_stable_refuses_a_change_after_its_newest_published_beta(self): 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"]) + "Plugins/Third.swift, TablePro/Second.swift: revert them on release/0.82, " + "then tag the next number"]) self.assertEqual(github.run_queries, []) + def test_the_next_number_after_a_refused_stable_is_held_to_the_beta(self): + self.repo.git("tag", "v0.82.0-beta.1", self.cut) + pick = self.repo.commit("fix: late pick", source="TablePro/Late.swift") + refused = 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", refused) + patch = self.repo.commit("chore(release): v0.82.1", MAIN_CHANGELOG.replace( + "## [0.81.0]", "## [0.82.1] - 2026-10-27\n\n## [0.81.0]")) + self.repo.git("tag", "v0.82.1", patch) + self.repo.git("revert", "--no-edit", pick) + reverted = self.repo.commit("chore(release): v0.82.2", MAIN_CHANGELOG.replace( + "## [0.81.0]", "## [0.82.2] - 2026-10-28\n\n## [0.81.0]")) + self.repo.git("tag", "v0.82.2", reverted) + self.repo.line("0.82", reverted) + releases = [("v0.81.0", False), ("v0.82.0-beta.1", False)] + runs, jobs = green_on(reverted) + self.assertEqual(self.check("v0.82.1", FakeGitHub(releases=releases)), + ["v0.82.1 differs from v0.82.0-beta.1, the newest published beta, in " + "TablePro/Late.swift: revert them on release/0.82, then tag the next number"]) + self.assertEqual(self.check("v0.82.2", FakeGitHub(releases=releases, runs=runs, jobs=jobs)), []) + + def test_a_fix_after_a_stable_tag_that_matched_the_beta_is_a_patch(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("ci: select a GA Xcode", source=".github/actions/setup-xcode/action.yml") + 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.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", True)] + self.assertEqual(self.check("v0.82.1", FakeGitHub(releases=releases, runs=runs, jobs=jobs)), []) + 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) diff --git a/scripts/release/version.py b/scripts/release/version.py index cc2ae88e6b..f36e467966 100755 --- a/scripts/release/version.py +++ b/scripts/release/version.py @@ -224,17 +224,30 @@ def beta_errors(git: Git, version: Version, releases: List[Tuple[str, bool]]) -> return [f"{version.tag} needs a published {version.tag}-beta.N release first"] +def untested_paths(git: Git, beta: Version, tag: str) -> List[str]: + return [path for path in git.changed_paths(beta.tag, tag) + if path != "CHANGELOG.md" and not path.startswith(FRAGMENTS)] + + +def earlier_stables(git: Git, version: Version) -> List[Version]: + tags = (try_parse_tag(tag) for tag in git.tags(f"v{version.line}.*")) + return [tag for tag in tags + if tag is not None and not tag.is_beta and tag.line == version.line and tag.build < version.build] + + 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: + if version.is_beta 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)] + # No beta can follow a stable tag, so a fix after one that matched the beta ships as a patch. + if any(not untested_paths(git, newest, stable.tag) for stable in earlier_stables(git, version)): + return [] + untested = untested_paths(git, newest, version.tag) 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"] + f"revert them on release/{version.line}, then tag the next number"] def changelog_errors(changelog: Optional[str], version: Version, stables: List[Version]) -> List[str]: