From 23db0cc5ac569bcf4d11ccf308032721e4889b44 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 00:25:01 +0700 Subject: [PATCH] fix(release): walk the CI check back to the last change it covers --- RELEASING.md | 2 +- scripts/release/test_version.py | 28 +++++++++++++++++++++- scripts/release/version.py | 41 +++++++++++++++++++++------------ 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 542b2c9479..660fc4310e 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, 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 `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 9c7cad66f2..aa2521bb79 100644 --- a/scripts/release/test_version.py +++ b/scripts/release/test_version.py @@ -134,6 +134,7 @@ def setUp(self): self.logged = [] self.diffs = [] self.changed_suites = set() + self.changed_bases = set() self.base = self.repo.commit("0.81.0", MAIN_CHANGELOG) self.repo.git("tag", "v0.81.0") self.cut = self.repo.commit("feat: something") @@ -141,7 +142,7 @@ def setUp(self): def changed(self, base, suite): self.diffs.append((base, suite)) - return suite in self.changed_suites + return suite in self.changed_suites or (base, suite) in self.changed_bases def check(self, tag, github): if self.git.exists(f"refs/tags/{tag}"): @@ -229,6 +230,31 @@ def test_a_success_on_another_branch_run_of_the_same_commit_counts(self): github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) self.assertEqual(self.check("v0.82.0-beta.1", github), []) + def test_skipped_commits_past_fifty_still_borrow_the_last_green_run(self): + tail = self.cut + for index in range(60): + tail = self.repo.commit(f"feat(linux): change {index}") + self.repo.line("0.82", tail) + self.repo.git("tag", "v0.82.0-beta.1", tail) + runs, jobs = green_on(self.cut) + github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) + self.assertEqual(self.check("v0.82.0-beta.1", github), []) + self.assertIn(f"macOS Tests: green on {self.cut}", self.logged) + + def test_the_walk_stops_at_the_newest_change_to_the_files_a_job_reads(self): + between = self.repo.commit("feat: before the change") + touch = self.repo.commit("feat: changes the app") + tail = self.repo.commit("docs: after the change") + self.repo.line("0.82", tail) + self.repo.git("tag", "v0.82.0-beta.1", tail) + self.changed_bases = {(between, "macos")} + runs, jobs = green_on(self.cut) + github = FakeGitHub(releases=[("v0.81.0", False)], runs=runs, jobs=jobs) + self.assertEqual(self.check("v0.82.0-beta.1", github), + [f"no macOS Tests run that was not skipped on {touch}, which changed files it reads, " + f"or after it"]) + self.assertNotIn(("macos-tests.yml", self.cut), github.run_queries) + def test_no_run_in_the_walk_refuses_the_tag(self): self.repo.git("tag", "v0.82.0-beta.1", self.cut) runs, jobs = green_on(self.cut) diff --git a/scripts/release/version.py b/scripts/release/version.py index 3a8b2687b8..dbffba5563 100755 --- a/scripts/release/version.py +++ b/scripts/release/version.py @@ -26,7 +26,6 @@ ) DETECTOR = "scripts/ci/detect-changed-paths.sh" FRAGMENTS = "changelog.d/" -WALK_DEPTH = 50 POLL_SECONDS = 60 PENDING_STATUSES = {"queued", "in_progress", "waiting", "requested", "pending"} @@ -131,8 +130,8 @@ 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 first_parents(self, ref: str, limit: int) -> List[str]: - return self._git("rev-list", "--first-parent", f"--max-count={limit}", f"{ref}^{{commit}}").stdout.split() + def first_parents(self, ref: str) -> List[str]: + return self._git("rev-list", "--first-parent", f"{ref}^{{commit}}").stdout.split() class GitHub: @@ -278,25 +277,37 @@ def ci_errors(git: Git, github: GitHub, version: Version, changed: Callable[[str tag_commit = git.commit(version.tag) if git.commit("HEAD") != tag_commit: return [f"check out {version.tag} first: the coverage check diffs against HEAD"] - shas = git.first_parents(version.tag, WALK_DEPTH) + shas = git.first_parents(version.tag) errors = [] for workflow, job, suite in REQUIRED_RUNS: try: - green = next((sha for sha in shas if is_green(github, workflow, job, sha, sleep, log)), None) + error = coverage_error(github, workflow, job, suite, shas, version.tag, changed, sleep, log) except CheckFailure as failure: - errors.append(str(failure)) - continue - if green is None: - errors.append(f"no {job} run that was not skipped among the {len(shas)} first-parent commits " - f"of {version.tag}") - # A commit with no run of its own would otherwise borrow a verdict that never covered it. - elif green != tag_commit and changed(green, suite): - errors.append(f"{job} passed on {green}, but files it reads changed before {version.tag}") - else: - log(f"{job}: green on {green}") + error = str(failure) + if error is not None: + errors.append(error) return errors +def coverage_error(github: GitHub, workflow: str, job: str, suite: str, shas: List[str], tag: str, + changed: Callable[[str, str], bool], sleep: Callable[[float], None], + log: Callable[[str], None]) -> Optional[str]: + # The walk ends at the newest commit that changed the files the job reads: a run on any + # commit before it never covered that change, however many skipped commits follow it. + newer = None + for sha in shas: + is_tag = newer is None + if is_green(github, workflow, job, sha, sleep, log): + if not is_tag and changed(sha, suite): + return f"{job} passed on {sha}, but files it reads changed before {tag}" + log(f"{job}: green on {sha}") + return None + if not is_tag and changed(sha, suite): + return f"no {job} run that was not skipped on {newer}, which changed files it reads, or after it" + newer = sha + return f"no {job} run that was not skipped among the {len(shas)} first-parent commits of {tag}" + + def check(tag: str, git: Git, github: GitHub, changed: Optional[Callable[[str, str], bool]] = None, sleep: Callable[[float], None] = time.sleep, log: Callable[[str], None] = print) -> List[str]: try: