From 1a75230fb7b4c10c50ab0000815c8667d1e45ec8 Mon Sep 17 00:00:00 2001 From: forkwright Date: Wed, 26 Aug 2026 09:34:58 -0500 Subject: [PATCH] feat(release-pr-checks): make the release-PR healer a fleet reusable release-please creates its PR with GITHUB_TOKEN and GitHub raises no workflow-triggering events for that token, so every release PR arrives with its required contexts ABSENT rather than red. Branch protection holds such a PR forever: there is nothing to re-run and nothing to approve. aletheia has carried the only healer for months. Seventeen other repos run release-please with none, and nothing in those repos could reveal the gap. Measured 2026-08-26: four release PRs stuck 8 days with an empty statusCheckRollup while five runs each sat at action_required. Three changes make the aletheia original shareable: - REPO comes from GITHUB_REPOSITORY, which inside a workflow_call job is the CALLER's repository. Validated in main() rather than at import, so the unit tests still load the module outside an Actions environment; an empty value is a loud failure, because every gh call would otherwise target "" and report "no open release PR" -- a green run that healed nothing. - REQUIRED_CONTEXT_WORKFLOWS becomes a caller input. It scopes only the fallback dispatch path; approval queries every held run at the head SHA regardless of workflow, so the primary path needs no per-repo configuration. - The script is vendored here and checked out explicitly from this repository, since a workflow_call job's default checkout fetches the caller, where it does not exist. The PAT is gone, and this is the substantive correction. The ancestor asserted that GITHUB_TOKEN "is documented to be refused for run approval" and fell back to a FLEET_REPO_TOKEN secret. That assertion was never tested and is false: a job holding only GITHUB_TOKEN with actions: write posted to /actions/runs/{id}/approve for a genuinely held run, received 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, not "Resource not accessible by integration", so authorization had already passed. Consuming repos therefore need no shared credential, which removes a 17-repo secret rollout that was about to be provisioned on an untested premise. Adding the missing-repository guard made three tests fail and revealed that two others had been passing for the wrong reason -- they expect 1 and were getting it from the new guard rather than from the condition each asserts. Reporting now pins REPO in setUp, and the guard has its own test asserting that an unset repository fails loudly instead of sweeping nothing. 12 tests pass. --- .github/workflows/release-pr-checks.yml | 101 ++++++++ scripts/release_pr_checks.py | 304 ++++++++++++++++++++++++ scripts/tests/test_release_pr_checks.py | 208 ++++++++++++++++ 3 files changed, 613 insertions(+) create mode 100644 .github/workflows/release-pr-checks.yml create mode 100644 scripts/release_pr_checks.py create mode 100644 scripts/tests/test_release_pr_checks.py 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()