Files
JMR-devandClaude Opus 4.8 2fcee291ce ci: trigger CI with the PAT so dispatches don't need manual approval (#351)
#350 made ci-trigger.yml dispatch ci.yml with the built-in GITHUB_TOKEN, on the
claim that a workflow_dispatch is anti-recursion-exempt so no PAT is needed. In
practice a GITHUB_TOKEN-triggered run is held in `action_required` awaiting manual
approval and never runs un-attended, so auto-updated PRs' CI never ran (stalled
#285). The original #349 design was right: dispatch with a PAT so the run executes
as the authorized owner with no approval gate.

- ci-trigger.yml: the trigger step's GH_TOKEN is now
  `${{ secrets.AUTOUPDATE_TOKEN || github.token }}` (was `${{ github.token }}`).
  AUTOUPDATE_TOKEN (the PAT) is REQUIRED for the scheduler; the `|| github.token`
  fallback stays fail-open but only starts CI if repo settings don't gate
  GITHUB_TOKEN-triggered runs.
- autoupdate.yml: branch update stays on GITHUB_TOKEN (must NOT retrigger CI --
  that would re-introduce the cascade). Clarified that AUTOUPDATE_TOKEN is still
  required by the repo (by ci-trigger.yml) so the secret isn't deleted.
- Corrected the now-wrong "no PAT needed / workflow_dispatch anti-recursion-exempt"
  comments in ci-trigger.yml and the traffic_control.py docstrings.

updates = GITHUB_TOKEN, triggering = PAT.

Validation: all three workflow YAMLs parse clean; traffic-control unit tests still
pass (59 tests) -- the change is workflow-env only, script logic unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:28:10 -05:00

