Skip to content
23 changes: 17 additions & 6 deletions scripts/ci/base_red_excuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@
_evidence = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_evidence)
Refused, job_for, latest_checks = _evidence.Refused, _evidence.job_for, _evidence.latest_checks
# A read GitHub rate-limited, where the evidence reader tells it apart (main's main_fix_evidence.py): exit 3, which
# gh-merge-green reports as a rate limit rather than a red. A reader without it raises Refused (exit 1).
RateLimited = getattr(_evidence, "RateLimited", ())


ESCAPE_FLAG = "--allow-escape-sequences"
Expand Down Expand Up @@ -804,15 +807,15 @@ def head_jobs(repo: str, sha: str, runs: list[int], checks: list[str], github, *
continue
job = job_for(repo, latest[name], sha, github)
if is_aggregate(name, repo):
if name in rollups(repo)["run"]:
aggregate_runs.append((name, job["run_id"]))
continue
# The check-run can remain failed while its Actions job has
# been retried or replaced. Never discard this exact job and
# rescan the run: a green/missing aggregate must not allow its
# child failures to be excused.
# child failures to be excused (a run rollup like ci-status too).
if job.get("conclusion") != "failure":
raise Refused(f"'{name}': exact aggregate job is {job.get('conclusion')}, not failure")
if name in rollups(repo)["run"]:
aggregate_runs.append((name, job["run_id"]))
continue
aggregate_jobs.append(job)
else:
jobs[job["id"]] = job
Expand Down Expand Up @@ -1325,8 +1328,13 @@ def judge(repo: str, base: str, sha: str, runs: list[int], github, *, checks: li
# jobs already answers for its finished ones (before_merge_base's note).
listed = github.json(f"repos/{repo}/actions/workflows/{workflow}/runs?branch={base}"
f"&per_page={BASE_RUNS_PAGE}")
base_runs[workflow] = [run for run in listed.get("workflow_runs") or []
if run.get("event") != "pull_request" and run.get("conclusion") != "skipped"]
# Newest first, whatever order GitHub lists them in; a run in a PR's context (pull_request or
# pull_request_target) is never the base's.
base_runs[workflow] = sorted(
(run for run in listed.get("workflow_runs") or []
if run.get("event") not in ("pull_request", "pull_request_target")
and run.get("conclusion") != "skipped"),
key=lambda run: run.get("created_at") or "", reverse=True)
# The newest base run where this job has a verdict decides whether it is red on the base.
verdicts = base_verdicts(repo, base, workflow, name, github, base_runs, base_jobs, job_reads)
base_run, base_job = next(verdicts, (None, None))
Expand Down Expand Up @@ -1391,6 +1399,9 @@ def main(argv: list[str] | None = None) -> int:
try:
audit = judge(args.repo, args.base, args.sha, args.run, GitHub(), checks=args.check,
flake_state=test_reds.load_flake_state(args.flake_state, fetch=True))
except RateLimited as error:
print(f"rate-limited: {error}", file=sys.stderr)
return 3
except Infra as error:
print(f"not excused: infra, not a verdict: {error}", file=sys.stderr)
return 1
Expand Down
8 changes: 6 additions & 2 deletions scripts/ci/test_reds.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")

FAILED = (
re.compile(r"✘ Test (.+?\)) (?:recorded an issue|failed)"), # Swift Testing
# Swift Testing: `name()`, or a display name (`@Test("A capped plan …")` prints `✘ Test "A capped plan …"`).
re.compile(r"✘ Test (\"[^\"\n]*\"|.+?\)) (?:recorded an issue|failed)"),
re.compile(r"Test Case '-\[(\S+ \S+)\]' failed"), # XCTest
re.compile(r"^test (\S+) \.\.\. FAILED$"), # cargo test
re.compile(r"^(?:FAIL|ERROR): (\S+ \([^)]+\))"), # unittest
Expand Down Expand Up @@ -293,7 +294,10 @@ def same_error(head: str | None, base: str | None) -> bool:

def log_lines(log: str) -> list[str]:
"""Every line of a job log with its timestamp and colours removed, the runner's step echoes included."""
return [STAMP.sub("", ANSI.sub("", line)).rstrip() for line in log.splitlines()]
# Swift Testing prefixes some event lines with a zero-width space (U+200B) when another writer's output
# shares the stream ("\u200b✔ Suite \"x\" passed"); left in, the anchored suite patterns miss the line and
# a suite that passed reads as never finished (https://github.com/manaflow-ai/cmux/pull/35296).
return [STAMP.sub("", ANSI.sub("", line)).lstrip("\u200b").rstrip() for line in log.splitlines()]


def lines_of(log: str) -> list[str]:
Expand Down
46 changes: 45 additions & 1 deletion tests/test_base_red_excuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ def json(self, route, *, paginate=False):
# Both list every status: the dispatch fallback for deferred_first_runs, the window because a finished
# job in a running base run is already a verdict.
assert "branch=feat-cmux-next" in route and "status=" not in route, route
return {"workflow_runs": copy.deepcopy(self.base_runs)}
runs = copy.deepcopy(self.base_runs)
if "&event=" in route:
# GitHub filters by event, as the tip suite's dispatch read asks it to.
event = route.split("&event=", 1)[1].split("&", 1)[0]
runs = [run for run in runs if run.get("event") == event]
return {"workflow_runs": runs}
if "/actions/runs/" in route:
return {"id": int(route.rsplit("/", 1)[1]), "workflow_id": 7}
raise AssertionError(route)
Expand Down Expand Up @@ -400,6 +405,22 @@ def test_a_test_failing_on_the_base_too_is_excused_with_the_base_run_named(self)
# The cancelled run 21 has no verdict; the newest run with one decides.
self.assertNotIn("run 21", text)

def test_a_pull_request_target_run_is_never_base_evidence(self):
# Like a pull_request run, it ran in a PR's context: its red is not the base's.
self.gh.base_runs[1]["event"] = "pull_request_target"
with self.assertRaisesRegex(excuse.Refused, "no feat-cmux-next run of its workflow has a verdict"):
self.judge()

def test_the_newest_base_run_decides_whatever_order_github_lists_them(self):
# Run 22 is newer and green; listed after the older red run 20, it still decides: not red on the base.
self.gh.jobs[3] = dict(failed_job(3, 22, "c" * 40), conclusion="success",
steps=[{"name": TEST_STEP, "status": "completed", "conclusion": "success"}])
self.gh.base_runs[1]["created_at"] = "2026-11-04T10:00:00Z"
self.gh.base_runs.append({"id": 22, "event": "push", "head_branch": "feat-cmux-next", "status": "completed",
"conclusion": "success", "head_sha": "c" * 40, "created_at": "2026-11-04T11:00:00Z"})
with self.assertRaisesRegex(excuse.Refused, "not red on feat-cmux-next run 22"):
self.judge()

def test_counts_do_not_matter_only_names(self):
# The base fails one more test than the head; the head's are all on the base.
self.assertTrue(self.judge())
Expand Down Expand Up @@ -942,6 +963,16 @@ def test_a_red_ci_status_is_judged_by_the_failed_jobs_of_its_run(self):
self.assertIn(f"macos / {SWIFT}", "\n".join(lines))
self.assertNotIn("'ci-status'", "\n".join(lines))

def test_a_red_ci_status_check_whose_job_was_retried_green_blocks(self):
# The check-run can stay failed while its exact job was re-run green: never rescan the run and excuse
# its child reds then, as for a routed rollup (the Guard status).
for job_id in (1, 2):
self.gh.jobs[job_id]["name"] = f"macos / {SWIFT}"
self.red_ci_status()
self.gh.jobs[3]["conclusion"] = "success"
with self.assertRaisesRegex(excuse.Refused, "exact aggregate job is success, not failure"):
excuse.judge(REPO, "feat-cmux-next", HEAD, [], self.gh, checks=["ci-status"])

def test_a_red_ci_status_whose_failed_jobs_are_all_outside_its_needs_blocks(self):
# The run's only red job is not under ci-status's needs (ci.yml's pr-red-checks, say), so it did not turn
# ci-status red: its own verdict did. A base-red job outside it must not carry it.
Expand Down Expand Up @@ -2377,6 +2408,19 @@ def test_a_red_outside_the_guard_comparison_is_refused(self):


class Script(unittest.TestCase):
def test_a_rate_limited_read_exits_3(self):
# gh-merge-green reports exit 3 as a rate limit, not a red (main's main_fix_evidence.RateLimited).
class RateLimited(excuse.Refused):
pass

def judge(*args, **kwargs):
raise RateLimited("GitHub rate-limited `gh api`")

with unittest.mock.patch.object(excuse, "RateLimited", RateLimited), \
unittest.mock.patch.object(excuse, "judge", judge), \
unittest.mock.patch.object(excuse.test_reds, "load_flake_state", lambda *a, **k: None):
self.assertEqual(excuse.main(["--repo", REPO, "--base", "main", "--sha", HEAD, "--check", "x"]), 3)

def test_gh_merge_green_asks_the_base_before_refusing_a_red_lane(self):
text = (ROOT / "scripts/gh-merge-green").read_text()
self.assertIn("base_red_excuse.py", text)
Expand Down
19 changes: 19 additions & 0 deletions tests/test_test_reds.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,17 @@ def test_a_new_failing_test_is_the_heads_own(self):
self.assertTrue(verdict.own)
self.assertIn("pass or do not run on the base: c()", verdict.reasons[0])

def test_display_named_swift_testing_reds_are_compared_by_name(self):
# PR 35296's shard 2: `@Test("…")` names print quoted, with no `()`.
shared = '✘ Test "A capped plan shows used/limit" recorded an issue at X.swift:25:9: Expectation failed'
param = ('✘ Test "Hovering shows help" recorded an issue with 2 arguments activeCount → 1, help → "x" '
'at X.swift:1:2: Expectation failed')
self.assertEqual(reds.read(log(shared, param, SUMMARY)).failed,
{'"A capped plan shows used/limit"', '"Hovering shows help"'})
self.assertFalse(reds.classify(log(shared, SUMMARY), log(shared, SUMMARY)).own)
new = log(shared, '✘ Test "Another" recorded an issue at X.swift:3:4: Expectation failed', SUMMARY)
self.assertIn('"Another"', reds.classify(new, log(shared, SUMMARY)).reasons[0])

def test_a_crash_on_both_sides_is_still_the_heads_own(self):
both = log(failed("a()"), ABORT, "*** Terminating app due to uncaught exception 'NSInternalInconsistencyException'")
verdict = reds.classify(both, both)
Expand Down Expand Up @@ -335,6 +346,14 @@ def test_a_whole_parameterized_run_is_not_cut_off(self):
"✘ Test a() failed after 0.1 seconds with 1 issue.", SUMMARY)
self.assertEqual(reds.read(whole).truncated, [])

def test_a_suite_end_after_a_zero_width_space_is_whole(self):
# PR 35296's app-host shard 2: Swift Testing printed "\u200b✔ Suite ... passed" after interleaved app logs.
whole = log('◇ Suite "Cloud sidebar recorded interaction" started.',
'\u200b✔ Suite "Cloud sidebar recorded interaction" passed after 0.001 seconds.', SUMMARY)
self.assertEqual(reds.read(whole).truncated, [])
cut = log('\u200b◇ Suite "s" started.', SUMMARY)
self.assertIn('1 suite(s) started and never finished: "s"', reds.read(cut).truncated)

def test_a_run_cut_off_between_tests_is_the_heads_own(self):
cut = log("◇ Test run started.", "◇ Suite S started.", "◇ Test a() started.", failed("a()"),
"✘ Test a() failed after 0.1 seconds with 1 issue.")
Expand Down
Loading