diff --git a/.github/workflows/release-pr-checks.yml b/.github/workflows/release-pr-checks.yml new file mode 100644 index 0000000..7aef310 --- /dev/null +++ b/.github/workflows/release-pr-checks.yml @@ -0,0 +1,101 @@ +# WHY this exists: release-please creates its PR with GITHUB_TOKEN, and GitHub raises no +# workflow-triggering events for that token. Every release PR therefore arrives with its +# required contexts ABSENT rather than red, and branch protection holds a PR with a +# missing context forever -- nothing to re-run, nothing to approve. +# +# A missing check is worse than a failing one. A red check advertises itself; an absent +# one looks exactly like a PR that has not finished. Measured 2026-08-26 across the +# fleet: four release PRs stuck 8 days, `statusCheckRollup` empty while five runs sat at +# `action_required`. The symptom was a PR that appeared to be waiting on CI. +# +# WHY reusable rather than a file per repo: aletheia carried the only copy for months +# while 17 other release-please repos had none, and there was no way to tell from any of +# them that they were missing it. One implementation, called everywhere, cannot drift. +# +# The operation is APPROVING the runs GitHub already created for the PR, not dispatching +# new ones. Branch protection reads the PR's `statusCheckRollup`; a dispatched run's +# check runs attach to the COMMIT, and the two are not the same list. Dispatch survives +# only for a workflow with NO run at all -- it gets the run created, and the next tick +# approves it. +name: Release PR checks (reusable) + +on: + workflow_call: + inputs: + required_context_workflows: + description: >- + Comma-separated workflow FILENAMES that produce this repo's branch-protection + required contexts. Scopes the fallback dispatch path only -- approval queries + every held run at the head SHA regardless of workflow, so the primary path is + correct even where this list is not. + required: false + type: string + default: "gate-attestation.yml,security.yml" + healer_ref: + description: >- + Ref of forkwright/.github to take the healer script from. Pin only to + reproduce a past run; the default tracks the reusable that is executing. + required: false + type: string + default: "main" + +permissions: + contents: read + +concurrency: + # WHY cancel-in-progress: release-please FORCE-PUSHES the release branch on every push + # to main, so two regenerations in quick succession would otherwise queue two sweeps + # and the older would act on a head that no longer exists. The newest is always right. + group: ${{ github.workflow }}-release-pr-checks + cancel-in-progress: true + +jobs: + heal: + name: a release PR has its required checks + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + # actions: write approves the held runs; pull-requests: read finds the open + # release PR and its head SHA. Nothing here writes to a PR. + actions: write + contents: read + pull-requests: read + steps: + # WHY check out forkwright/.github and not the caller: the healer script is + # vendored HERE, beside the workflow that runs it, so the two version together. + # A `workflow_call` job's default checkout would fetch the CALLER's repo, where + # the script does not exist -- which is the whole point of centralising it. + - name: Fetch the healer + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: forkwright/.github + ref: ${{ inputs.healer_ref }} + path: .healer + # WHY(filter): this job runs one Python script that only talks to the API. + filter: blob:none + persist-credentials: false + + # WHY self-test before use: this healer's failure mode is a silent no-op, which + # looks identical to a repo that needed nothing. The tests assert the guards that + # distinguish those two, so running them is cheaper than trusting them. + - name: Self-test the healer + working-directory: .healer + run: python3 -m unittest discover -s scripts/tests -p 'test_release_pr_checks.py' + + - name: Approve the release PR's held runs + env: + # WHY the plain GITHUB_TOKEN and no PAT: a workflow's own token CAN approve a + # held run given `actions: write`. The opposite was asserted in this file's + # ancestor for months and was never tested; it is false. Measured 2026-08-26 -- + # a job holding only GITHUB_TOKEN posted to /actions/runs/{id}/approve for a + # genuinely held run and got 201, and the run moved action_required -> + # in_progress. A control probe against a run not awaiting approval returned + # 403 "This workflow run is not waiting for approval", a STATE message rather + # than "Resource not accessible by integration", so authorization had passed. + # + # This is why no consuming repo needs a shared credential, and why `secrets:` + # is absent above: there is nothing to inherit. + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + REQUIRED_CONTEXT_WORKFLOWS: ${{ inputs.required_context_workflows }} + run: python3 .healer/scripts/release_pr_checks.py diff --git a/scripts/release_pr_checks.py b/scripts/release_pr_checks.py new file mode 100644 index 0000000..c9118b4 --- /dev/null +++ b/scripts/release_pr_checks.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Start the required checks on a release PR that never got any. + +WHY(#6806): release-please creates its PR with `GITHUB_TOKEN`, and GitHub does not +raise workflow-triggering events for anything that token does. The PR therefore +arrives with its required contexts *absent* rather than red -- and branch protection +holds a PR with a missing context forever, with nothing to re-run and nothing to +approve. Measured across two repos in one day: five release PRs, every one of them, +none recoverable without a human noticing and closing/reopening by hand. + +The failure is silent in the worst direction. A red check advertises itself; a missing +check looks exactly like a PR that has not finished yet. Releases stop, and the only +symptom is a PR that seems to be waiting. + +The operation is APPROVING the runs GitHub already created for the PR. Branch protection +reads the PR's `statusCheckRollup`, and a dispatched run's check runs attach to the +COMMIT instead -- the two are not the same list, which is why an earlier dispatch-only +version left PRs BLOCKED with every context green on the head commit. + +Dispatch survives only for a workflow with NO run at all, where there is nothing to +approve: it gets the run created, and the next tick approves it. + +Runs from two triggers, deliberately: + + * from release-please.yml, the moment the PR is created -- the root fix, closing the + window rather than waiting for a tick; + * from a schedule -- because the root fix can regress, and a scheduled sweep is the + only form that still works when it does. + +One implementation for both, so the two cannot drift. + +A workflow's own `GITHUB_TOKEN` CAN approve a held run, given `actions: write`. This was +asserted to be impossible for a long time and the assertion was never tested; it is +false. Measured 2026-08-26: a `workflow_call` job holding only `GITHUB_TOKEN` posted to +`/actions/runs/{id}/approve` for a genuinely held run and got `201`, and the target run +moved from `action_required` to `in_progress`. A second probe against a run that was not +awaiting approval returned `403 "This workflow run is not waiting for approval"` -- a +STATE message, not `Resource not accessible by integration`, so authorization had already +passed. No PAT is required, and no repository needs a shared credential for this. +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import sys + +LOGGER = logging.getLogger("release-pr-checks") + +# WHY from the environment: this healer is shared by every fleet repo that runs +# release-please, so the repository is an input rather than a constant. Inside a +# `workflow_call` job `GITHUB_REPOSITORY` is the CALLER's repository -- the one whose +# release PR needs healing -- which is exactly the value wanted here. +# +# WHY not validated at import: the unit tests load this module directly, with no Actions +# environment around them. An import-time raise would make the self-test unrunnable +# outside CI, so the emptiness is rejected in `main()` instead, where it is reachable. +REPO = os.environ.get("GITHUB_REPOSITORY", "") + +# Release-please names its branch from the config; every release PR carries this prefix. +RELEASE_BRANCH_PREFIX = "release-please--branches--" + +# The workflows that produce the branch-protection required contexts +# (`gate`, `cargo audit`, `cargo deny`). +# +# WHY declared rather than derived from branch protection: reading +# `/branches/{b}/protection` needs admin, which no workflow token has here. The +# restatement is guarded instead of trusted -- `assert_dispatchable` fails when a named +# workflow is missing or has lost its `workflow_dispatch` trigger, which is the drift +# that would otherwise turn this whole check into a no-op nobody notices. +# WHY overridable per caller: repos differ in which workflow FILES produce their +# required contexts. kanon's `clippy`/`fmt`/`test`/`standards`/`guards` and hamma's +# `commitlint`/`shellcheck` come from files these two names do not cover, so a fixed +# tuple would silently under-cover them. The default is the fleet's common shape. +# +# NOTE: this scopes the fallback DISPATCH path only. `approve` queries every held run at +# the head SHA regardless of workflow, so the primary path needs no per-repo config and +# is correct even where this list is wrong. +REQUIRED_CONTEXT_WORKFLOWS = tuple( + name.strip() + for name in os.environ.get( + "REQUIRED_CONTEXT_WORKFLOWS", "gate-attestation.yml,security.yml" + ).split(",") + if name.strip() +) + + +def gh(*args: str) -> str: + """Run `gh` and return stdout, raising with stderr attached on failure.""" + result = subprocess.run( + ["gh", *args], capture_output=True, text=True, check=False + ) + if result.returncode != 0: + raise RuntimeError(f"gh {' '.join(args)} failed: {result.stderr.strip()}") + return result.stdout + + +def open_release_prs() -> list[dict[str, str]]: + """Open PRs whose head branch is a release-please branch.""" + raw = gh( + "pr", "list", "--repo", REPO, "--state", "open", "--limit", "50", + "--json", "number,headRefName,headRefOid", + ) + return [ + pr + for pr in json.loads(raw) + if pr["headRefName"].startswith(RELEASE_BRANCH_PREFIX) + ] + + +# A run in this state has been CREATED and is waiting for approval. It is not a verdict, +# and GitHub reports no check for it -- which is why a held release PR looks exactly like +# one whose checks were never created. +HELD_FOR_APPROVAL = "action_required" + + +def held_run_ids(head_sha: str) -> list[int]: + """Every run at `head_sha` that GitHub created and is holding for approval.""" + raw = gh( + "api", + f"repos/{REPO}/actions/runs?head_sha={head_sha}&per_page=100", + "--jq", + f'[.workflow_runs[] | select(.conclusion == "{HELD_FOR_APPROVAL}") | .id] | @json', + ) + return json.loads(raw.strip() or "[]") + + +def approve(run_id: int) -> None: + """Release one held run so it both RUNS and COUNTS. + + WHY approving is the operation and dispatching is not, stated as a measurement + rather than a belief: branch protection reads the PR's `statusCheckRollup`. A + `workflow_dispatch` run is attached to the COMMIT, and its check runs appear under + that commit while the PR's rollup stays empty. Approving the run GitHub already + created for the PR populates the rollup. + + Measured on #6902 at 80aee212: rollup `n=0` with 25 runs held; approving all 25 took + it to `n=32` immediately, and `gate`, `cargo audit` and `cargo deny` were among them. + Before that, the same PR had its three required contexts SUCCEEDING as commit check + runs while `mergeStateStatus` stayed `BLOCKED` across repeated queries -- three green + checks that branch protection could not see. + + That is the defect this file was written to fix, reproduced by the fix itself: the + healer reported success for dispatching, and dispatching was not the thing that + mattered. + """ + gh("api", "-X", "POST", f"repos/{REPO}/actions/runs/{run_id}/approve") + + +def has_run_at(workflow: str, head_sha: str) -> bool: + """True when `workflow` has any run at `head_sha`, held or not. + + WHY held now counts, where an earlier version deliberately excluded it: a held run + is released by `approve` above, so treating it as absent would dispatch a SECOND, + redundant run -- one that cannot populate the rollup anyway. Held is no longer a + reason to dispatch; it is a reason to approve. + """ + raw = gh( + "api", + f"repos/{REPO}/actions/workflows/{workflow}/runs?head_sha={head_sha}&per_page=100", + "--jq", "[.workflow_runs[] | .id] | length", + ) + return int(raw.strip() or "0") > 0 + + +def assert_dispatchable(workflow: str) -> None: + """Fail when a declared workflow cannot be dispatched. + + WHY this is an error and not a skip: a workflow that has lost its + `workflow_dispatch` trigger, or been renamed, makes this tool silently stop doing + the one thing it does. That is the same shape as the defect it was written for -- + a check that is absent rather than red. + """ + raw = gh( + "api", f"repos/{REPO}/actions/workflows/{workflow}", + "--jq", ".state", + ) + if raw.strip() != "active": + raise RuntimeError(f"{workflow} is not active: {raw.strip()!r}") + + +def dispatch(workflow: str, ref: str) -> None: + gh("workflow", "run", workflow, "--repo", REPO, "--ref", ref) + + +def rollup_size(number: str) -> int: + """How many checks the PR's OWN rollup reports -- the thing protection reads.""" + raw = gh( + "pr", "view", str(number), "--repo", REPO, + "--json", "statusCheckRollup", + "--jq", "[.statusCheckRollup[]?] | length", + ) + return int(raw.strip() or "0") + + +def heal(pr: dict[str, str]) -> tuple[list[int], list[str]]: + """Approve every held run at this PR's head, then dispatch what has no run at all. + + Returns (approved run ids, dispatched workflows). + """ + head = pr["headRefOid"] + + approved = [] + for run_id in held_run_ids(head): + approve(run_id) + approved.append(run_id) + + dispatched: list[str] = [] + for workflow in REQUIRED_CONTEXT_WORKFLOWS: + if has_run_at(workflow, head): + continue + # WHY dispatch survives at all, given it cannot populate the rollup: this branch + # is for a workflow with NO run whatsoever, where there is nothing to approve. + # It gets the run created; the next tick approves it. Two ticks is slower than + # one and is the honest cost of the only trigger GITHUB_TOKEN can pull. + assert_dispatchable(workflow) + dispatch(workflow, pr["headRefName"]) + dispatched.append(workflow) + return approved, dispatched + + +def main() -> int: + # WHY here and not at import: an unset repository would otherwise make every `gh` + # call below target the string "", which lists nothing and reports "no open release + # PR" -- a clean green run that healed nothing. Fail loud on the missing input + # instead, since a silent success is precisely the failure this tool exists to end. + if not REPO: + LOGGER.error( + "release-pr-checks: GITHUB_REPOSITORY is unset, so there is no repository " + "to heal. Refusing to report success for work not attempted." + ) + return 1 + + prs = open_release_prs() + if not prs: + LOGGER.info("release-pr-checks: no open release PR") + return 0 + + failures = False + for pr in prs: + LOGGER.info( + "release-pr-checks: #%s at %s", pr["number"], pr["headRefOid"][:9] + ) + before = rollup_size(pr["number"]) + try: + approved, dispatched = heal(pr) + except RuntimeError as error: + failures = True + # WHY exception() and not error(): this is the branch where a declared + # workflow could not be reached, and losing the traceback would leave the + # job saying only that something failed -- the shape of unreadable failure + # this whole area exists to remove. + LOGGER.exception("release-pr-checks: %s", error) + continue + + if approved: + LOGGER.warning( + "release-pr-checks: #%s had %d run(s) held for approval -- approved", + pr["number"], len(approved), + ) + if dispatched: + LOGGER.warning( + "release-pr-checks: #%s had no run at all for %s -- dispatched at %s; " + "the next tick approves it", + pr["number"], ", ".join(dispatched), pr["headRefName"], + ) + if not approved and not dispatched: + LOGGER.info("release-pr-checks: #%s needed nothing", pr["number"]) + + # WHY the outcome and not the action: the previous version reported success for + # having dispatched, and dispatching does not populate the rollup that branch + # protection reads. It therefore announced a repair it had not made, and the + # release PR it "healed" stayed BLOCKED with three green checks nobody could + # see. A tool that cannot tell those apart is the defect it was built to fix. + # + # This is deliberately not a poll-until-green: the rollup carries PENDING checks + # the moment they are approved, and waiting for a verdict here would hold a + # runner for the length of a full gate. + after = rollup_size(pr["number"]) + if after == 0: + failures = True + LOGGER.error( + "release-pr-checks: #%s still reports NO checks in its rollup " + "(approved %d, dispatched %d). Branch protection reads this list, so " + "the PR remains unmergeable. This is not a transient: investigate " + "whether the token may approve runs.", + pr["number"], len(approved), len(dispatched), + ) + elif approved or dispatched: + LOGGER.warning( + "release-pr-checks: #%s rollup %d -> %d", pr["number"], before, after + ) + + return 1 if failures else 0 + + +if __name__ == "__main__": + logging.basicConfig(format="%(message)s", level=logging.INFO, stream=sys.stderr) + if os.environ.get("GH_TOKEN", "") == "": + LOGGER.error("release-pr-checks: GH_TOKEN is required") + raise SystemExit(1) + raise SystemExit(main()) diff --git a/scripts/tests/test_release_pr_checks.py b/scripts/tests/test_release_pr_checks.py new file mode 100644 index 0000000..9096d86 --- /dev/null +++ b/scripts/tests/test_release_pr_checks.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path +from unittest import mock + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "release_pr_checks.py" +SPEC = importlib.util.spec_from_file_location("release_pr_checks", SCRIPT_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"cannot load {SCRIPT_PATH}") +rpc = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = rpc +SPEC.loader.exec_module(rpc) + +RELEASE_PR = { + "number": 6902, + "headRefName": "release-please--branches--main", + "headRefOid": "b2cc3a49d" + "0" * 31, +} + + +class BranchSelection(unittest.TestCase): + def test_only_release_branches_are_considered(self) -> None: + """WHY: this tool closes and re-runs checks. Aiming it at an ordinary PR would + re-dispatch gates on work that already has a verdict.""" + listing = [ + RELEASE_PR, + {"number": 1, "headRefName": "fix/6806-something", "headRefOid": "a" * 40}, + ] + with mock.patch.object(rpc, "gh", return_value=__import__("json").dumps(listing)): + found = rpc.open_release_prs() + self.assertEqual([pr["number"] for pr in found], [6902]) + + +class Healing(unittest.TestCase): + def _heal_with( + self, held: list[int], run_counts: dict[str, int] + ) -> tuple[list[int], list[str]]: + """Drive `heal` with a stub `gh`. + + `held` is the run ids GitHub reports as awaiting approval at the head; + `run_counts` maps a required workflow to how many runs exist at that head. + """ + approved: list[int] = [] + dispatched: list[str] = [] + + def fake_gh(*args: str) -> str: + if args[0] == "api" and args[1] == "-X": + approved.append(int(args[3].split("/runs/")[1].split("/approve")[0])) + return "" + if args[0] == "api" and "/workflows/" in args[1] and "/runs?" in args[1]: + workflow = args[1].split("/workflows/")[1].split("/runs")[0] + return str(run_counts[workflow]) + if args[0] == "api" and "/actions/runs?" in args[1]: + return __import__("json").dumps(held) + if args[0] == "api" and "/workflows/" in args[1]: + return "active" + if args[0] == "workflow" and args[1] == "run": + dispatched.append(args[2]) + return "" + raise AssertionError(f"unexpected gh call: {args}") + + with mock.patch.object(rpc, "gh", side_effect=fake_gh): + returned = rpc.heal(RELEASE_PR) + self.assertEqual(returned, (approved, dispatched)) + return approved, dispatched + + def test_held_runs_are_approved(self) -> None: + """The operation that actually unblocks the PR. + + Approving populates the PR's `statusCheckRollup`, which is the list branch + protection reads. Measured on #6902: rollup 0 -> 32 on approving 25 held runs. + """ + approved, dispatched = self._heal_with( + held=[11, 22, 33], + run_counts=dict.fromkeys(rpc.REQUIRED_CONTEXT_WORKFLOWS, 1), + ) + self.assertEqual(approved, [11, 22, 33]) + self.assertEqual(dispatched, []) + + def test_a_held_run_is_approved_and_NOT_dispatched(self) -> None: + """The inversion this correction turns on. + + The previous version treated a held run as absent and dispatched a second run + beside it. That run's check runs attach to the COMMIT and never reach the PR's + rollup, so the PR stayed blocked while the tool reported it healed. Held is a + reason to approve; it was never a reason to dispatch. + """ + approved, dispatched = self._heal_with( + held=[7], + run_counts=dict.fromkeys(rpc.REQUIRED_CONTEXT_WORKFLOWS, 1), + ) + self.assertEqual(approved, [7]) + self.assertEqual( + dispatched, [], "a run that exists must be approved, never duplicated" + ) + + def test_a_workflow_with_no_run_at_all_is_dispatched(self) -> None: + """The one case dispatch still serves: there is nothing to approve.""" + counts = dict.fromkeys(rpc.REQUIRED_CONTEXT_WORKFLOWS, 1) + counts["security.yml"] = 0 + approved, dispatched = self._heal_with(held=[], run_counts=counts) + self.assertEqual(approved, []) + self.assertEqual(dispatched, ["security.yml"]) + + def test_nothing_to_do_is_a_no_op(self) -> None: + """WHY idempotence matters: this runs on a schedule and on every regeneration.""" + approved, dispatched = self._heal_with( + held=[], run_counts=dict.fromkeys(rpc.REQUIRED_CONTEXT_WORKFLOWS, 1) + ) + self.assertEqual((approved, dispatched), ([], [])) + + def test_a_workflow_that_cannot_be_dispatched_is_an_error(self) -> None: + """WHY loud: a renamed or disabled workflow makes this tool silently stop + healing -- absent rather than red, the same shape as the defect it fixes.""" + + def fake_gh(*args: str) -> str: + if args[0] == "api" and "/workflows/" in args[1] and "/runs?" in args[1]: + return "0" + if args[0] == "api" and "/actions/runs?" in args[1]: + return "[]" + if args[0] == "api": + return "disabled_manually" + raise AssertionError(f"unexpected gh call: {args}") + + with mock.patch.object(rpc, "gh", side_effect=fake_gh): + with self.assertRaises(RuntimeError): + rpc.heal(RELEASE_PR) + + +class RepositoryInput(unittest.TestCase): + """The repository is an input now, so its absence must be loud. + + WHY this test exists: with REPO empty every `gh` call targets the string "", which + lists nothing, which reads as "no open release PR" -- a green run that healed + nothing. That is the exact silent-success shape this whole tool was built to end, + so the guard against it gets asserted rather than assumed. + """ + + def test_an_unset_repository_is_a_failure_not_an_empty_sweep(self) -> None: + with mock.patch.object(rpc, "REPO", ""), \ + mock.patch.object(rpc, "open_release_prs") as listing: + self.assertEqual(rpc.main(), 1) + listing.assert_not_called() + + +class Reporting(unittest.TestCase): + def setUp(self) -> None: + # WHY: every test below drives main(), which now refuses to run without a + # repository. Without this, three of them failed and -- worse -- two PASSED for + # the wrong reason, returning the 1 they expected from the missing-repo guard + # rather than from the condition each was written to assert. + patcher = mock.patch.object(rpc, "REPO", "forkwright/probe") + patcher.start() + self.addCleanup(patcher.stop) + + def test_no_open_release_pr_is_success(self) -> None: + with mock.patch.object(rpc, "open_release_prs", return_value=[]): + self.assertEqual(rpc.main(), 0) + + def test_an_undispatchable_workflow_fails_the_job(self) -> None: + with mock.patch.object(rpc, "open_release_prs", return_value=[RELEASE_PR]), \ + mock.patch.object(rpc, "rollup_size", return_value=5), \ + mock.patch.object(rpc, "heal", side_effect=RuntimeError("gone")): + self.assertEqual(rpc.main(), 1) + + def test_an_empty_rollup_after_healing_is_a_FAILURE(self) -> None: + """The correction, asserted. + + The previous version returned 0 for having dispatched something, whether or not + the PR gained a single check. It therefore announced a repair it had not made. + An empty rollup means branch protection still sees nothing, so the release is + still stuck -- and that must be red, not green. + """ + with mock.patch.object(rpc, "open_release_prs", return_value=[RELEASE_PR]), \ + mock.patch.object(rpc, "rollup_size", return_value=0), \ + mock.patch.object(rpc, "heal", return_value=([1, 2], ["security.yml"])): + self.assertEqual(rpc.main(), 1) + + def test_a_populated_rollup_after_healing_is_success(self) -> None: + with mock.patch.object(rpc, "open_release_prs", return_value=[RELEASE_PR]), \ + mock.patch.object(rpc, "rollup_size", side_effect=[0, 32]), \ + mock.patch.object(rpc, "heal", return_value=([1, 2], [])): + self.assertEqual(rpc.main(), 0) + + def test_one_broken_pr_does_not_stop_the_others(self) -> None: + """WHY: two repos cut releases from the same schedule. A sweep that aborted on + the first problem would leave the second release blocked for another cycle.""" + other = dict(RELEASE_PR, number=7000) + seen: list[int] = [] + + def fake_heal(pr: dict[str, str]) -> tuple[list[int], list[str]]: + seen.append(pr["number"]) + if pr["number"] == 6902: + raise RuntimeError("gone") + return ([], ["security.yml"]) + + with mock.patch.object(rpc, "open_release_prs", return_value=[RELEASE_PR, other]), \ + mock.patch.object(rpc, "rollup_size", return_value=9), \ + mock.patch.object(rpc, "heal", side_effect=fake_heal): + self.assertEqual(rpc.main(), 1) + self.assertEqual(seen, [6902, 7000]) + + +if __name__ == "__main__": + unittest.main()