806 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""CI traffic-controller: priority-based runner orchestration for LibreMail's
`ci.yml`. Extracted out of the old inline-bash `traffic-control` step into a
Python module so the decision logic is developer-legible and, above all, unit-
testable (see `test_traffic_control.py`).
DESIGN: pure decision CORE + thin gh-I/O SHELL
----------------------------------------------
The decisions ("who do we cancel?", "do we proceed or wait?") are pure functions
over plain `PullRequest` snapshots — no network, no clock, no subprocess — so they
can be exercised exhaustively in unit tests. The SHELL (`run_live`) is the only part
that touches `gh`: it gathers the snapshot, applies the cancellations, and runs the
bounded hold-back poll loop. Feed the core a snapshot JSON (`--dry-run`) to see its
decisions with zero network.
TWO MODES
---------
* ``--mode orchestrate`` (default; unchanged behaviour): the in-run `traffic-control`
job of `ci.yml`. Orders runner ACCESS for the PR whose run is already executing —
PASS 1 preemption + PASS 2 hold-back (below). This is `run_live`.
* ``--mode trigger`` (issue #349): the *scheduler* (companion `ci-trigger.yml`, run
after each auto-update and on a cron backstop). It OWNS CI *triggering*: it
(re-)triggers CI for the highest-priority PR(s) whose head SHA has absent/stale
required checks — a few at a time (an inflight cap), in the SAME priority order —
via a `workflow_dispatch`. This is `run_trigger` / the pure `select_triggers`.
WHY --mode trigger EXISTS (issue #349): `autoupdate.yml` now updates PR branches with the
built-in GITHUB_TOKEN instead of a PAT, so an update push no longer auto-retriggers CI
(GitHub's anti-recursion rule) — killing the merge-cascade that cancelled every open PR's
run on every merge. The cost is that a freshly-updated PR's required checks go stale/absent
on its NEW head SHA, so this scheduler deliberately (re-)triggers them in priority order (a
poor-man's merge queue). The dispatch uses the AUTOUPDATE_TOKEN PAT, NOT the built-in
GITHUB_TOKEN: a GITHUB_TOKEN-triggered run is held for MANUAL approval (`action_required`) and
never runs un-attended, whereas a PAT dispatch runs as the authorized owner with no approval gate
(#350's "no PAT needed" claim was wrong — see ci-trigger.yml + issue #351). FAIL-OPEN,
structurally: `ci.yml` KEEPS its `on: pull_request`
trigger, so any human push — and a brand-new PR — always gets CI regardless of this
scheduler; the scheduler only fills the gap left by GITHUB_TOKEN auto-updates and can never
leave a PR un-triggerable. Fork PRs (no token/secret access) are skipped by the scheduler and
left to `on: pull_request`, so they are never wedged either.
ORDER OF OPERATIONS (issue #342)
--------------------------------
1. Effective priority orders everything: the lowest-numbered `P0`-`P9` label present
(P0 = highest), default `P5` if none. A `broken` OR `draft` PR is effectively P10
(bottom, below P9), overriding any P0-P9 label.
2. PASS 1 - preemption: a strictly-lower OTHER PR's in-progress / queued run is
cancelled iff THIS PR is P0 (an emergency reclaims ALL lower runners) OR the target
is broken/draft (a wasted run any higher-priority PR may reclaim). P1-P9 never bump
a *normal* lower run mid-flight — only a P0 does that.
3. PASS 2 - bounded hold-back: a non-P0 PR yields (cancels nothing) to any strictly-
higher-priority OTHER PR that has an active/queued run, and — among its OWN
priority level — to any PR ordered ahead of it (running-first, then oldest by
`createdAt`). It proceeds the moment it is at the front, or when the wait budget
elapses (a PR never blocks itself).
SAFETY INVARIANTS (preserved from the original step)
----------------------------------------------------
* never cancel a run on `main` / a push event — the shell's `gh run list` query
filters `--event pull_request` and drops `headBranch == main`, so only PR-event
runs ever reach the core;
* never cancel THIS PR's own run — skipped by PR number AND by run id;
* never cancel an equal-or-higher-priority PR — only strictly-lower (prio > self).
This is deliberately NOT a merge gate: every gh call is guarded, the shell always
exits 0, and the ci.yml step stays `continue-on-error`, so a hiccup (API error,
missing permission, fork PR) can never fail CI.
Pure standard library, cross-platform (the primary dev box is Windows, where the old
bash + `jq` pipeline had no clean equivalent).
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import time
from dataclasses import dataclass, field
# ── Priority model ───────────────────────────────────────────────────────────
BROKEN_LABEL = "broken"
DEFAULT_PRIORITY = 5 # a PR with no P0-P9 label
BOTTOM_PRIORITY = 10 # broken OR draft — below P9
TOP_PRIORITY = 0 # P0, the only priority that preempts
_P_LABEL = re.compile(r"^P([0-9])$") # single digit only, matching the old jq `^P[0-9]$`
# ── Run-status model (normalised from gh's raw run statuses) ─────────────────
RUNNING = "running" # gh status in_progress
QUEUED = "queued" # gh status queued / waiting / requested / pending
NONE = "none" # no active run (completed or absent)
ACTIVE = frozenset({RUNNING, QUEUED})
# For aggregating a branch's overall status from its runs: running beats queued
# beats none (most-active wins). NB: the same-level ORDER (see _ordering_key) is a
# coarser two-bucket split — in-flight (running) vs everything-else-by-age.
_STATUS_RANK = {RUNNING: 0, QUEUED: 1, NONE: 2}
# createdAt sentinel so a PR with an unknown timestamp sorts LAST (never wrongly
# "oldest"/front, so it yields rather than preempts another PR's front slot).
_FAR_FUTURE = "9999-12-31T23:59:59Z"
# Run conclusions that count as a FINAL VERDICT on a head SHA (issue #349, --mode trigger).
# A SHA with one of these is NOT re-triggered: success = green, failure/timeout/etc. = the
# author's to fix — auto-retriggering a real failure would waste runners and could loop.
# Everything else a completed run can report (cancelled / skipped / stale / startup_failure /
# null) is treated as "no verdict", so a SHA whose only runs are those — or that has no run at
# all (absent checks after a GITHUB_TOKEN auto-update) — is NEEDY and gets (re-)triggered.
VERDICT_CONCLUSIONS = frozenset(
{"success", "failure", "timed_out", "action_required", "neutral"}
)
@dataclass(frozen=True)
class PullRequest:
"""A snapshot of one open PR. The pure decision core consumes only these — no
network. `run_ids` are the PR's active (non-completed) CI run ids, already
filtered to pull_request events on a non-main head by the shell that built them."""
number: int
labels: tuple[str, ...] = ()
is_draft: bool = False
created_at: str = ""
run_status: str = NONE
run_ids: tuple[int, ...] = ()
@classmethod
def from_json(cls, obj: dict) -> "PullRequest":
"""Build from a snapshot dict. `labels` may be a list of names or of gh's
label objects (`{"name": ...}`)."""
raw_labels = obj.get("labels") or []
names: list[str] = []
for lab in raw_labels:
if isinstance(lab, dict):
name = lab.get("name")
else:
name = lab
if name:
names.append(str(name))
status = (obj.get("runStatus") or obj.get("run_status") or NONE).lower()
if status not in (RUNNING, QUEUED, NONE):
status = NONE
raw_ids = obj.get("runIds") or obj.get("run_ids") or ()
return cls(
number=int(obj["number"]),
labels=tuple(names),
is_draft=bool(obj.get("isDraft") or obj.get("is_draft")
or obj.get("draft") or False),
created_at=str(obj.get("createdAt") or obj.get("created_at") or ""),
run_status=status,
run_ids=tuple(int(r) for r in raw_ids),
)
@dataclass(frozen=True)
class Blocker:
"""A PR that THIS PR must yield to in PASS 2 (purely informational for logging)."""
number: int
priority: int
kind: str # "higher-priority" | "same-level-ahead"
@dataclass(frozen=True)
class Decision:
"""The full point-in-time decision for THIS PR (used by --dry-run and tests)."""
self_number: int
self_priority: int
cancel_run_ids: tuple[int, ...] = ()
blockers: tuple[Blocker, ...] = ()
proceed: bool = True
# ── Pure decision core (no network / clock / subprocess) ─────────────────────
def effective_priority(pr: PullRequest) -> int:
"""Effective priority: `broken` OR `draft` => 10 (bottom, overriding any P0-P9);
else the lowest-numbered P0-P9 label present; else the default P5."""
if pr.is_draft or BROKEN_LABEL in pr.labels:
return BOTTOM_PRIORITY
nums = [int(m.group(1)) for name in pr.labels if (m := _P_LABEL.match(name))]
return min(nums) if nums else DEFAULT_PRIORITY
def priority_label(prio: int) -> str:
"""Human-readable priority for logs."""
if prio >= BOTTOM_PRIORITY:
return f"P{BOTTOM_PRIORITY} (broken/draft — bottom, below P9)"
return f"P{prio}"
def _ordering_key(pr: PullRequest) -> tuple[int, str, int]:
"""Same-level ordering (issue #342 rule 3): an in-flight (RUNNING) run keeps its
place at the front — a same-level peer never reorders it — then, among the PRs
still waiting to start (QUEUED or no run yet), OLDEST createdAt first (ascending),
then PR number as a stable final tiebreak so the order is fully deterministic."""
in_flight = 0 if pr.run_status == RUNNING else 1
return (in_flight, pr.created_at or _FAR_FUTURE, pr.number)
def runs_to_cancel(
this_pr: PullRequest,
all_prs: list[PullRequest],
*,
self_run_id: int | None = None,
) -> list[int]:
"""PASS 1. Run ids to cancel. A strictly-lower OTHER PR's active (running/queued)
run is cancelled iff keeping it running is wasteful, i.e. EITHER:
* THIS PR is P0 — an emergency reclaims every strictly-lower runner now; OR
* the target is broken/draft (effective priority 10) — its run can't merge /
isn't merge-ready, so ANY higher-priority PR may reclaim its runner.
P1-P9 never cancel a *normal* strictly-lower run — they yield in PASS 2 instead.
Invariants: never cancel self (by number or run id), never cancel an
equal-or-higher-priority PR (only strictly-lower, prio > self)."""
self_prio = effective_priority(this_pr)
to_cancel: list[int] = []
seen: set[int] = set()
for pr in all_prs:
if pr.number == this_pr.number:
continue # never cancel self
target_prio = effective_priority(pr)
if target_prio <= self_prio:
continue # only strictly-lower (skip equal-or-higher)
if pr.run_status not in ACTIVE:
continue # nothing running/queued to cancel
# Strictly lower: preemptible iff we're P0 OR the target is broken/draft
# (a bottom, priority-10, wasted run that any higher PR may reclaim).
if self_prio != TOP_PRIORITY and target_prio < BOTTOM_PRIORITY:
continue # P1-P9 don't bump a *normal* lower run
for rid in pr.run_ids:
if self_run_id is not None and rid == self_run_id:
continue # never cancel our own run
if rid in seen:
continue
seen.add(rid)
to_cancel.append(rid)
return to_cancel
def wait_blockers(this_pr: PullRequest, all_prs: list[PullRequest]) -> list[Blocker]:
"""PASS 2. The PRs THIS PR must yield to right now (empty => proceed). P0 never
yields. Otherwise yield to (a) any strictly-higher-priority OTHER PR with an
active/queued run, and (b) any SAME-priority PR ordered ahead of THIS PR
(running-first, then oldest createdAt)."""
self_prio = effective_priority(this_pr)
if self_prio == TOP_PRIORITY:
return [] # P0 outranks everything — never wait
others = [pr for pr in all_prs if pr.number != this_pr.number]
blockers: list[Blocker] = []
# (a) strictly-higher-priority PRs that actually have an active/queued run.
for pr in others:
p = effective_priority(pr)
if p < self_prio and pr.run_status in ACTIVE:
blockers.append(Blocker(pr.number, p, "higher-priority"))
# (b) same-level ordering: THIS PR proceeds only when it is at the front.
same_level = [pr for pr in others if effective_priority(pr) == self_prio]
same_level.append(this_pr) # this_pr appears exactly once
for pr in sorted(same_level, key=_ordering_key):
if pr.number == this_pr.number:
break # reached self => nobody ahead remains
blockers.append(Blocker(pr.number, self_prio, "same-level-ahead"))
return blockers
def decide(
this_pr: PullRequest,
all_prs: list[PullRequest],
*,
self_run_id: int | None = None,
) -> Decision:
"""Convenience: the full point-in-time decision (both passes) for THIS PR."""
cancels = runs_to_cancel(this_pr, all_prs, self_run_id=self_run_id)
blockers = wait_blockers(this_pr, all_prs)
return Decision(
self_number=this_pr.number,
self_priority=effective_priority(this_pr),
cancel_run_ids=tuple(cancels),
blockers=tuple(blockers),
proceed=not blockers,
)
# ── Pure TRIGGER-decision core (issue #349, --mode trigger) ──────────────────
def classify_sha_runs(runs: list[dict]) -> tuple[str, tuple[int, ...], bool]:
"""PURE. Summarise the CI runs on ONE head SHA. Returns (run_status, active_run_ids,
needy):
* run_status: RUNNING if any run is in progress, else QUEUED if any is queued/pending,
else NONE;
* active_run_ids: databaseIds of the non-completed (running/queued) runs;
* needy: True iff the SHA has NO active run AND NO run with a final VERDICT — i.e. its
required checks are absent/stale (a fresh SHA after a GITHUB_TOKEN auto-update) or
only cancelled/infra-aborted, so the PR cannot merge until CI is (re-)triggered on
that SHA. A success/failure/timeout verdict is NOT needy (green, or the author's to
fix — never auto-retried)."""
status = NONE
active_ids: list[int] = []
has_verdict = False
for r in runs:
raw = (r.get("status") or "").lower()
if raw == "completed":
if (r.get("conclusion") or "").lower() in VERDICT_CONCLUSIONS:
has_verdict = True
continue
norm = _normalise_status(raw) # in_progress -> running; else queued
if _STATUS_RANK[norm] < _STATUS_RANK[status]:
status = norm
rid = r.get("databaseId")
if rid is not None:
active_ids.append(int(rid))
needy = not active_ids and not has_verdict
return status, tuple(active_ids), needy
def _trigger_order_key(pr: PullRequest) -> tuple[int, str, int]:
"""Trigger ordering: highest priority first (lowest effective-priority number), then
OLDEST createdAt first (the longest-waiting PR at a level goes first — the same-level
fairness / anti-starvation rule), then PR number as a stable final tiebreak."""
return (effective_priority(pr), pr.created_at or _FAR_FUTURE, pr.number)
@dataclass(frozen=True)
class TriggerDecision:
"""PURE output of `select_triggers`: which PR(s) the scheduler should (re-)trigger CI
for right now, in order, plus any strictly-lower runs a P0 emergency preempts to free a
runner. Exercised by `--mode trigger --dry-run` and the unit tests."""
trigger_numbers: tuple[int, ...] = ()
cancel_run_ids: tuple[int, ...] = ()
inflight_numbers: tuple[int, ...] = ()
needy_numbers: tuple[int, ...] = ()
skipped_fork_numbers: tuple[int, ...] = ()
slots: int = 0
max_inflight: int = 0
def select_triggers(
all_prs: list[PullRequest],
needy: "set[int] | frozenset[int]",
*,
max_inflight: int,
forks: "set[int] | frozenset[int]" = frozenset(),
) -> TriggerDecision:
"""PURE. Choose the PR(s) to (re-)trigger CI for now — a poor-man's merge queue over the
existing priority model. No network / clock / subprocess, so it is exhaustively unit-
tested (see TestSelectTriggers / TestTriggerStarvation).
* inflight = PRs already running/queued on their head SHA — they occupy the cap.
* candidates = NEEDY PRs (absent/stale checks on their head SHA) that are not already
running and are not forks (forks have no token/secret access — see `run_trigger`).
* order = effective priority, then oldest createdAt, then number (`_trigger_order_key`).
* P0 = EMERGENCY: always triggered, BYPASSING the cap, and it PREEMPTS its strictly-lower
OTHER runs (reusing `runs_to_cancel`) so a runner frees for it immediately.
* P1–P10 fill only the remaining ``slots = max_inflight - len(inflight)``; the rest wait
for a later pass.
STARVATION is bounded, not by aging but structurally: triggering a PR gives its head SHA
a run, so it LEAVES the needy set; between merges the needy set only shrinks, and the
scheduler re-runs on every auto-update plus a cron backstop, so every eligible PR is
triggered within a bounded number of passes (proved by TestTriggerStarvation). Ordering
is still by priority, so higher-priority PRs are simply served first, never exclusively
forever (a served PR stops being needy until its next push/auto-update)."""
cap = max(1, max_inflight)
inflight = [p for p in all_prs if p.run_status in ACTIVE]
candidates = [
p for p in all_prs
if p.number in needy and p.run_status not in ACTIVE and p.number not in forks
]
ordered = sorted(candidates, key=_trigger_order_key)
emergencies = [p for p in ordered if effective_priority(p) == TOP_PRIORITY]
normal = [p for p in ordered if effective_priority(p) != TOP_PRIORITY]
slots = max(0, cap - len(inflight))
chosen = emergencies + normal[:slots] # P0 bypasses the cap; P1–P10 fill free slots
cancel_ids: list[int] = []
seen: set[int] = set()
for emergency in emergencies: # P0 preempts its strictly-lower active runs
for rid in runs_to_cancel(emergency, all_prs):
if rid not in seen:
seen.add(rid)
cancel_ids.append(rid)
return TriggerDecision(
trigger_numbers=tuple(p.number for p in chosen),
cancel_run_ids=tuple(cancel_ids),
inflight_numbers=tuple(sorted(p.number for p in inflight)),
needy_numbers=tuple(p.number for p in ordered),
skipped_fork_numbers=tuple(sorted(n for n in needy if n in forks)),
slots=slots,
max_inflight=cap,
)
# ── gh I/O shell (the only part that touches the network) ────────────────────
def _log(msg: str) -> None:
print(msg, flush=True)
def _gh_json(args: list[str]) -> list | dict | None:
"""Run `gh <args> --json ...` and parse stdout as JSON. Returns None (never
raises) on any failure — the caller fails open."""
try:
proc = subprocess.run(
["gh", *args],
capture_output=True,
text=True,
check=False,
)
except (OSError, ValueError) as exc:
_log(f"::warning::gh invocation failed ({' '.join(args[:2])}): {exc}")
return None
if proc.returncode != 0:
_log(f"::warning::gh exited {proc.returncode} ({' '.join(args[:2])}): "
f"{proc.stderr.strip()}")
return None
try:
return json.loads(proc.stdout or "null")
except json.JSONDecodeError as exc:
_log(f"::warning::could not parse gh JSON ({' '.join(args[:2])}): {exc}")
return None
def _normalise_status(raw: str) -> str:
"""Map a gh run status onto our RUNNING / QUEUED / NONE model."""
if raw == "in_progress":
return RUNNING
if raw == "completed":
return NONE
return QUEUED # queued / waiting / requested / pending
def _runs_by_head(limit: int = 300) -> dict[str, dict]:
"""One bulk `gh run list` -> {headBranch: {"status", "ids"}} for active PR-event
runs. Enforces the 'never cancel main/push' invariant at the source: only
`event == pull_request`, non-`main`, non-completed runs are kept. Active runs are
the most recent, so `limit` most-recent runs comfortably covers them."""
rows = _gh_json([
"run", "list", "--workflow", "ci.yml", "--event", "pull_request",
"--limit", str(limit),
"--json", "databaseId,status,headBranch,event",
])
by_head: dict[str, dict] = {}
for row in rows or []:
if row.get("event") != "pull_request":
continue
head = row.get("headBranch")
if not head or head == "main":
continue
if row.get("status") == "completed":
continue
entry = by_head.setdefault(head, {"status": NONE, "ids": []})
entry["ids"].append(int(row["databaseId"]))
status = _normalise_status(row.get("status", ""))
# running beats queued beats none for the branch's aggregate status.
if _STATUS_RANK[status] < _STATUS_RANK[entry["status"]]:
entry["status"] = status
return by_head
def gather_snapshot(self_pr_number: int) -> tuple[PullRequest | None, list[PullRequest]]:
"""Build (this_pr, all_prs) from live gh data. this_pr is forced to RUNNING —
by definition our own run is in progress while this job executes."""
prs = _gh_json([
"pr", "list", "--state", "open", "--limit", "300",
"--json", "number,headRefName,labels,isDraft,createdAt",
])
if prs is None:
return None, []
runs = _runs_by_head()
all_prs: list[PullRequest] = []
this_pr: PullRequest | None = None
for obj in prs:
head = obj.get("headRefName") or ""
run_info = runs.get(head, {"status": NONE, "ids": []})
number = int(obj["number"])
is_self = number == self_pr_number
pr = PullRequest.from_json({
**obj,
# self is definitionally running (this job is in progress).
"runStatus": RUNNING if is_self else run_info["status"],
"runIds": run_info["ids"],
})
all_prs.append(pr)
if is_self:
this_pr = pr
return this_pr, all_prs
def _cancel_run(run_id: int) -> bool:
try:
proc = subprocess.run(
["gh", "run", "cancel", str(run_id)],
capture_output=True, text=True, check=False,
)
except (OSError, ValueError) as exc:
_log(f"::warning::could not cancel run {run_id}: {exc}")
return False
if proc.returncode == 0:
return True
_log(f"::warning::could not cancel run {run_id} — likely already finished. "
f"{proc.stderr.strip()}")
return False
def _positive_int(env_name: str, default: int) -> int:
raw = os.environ.get(env_name, "")
return int(raw) if raw.isdigit() and int(raw) > 0 else default
def run_live() -> int:
"""The gh-driven shell: gather, PASS 1 (cancel), PASS 2 (bounded hold-back). Always
returns 0 — the traffic-controller must never fail CI."""
event = os.environ.get("GITHUB_EVENT_NAME", "")
self_raw = os.environ.get("SELF_PR", "")
if event != "pull_request" or not self_raw.isdigit():
_log("Not a pull_request event (or no PR number) — nothing to do.")
return 0
self_number = int(self_raw)
self_run_id = int(os.environ["GITHUB_RUN_ID"]) if os.environ.get(
"GITHUB_RUN_ID", "").isdigit() else None
this_pr, all_prs = gather_snapshot(self_number)
if this_pr is None:
_log("::warning::Could not resolve THIS PR from the open-PR list — skipping.")
return 0
self_prio = effective_priority(this_pr)
_log(f"This PR #{self_number} effective priority: {priority_label(self_prio)} "
"(P0 = highest/emergency, P9 = lowest, broken/draft = bottom).")
# ── PASS 1: PREEMPTION (P0 reclaims all lower; anyone reclaims broken/draft) ──
to_cancel = runs_to_cancel(this_pr, all_prs, self_run_id=self_run_id)
if not to_cancel:
if self_prio == TOP_PRIORITY:
_log("P0 emergency — no strictly-lower active runs to cancel.")
else:
_log("No preemptible runs (P1-P9 only reclaim broken/draft lower runs; "
"none active).")
else:
cancelled = 0
for rid in to_cancel:
if _cancel_run(rid):
_log(f" cancelled run {rid} (freed its runner).")
cancelled += 1
_log(f"P0 preemption complete — cancelled {cancelled}/{len(to_cancel)} run(s).")
# ── PASS 2: BOUNDED HOLD-BACK (yield to higher / same-level-ahead) ────────
if self_prio == TOP_PRIORITY:
_log("P0 emergency — not yielding; proceeding immediately.")
return 0
budget = _positive_int("HOLD_BACK_BUDGET_SECONDS", 180)
poll = _positive_int("HOLD_BACK_POLL_SECONDS", 15)
deadline = time.monotonic() + budget
_log(f"{priority_label(self_prio)} — holding back up to {budget}s for higher / "
"earlier same-level PRs (no cancellation).")
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
_log("Hold-back budget elapsed — proceeding; higher-priority PRs got their "
"head start.")
break
# Refresh OTHER PRs so newly-opened higher-priority PRs are seen mid-wait;
# THIS PR's own identity/priority stays fixed (matching the original).
_, fresh = gather_snapshot(self_number)
if not fresh:
_log("::warning::Could not refresh open PRs — proceeding.")
break
blockers = wait_blockers(this_pr, fresh)
if not blockers:
_log("No higher-priority or earlier same-level PR is ahead — proceeding.")
break
tags = " ".join(f"#{b.number}(P{b.priority},{b.kind})" for b in blockers)
sleep_s = min(poll, int(remaining)) if remaining >= 1 else 0
_log(f"Yielding to: {tags} — re-checking in {sleep_s}s "
f"({int(remaining)}s budget left).")
if sleep_s > 0:
time.sleep(sleep_s)
_log("Hold-back complete — this PR's heavy jobs may now start.")
return 0
# ── --dry-run: feed the pure core a snapshot JSON, print its decisions ───────
def _load_snapshot(text: str) -> tuple[PullRequest, list[PullRequest], int | None]:
data = json.loads(text)
all_prs = [PullRequest.from_json(o) for o in data.get("prs", [])]
self_number = int(data["self"])
self_run_id = data.get("self_run_id")
self_run_id = int(self_run_id) if self_run_id is not None else None
this_pr = next((p for p in all_prs if p.number == self_number), None)
if this_pr is None:
raise ValueError(f"self #{self_number} not present in prs[]")
return this_pr, all_prs, self_run_id
def run_dry(text: str) -> int:
this_pr, all_prs, self_run_id = _load_snapshot(text)
dec = decide(this_pr, all_prs, self_run_id=self_run_id)
_log(f"This PR #{dec.self_number} effective priority: "
f"{priority_label(dec.self_priority)}")
if dec.cancel_run_ids:
why = ("P0 emergency (reclaims all strictly-lower)"
if dec.self_priority == TOP_PRIORITY
else "reclaiming broken/draft lower runs")
_log(f"PASS 1 (preemption): {why} — cancel run ids: "
f"{list(dec.cancel_run_ids)}")
else:
_log("PASS 1 (preemption): nothing to cancel.")
if dec.proceed:
_log("PASS 2 (hold-back): PROCEED — no blockers.")
else:
tags = ", ".join(f"#{b.number}(P{b.priority}, {b.kind})" for b in dec.blockers)
_log(f"PASS 2 (hold-back): WAIT — yielding to: {tags}")
return 0
# ── --mode trigger: the PAT-free scheduler shell (issue #349) ────────────────
def _gh_ok(args: list[str]) -> bool:
"""Run `gh <args>` for its side effect (no JSON parse). Returns True on exit 0; never
raises — the scheduler fails open on any I/O error."""
try:
proc = subprocess.run(["gh", *args], capture_output=True, text=True, check=False)
except (OSError, ValueError) as exc:
_log(f"::warning::gh invocation failed ({' '.join(args[:2])}): {exc}")
return False
if proc.returncode != 0:
_log(f"::warning::gh exited {proc.returncode} ({' '.join(args[:3])}): "
f"{proc.stderr.strip()}")
return False
return True
def _ci_workflow_file() -> str:
return os.environ.get("CI_WORKFLOW_FILE", "ci.yml")
def gather_trigger_snapshot() -> tuple[list[PullRequest], set[int], set[int], dict[int, dict]]:
"""Build (all_prs, needy, forks, meta) from live gh data for the trigger scheduler.
* all_prs: PullRequest snapshots whose run_status / run_ids reflect the runs on each
PR's CURRENT head SHA (so 'inflight' means a live run on the mergeable SHA, never a
stale one on a superseded SHA);
* needy: PR numbers whose head SHA has absent/stale checks (must be (re-)triggered);
* forks: cross-repository PR numbers — no token/secret access, so NOT token-triggerable;
* meta: number -> {headRefName, headRefOid} for the dispatch I/O.
Returns empty structures (never raises) if gh can't be reached — the caller fails open."""
prs = _gh_json([
"pr", "list", "--state", "open", "--limit", "300",
"--json", "number,headRefName,headRefOid,isCrossRepository,labels,isDraft,createdAt",
])
if prs is None:
return [], set(), set(), {}
runs = _gh_json([
"run", "list", "--workflow", _ci_workflow_file(), "--limit", "300",
"--json", "databaseId,status,conclusion,headSha,headBranch,event",
]) or []
by_sha: dict[str, list[dict]] = {}
for row in runs:
sha = row.get("headSha")
if sha:
by_sha.setdefault(sha, []).append(row)
all_prs: list[PullRequest] = []
needy: set[int] = set()
forks: set[int] = set()
meta: dict[int, dict] = {}
for obj in prs:
number = int(obj["number"])
sha = obj.get("headRefOid") or ""
status, run_ids, is_needy = classify_sha_runs(by_sha.get(sha, []))
all_prs.append(PullRequest.from_json(
{**obj, "runStatus": status, "runIds": list(run_ids)}))
meta[number] = {
"headRefName": obj.get("headRefName") or "",
"headRefOid": sha,
}
if obj.get("isCrossRepository"):
forks.add(number)
if is_needy:
needy.add(number)
return all_prs, needy, forks, meta
def _dispatch_ci(pr_number: int, head_ref: str, head_sha: str) -> bool:
"""Trigger `ci.yml` for one PR via a `workflow_dispatch` on the PR's head branch. The
dispatch runs as GH_TOKEN, which ci-trigger.yml sets to the AUTOUPDATE_TOKEN PAT: a run
triggered by the built-in GITHUB_TOKEN is held for MANUAL approval (`action_required`) and
never runs un-attended, so the PAT (authorized owner) is what actually starts the run with no
approval gate (see issue #351). Running on the head branch puts the run's checks on the PR
head SHA, so they satisfy branch protection's required checks."""
if not head_ref:
_log(f"::warning::PR #{pr_number} has no head branch — cannot dispatch; skipping.")
return False
ok = _gh_ok([
"workflow", "run", _ci_workflow_file(), "--ref", head_ref,
"-f", f"pr={pr_number}",
"-f", f"head_sha={head_sha}",
"-f", "reason=traffic-controller",
])
if ok:
short = head_sha[:8] if head_sha else "?"
_log(f" triggered CI for #{pr_number} on {head_ref} (head {short}).")
return ok
def run_trigger() -> int:
"""The scheduler shell (companion `ci-trigger.yml`): pick the highest-priority needy
PR(s) within the inflight cap and (re-)trigger their CI via workflow_dispatch; a P0
emergency additionally preempts its strictly-lower runs. ALWAYS returns 0 — the scheduler
must never wedge CI, and structurally it cannot: `ci.yml` keeps `on: pull_request`, so any
human push (and a brand-new PR) still gets CI independently of this scheduler."""
max_inflight = _positive_int("MAX_INFLIGHT_RUNS", 2)
all_prs, needy, forks, meta = gather_trigger_snapshot()
if not all_prs:
_log("No open PRs (or could not list them) — nothing to trigger.")
return 0
dec = select_triggers(all_prs, needy, max_inflight=max_inflight, forks=forks)
_log(f"Open PRs: {len(all_prs)} | needy (absent/stale checks): {list(dec.needy_numbers)} "
f"| inflight: {list(dec.inflight_numbers)} | cap {dec.max_inflight}, "
f"free slots {dec.slots}.")
if dec.skipped_fork_numbers:
_log(f"Fork PR(s) needing CI left to `on: pull_request` (no token access — not "
f"wedged): {list(dec.skipped_fork_numbers)}.")
for rid in dec.cancel_run_ids: # P0 emergency preemption
if _cancel_run(rid):
_log(f" P0 preemption: cancelled lower run {rid} (freed its runner).")
if not dec.trigger_numbers:
_log("Nothing to trigger this pass (no needy PR fits a free slot).")
return 0
triggered = 0
for number in dec.trigger_numbers:
info = meta.get(number, {})
if _dispatch_ci(number, info.get("headRefName", ""), info.get("headRefOid", "")):
triggered += 1
_log(f"Trigger pass complete — dispatched {triggered}/{len(dec.trigger_numbers)} "
"run(s) in priority order.")
return 0
def _load_trigger_snapshot(
text: str,
) -> tuple[list[PullRequest], set[int], set[int], int]:
data = json.loads(text)
all_prs = [PullRequest.from_json(o) for o in data.get("prs", [])]
needy = {int(n) for n in data.get("needy", [])}
forks = {int(n) for n in data.get("forks", [])}
max_inflight = int(data.get("max_inflight", 2))
return all_prs, needy, forks, max_inflight
def run_trigger_dry(text: str) -> int:
all_prs, needy, forks, max_inflight = _load_trigger_snapshot(text)
dec = select_triggers(all_prs, needy, max_inflight=max_inflight, forks=forks)
_log(f"Trigger decision (cap {dec.max_inflight}, free slots {dec.slots}):")
_log(f" inflight (occupying the cap): {list(dec.inflight_numbers)}")
_log(f" needy candidates (priority order): {list(dec.needy_numbers)}")
if dec.skipped_fork_numbers:
_log(f" skipped forks (no token access): {list(dec.skipped_fork_numbers)}")
if dec.cancel_run_ids:
_log(f" P0 preemption — cancel run ids: {list(dec.cancel_run_ids)}")
_log(f" => TRIGGER (in priority order): {list(dec.trigger_numbers)}")
return 0
def main(argv: list[str] | None = None) -> int:
# Emit UTF-8 regardless of the host console so the log typography is stable on
# the UTF-8 CI runners (and never raises on a legacy Windows code page).
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--mode", choices=("orchestrate", "trigger"), default="orchestrate",
help="orchestrate (default): in-run runner-priority for the executing PR "
"(unchanged). trigger: the scheduler that (re-)triggers CI by priority "
"(issue #349).")
parser.add_argument(
"--dry-run", action="store_true",
help="read a snapshot JSON (from --input or stdin), print decisions, no network.")
parser.add_argument(
"--input", help="snapshot JSON file for --dry-run (default: stdin).")
args = parser.parse_args(argv)
if args.dry_run:
text = (open(args.input, encoding="utf-8").read() if args.input
else sys.stdin.read())
return run_trigger_dry(text) if args.mode == "trigger" else run_dry(text)
return run_trigger() if args.mode == "trigger" else run_live()
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # never let the controller fail CI
_log(f"::warning::traffic_control crashed, proceeding fail-open: {exc}")
sys.exit(0)