diff --git a/.github/checks-manifest.yaml b/.github/checks-manifest.yaml index 166c8a4f45e..0217937a23f 100644 --- a/.github/checks-manifest.yaml +++ b/.github/checks-manifest.yaml @@ -49,6 +49,11 @@ checks: triggers: [".github/workflows/desktop_qualify_beta.yml", ".github/scripts/check-release-process-guards.py", "scripts/run-release-process-guards.sh", "backend/tests/unit/test_desktop_release_scripts.py", ".github/checks-manifest.yaml"] lanes: ["local", "ci"] reason: "SCA-174: qualification must check out the exact release tag inside its isolated source directory; the guard and its focused behavioral test run on every relevant diff" + - id: windows-release-sync-pr-lifecycle + command: ["python3", ".github/scripts/test_retire_superseded_windows_sync_prs.py"] + triggers: [".github/workflows/desktop_windows_release.yml", ".github/scripts/retire_superseded_windows_sync_prs.py", ".github/scripts/test_retire_superseded_windows_sync_prs.py", ".github/checks-manifest.yaml"] + lanes: ["local", "ci"] + reason: "#10727: a replacement Windows version-sync PR must retire only older same-repository sync PRs; nine superseded PRs accumulated when one-shot auto-merge could not pass branch protection" - id: failure-class-retirement-author command: ["python3", ".github/scripts/test_failure_class_retirement.py"] triggers: [".github/scripts/failure_class_retirement.py", ".github/scripts/test_failure_class_retirement.py", ".github/workflows/repo-hygiene.yml", "scripts/failure-class", ".github/checks-manifest.yaml"] diff --git a/.github/failure-classes/FC-superseded-windows-sync-pr-lifecycle.json b/.github/failure-classes/FC-superseded-windows-sync-pr-lifecycle.json new file mode 100644 index 00000000000..e3a08a50a37 --- /dev/null +++ b/.github/failure-classes/FC-superseded-windows-sync-pr-lifecycle.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, + "id": "FC-superseded-windows-sync-pr-lifecycle", + "violated_contract": "Only the newest unmerged Windows release version-sync PR remains actionable; once a replacement PR exists, older same-repository sync PRs must stop competing for review.", + "canonical_prevention": "The Windows release workflow confirms its current same-repository main-targeting sync PR, then retires only strictly older exact-semver release/windows-v* PRs without deleting release state or blocking publication.", + "canonical_prevention_artifact": [ + ".github/scripts/retire_superseded_windows_sync_prs.py", + ".github/scripts/test_retire_superseded_windows_sync_prs.py" + ], + "evidence_prs": [ + 10729 + ], + "scope_hints": [ + ".github/workflows/desktop_windows_release.yml", + ".github/scripts/retire_superseded_windows_sync_prs.py" + ], + "status": "open" +} diff --git a/.github/scripts/retire_superseded_windows_sync_prs.py b/.github/scripts/retire_superseded_windows_sync_prs.py new file mode 100644 index 00000000000..869ab0dc0be --- /dev/null +++ b/.github/scripts/retire_superseded_windows_sync_prs.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Close Windows version-sync PRs superseded by a newer release.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import TextIO + +WINDOWS_SYNC_BRANCH_RE = re.compile(r"^release/windows-v(\d+)\.(\d+)\.(\d+)$") +Version = tuple[int, int, int] +Runner = Callable[..., subprocess.CompletedProcess[str]] + + +@dataclass(frozen=True) +class SyncPullRequest: + number: int + branch: str + version: Version + + +def parse_version(value: str) -> Version: + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", value) + if match is None: + raise argparse.ArgumentTypeError("version must use MAJOR.MINOR.PATCH") + return tuple(int(part) for part in match.groups()) + + +def format_version(version: Version) -> str: + return ".".join(str(part) for part in version) + + +def decode_sync_pr(raw: object) -> SyncPullRequest | None: + if not isinstance(raw, dict): + return None + number = raw.get("number") + branch = raw.get("headRefName") + if ( + not isinstance(number, int) + or isinstance(number, bool) + or not isinstance(branch, str) + or raw.get("baseRefName") != "main" + or raw.get("isCrossRepository") is not False + ): + return None + match = WINDOWS_SYNC_BRANCH_RE.fullmatch(branch) + if match is None: + return None + return SyncPullRequest( + number=number, + branch=branch, + version=tuple(int(part) for part in match.groups()), + ) + + +def select_superseded_prs( + raw_prs: object, + *, + current_pr: int, + current_version: Version, +) -> list[SyncPullRequest] | None: + if not isinstance(raw_prs, list): + return None + + sync_prs = [decoded for raw in raw_prs if (decoded := decode_sync_pr(raw)) is not None] + current = next((pr for pr in sync_prs if pr.number == current_pr), None) + if current is None or current.version != current_version: + return None + + return sorted( + (pr for pr in sync_prs if pr.number != current_pr and pr.version < current_version), + key=lambda pr: (pr.version, pr.number), + ) + + +def retire_superseded_prs( + *, + repository: str, + current_pr: int, + current_version: Version, + runner: Runner = subprocess.run, + stdout: TextIO = sys.stdout, + stderr: TextIO = sys.stderr, +) -> int: + listing = runner( + [ + "gh", + "pr", + "list", + "--repo", + repository, + "--base", + "main", + "--state", + "open", + "--limit", + "200", + "--json", + "number,headRefName,baseRefName,isCrossRepository", + ], + check=False, + capture_output=True, + text=True, + ) + if listing.returncode != 0: + print("Warning: could not list Windows sync PRs; cleanup skipped.", file=stderr) + return 0 + + try: + raw_prs = json.loads(listing.stdout) + except json.JSONDecodeError: + print("Warning: gh returned invalid Windows sync PR JSON; cleanup skipped.", file=stderr) + return 0 + + superseded = select_superseded_prs( + raw_prs, + current_pr=current_pr, + current_version=current_version, + ) + if superseded is None: + print( + f"Warning: current Windows sync PR #{current_pr} was not confirmed; cleanup skipped.", + file=stderr, + ) + return 0 + + current_version_text = format_version(current_version) + closed = 0 + for pull_request in superseded: + comment = ( + f"Superseded by Windows release sync PR #{current_pr} " + f"(v{current_version_text}). The release tag remains authoritative; " + "no tag, release, or branch was deleted." + ) + result = runner( + [ + "gh", + "pr", + "close", + str(pull_request.number), + "--repo", + repository, + "--comment", + comment, + ], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + print( + f"Warning: could not close superseded Windows sync PR #{pull_request.number}.", + file=stderr, + ) + continue + closed += 1 + message = ( + f"Closed superseded Windows sync PR #{pull_request.number} " f"(v{format_version(pull_request.version)})." + ) + print( + message, + file=stdout, + ) + + print(f"Retired {closed} superseded Windows sync PR(s).", file=stdout) + return closed + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Close older same-repository Windows version-sync PRs.") + parser.add_argument( + "--repository", + default=os.environ.get("GITHUB_REPOSITORY"), + help="GitHub repository in owner/name form (defaults to GITHUB_REPOSITORY).", + ) + parser.add_argument("--current-pr", type=int, required=True) + parser.add_argument("--current-version", type=parse_version, required=True) + args = parser.parse_args(argv) + if not args.repository: + parser.error("--repository or GITHUB_REPOSITORY is required") + if args.current_pr <= 0: + parser.error("--current-pr must be positive") + + retire_superseded_prs( + repository=args.repository, + current_pr=args.current_pr, + current_version=args.current_version, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/test_retire_superseded_windows_sync_prs.py b/.github/scripts/test_retire_superseded_windows_sync_prs.py new file mode 100644 index 00000000000..7ee45b79390 --- /dev/null +++ b/.github/scripts/test_retire_superseded_windows_sync_prs.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Tests for retiring superseded Windows release sync PRs.""" + +from __future__ import annotations + +import importlib.util +import io +import json +import subprocess +import sys +import unittest +from pathlib import Path + +_SPEC = importlib.util.spec_from_file_location( + "retire_superseded_windows_sync_prs", + Path(__file__).with_name("retire_superseded_windows_sync_prs.py"), +) +sync_cleanup = importlib.util.module_from_spec(_SPEC) +sys.modules[_SPEC.name] = sync_cleanup +_SPEC.loader.exec_module(sync_cleanup) + + +def pull_request( + number: int, + branch: str, + *, + base: str = "main", + cross_repository: bool = False, +) -> dict[str, object]: + return { + "number": number, + "headRefName": branch, + "baseRefName": base, + "isCrossRepository": cross_repository, + } + + +class FakeRunner: + def __init__( + self, + payload: object, + *, + list_returncode: int = 0, + close_failures: set[int] | None = None, + ) -> None: + self.payload = payload + self.list_returncode = list_returncode + self.close_failures = close_failures or set() + self.calls: list[list[str]] = [] + + def __call__(self, args: list[str], **_: object) -> subprocess.CompletedProcess[str]: + self.calls.append(args) + if args[:3] == ["gh", "pr", "list"]: + return subprocess.CompletedProcess( + args, + self.list_returncode, + stdout=json.dumps(self.payload), + stderr="list failed" if self.list_returncode else "", + ) + number = int(args[3]) + return subprocess.CompletedProcess( + args, + 1 if number in self.close_failures else 0, + stdout="", + stderr="close failed" if number in self.close_failures else "", + ) + + @property + def closed_numbers(self) -> list[int]: + return [int(args[3]) for args in self.calls if args[:3] == ["gh", "pr", "close"]] + + +class RetireSupersededWindowsSyncPRsTests(unittest.TestCase): + def test_closes_only_older_same_repository_main_sync_prs(self) -> None: + runner = FakeRunner( + [ + pull_request(10723, "release/windows-v1.0.26"), + pull_request(10419, "release/windows-v1.0.3"), + pull_request(10718, "release/windows-v1.0.25"), + pull_request(10730, "release/windows-v1.0.27"), + pull_request(10684, "release/windows-v1.0.22", cross_repository=True), + pull_request(10653, "release/windows-v1.0.21", base="development"), + pull_request(10000, "release/windows-maintenance"), + ] + ) + stdout = io.StringIO() + + closed = sync_cleanup.retire_superseded_prs( + repository="BasedHardware/Omi", + current_pr=10723, + current_version=(1, 0, 26), + runner=runner, + stdout=stdout, + stderr=io.StringIO(), + ) + + self.assertEqual(closed, 2) + self.assertEqual(runner.closed_numbers, [10419, 10718]) + close_calls = [args for args in runner.calls if args[:3] == ["gh", "pr", "close"]] + self.assertTrue(all("--delete-branch" not in args for args in close_calls)) + self.assertTrue(all("#10723" in args[args.index("--comment") + 1] for args in close_calls)) + self.assertIn("Retired 2 superseded", stdout.getvalue()) + + def test_skips_cleanup_unless_the_current_pr_is_confirmed(self) -> None: + runner = FakeRunner([pull_request(10419, "release/windows-v1.0.3")]) + stderr = io.StringIO() + + closed = sync_cleanup.retire_superseded_prs( + repository="BasedHardware/Omi", + current_pr=10723, + current_version=(1, 0, 26), + runner=runner, + stdout=io.StringIO(), + stderr=stderr, + ) + + self.assertEqual(closed, 0) + self.assertEqual(runner.closed_numbers, []) + self.assertIn("current Windows sync PR #10723 was not confirmed", stderr.getvalue()) + + def test_close_failure_is_nonfatal_and_does_not_stop_cleanup(self) -> None: + runner = FakeRunner( + [ + pull_request(10723, "release/windows-v1.0.26"), + pull_request(10419, "release/windows-v1.0.3"), + pull_request(10718, "release/windows-v1.0.25"), + ], + close_failures={10419}, + ) + stderr = io.StringIO() + + closed = sync_cleanup.retire_superseded_prs( + repository="BasedHardware/Omi", + current_pr=10723, + current_version=(1, 0, 26), + runner=runner, + stdout=io.StringIO(), + stderr=stderr, + ) + + self.assertEqual(closed, 1) + self.assertEqual(runner.closed_numbers, [10419, 10718]) + self.assertIn("could not close superseded Windows sync PR #10419", stderr.getvalue()) + + def test_list_failure_is_nonfatal(self) -> None: + runner = FakeRunner([], list_returncode=1) + stderr = io.StringIO() + + closed = sync_cleanup.retire_superseded_prs( + repository="BasedHardware/Omi", + current_pr=10723, + current_version=(1, 0, 26), + runner=runner, + stdout=io.StringIO(), + stderr=stderr, + ) + + self.assertEqual(closed, 0) + self.assertEqual(runner.closed_numbers, []) + self.assertIn("could not list Windows sync PRs", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/desktop_windows_release.yml b/.github/workflows/desktop_windows_release.yml index aabdbbeaf57..0d02e4dc4ad 100644 --- a/.github/workflows/desktop_windows_release.yml +++ b/.github/workflows/desktop_windows_release.yml @@ -178,6 +178,15 @@ jobs: fi if [ -n "$PR_NUMBER" ]; then + # A newer tag makes every older version-stamp PR obsolete. Confirm + # the replacement PR before closing only older same-repo sync PRs; + # cleanup never deletes tags, releases, or branches and is non-fatal. + python3 .github/scripts/retire_superseded_windows_sync_prs.py \ + --repository "$GITHUB_REPOSITORY" \ + --current-pr "$PR_NUMBER" \ + --current-version "$VERSION" || \ + echo "Could not retire superseded Windows sync PRs (non-fatal)." + gh pr merge "$PR_NUMBER" --merge --admin || \ gh pr merge "$PR_NUMBER" --merge --auto || \ gh pr merge "$PR_NUMBER" --merge || \