repomatic.github.ci_status module¶
Which CI jobs are red, and which of those actually gate a merge.
repomatic names the jobs it generates, prefixing each matrix cell with a
glyph that records whether the cell is allowed to fail: ✅ for a required
one, ⁉️ for a probe running under continue-on-error. Reading that back
was left to whoever was watching CI, and it is easy to get wrong in four
specific ways this module exists to settle:
The glyph, not the position. Job names differ in shape across workflows:
tests.yamlemits✅ ubuntu-26.04 / py3.11while the release engine emitsworkflow / ✅ ubuntu-26.04, abc1234 build. Anything that splits on" / "and reads a fixed field strips the glyph off one of the two and files a required red as a probe, which reads as green.Jobs, not the run. A run’s own
conclusionissuccesswhile acontinue-on-errorprobe inside it crashed, and itsstatusstill readsqueuedwhile a dozen of its jobs have already finished. Neither answers “is anything broken”.A run that failed around its jobs. A
failureconclusion with no failed job is a workflow-level error: an invalidstrategy.matrixexpression, malformed YAML, a missing secret. There is no job log to read, and treating it as benign is how a persistently red workflow gets written off as a known artifact.A run that settled without a result. A newer push cancels the run of the commit under it, and a job-level
if:skips every job of a run: the bundledtests.yamldoes that for a version-bump commit. Neither run has a failed job, and neither says anything about its commit. Reading one as green hides that the matrix never ran on the newest commits.
A job carrying no stability glyph is required. That covers every non-matrix job
(1️⃣ Run-once tests, 📦 Package install, 🛡️ Lint types), where the absence
of a marker means the job was never optional rather than that its status is
unknown. Only the two stability glyphs count: a job name may carry any other
emoji and still be required, which is why the test is for ⁉️ specifically
rather than for a decorated name. A name that holds both glyphs is the one
exception: see JobStatus.required().
- repomatic.github.ci_status.TIP_HISTORY_DEPTH = 10¶
Commits
read_ci_status()walks back from the branch tip, tip included.A push starts most workflows on the tip itself. A workflow whose
paths:filter skipped the tip last ran on an earlier commit, which a walk this deep reaches on any active branch. A workflow with no run in the window falls back tolatest_run().
- repomatic.github.ci_status.STABLE_GLYPH = '✅'¶
Marks a matrix cell that must pass. See
UNSTABLE_GLYPH.
- repomatic.github.ci_status.UNSTABLE_GLYPH = '⁉️'¶
Marks a matrix cell running under
continue-on-error.A red one never gates a merge, which is exactly why it has to be told apart from a required cell rather than counted with it. A release still fixes what it can: see the
repomatic-shipskill on the genuinely-green goal.
- repomatic.github.ci_status.TERMINAL_STATUSES = frozenset({'completed'})¶
Job statuses meaning the job will not change again.
- repomatic.github.ci_status.CI_STATUS_HEADER_DEFS: tuple[ColumnSpec, ...] = (ColumnSpec(id='workflow', label='Workflow', description='', max_width=None, optional=False, sortable=True), ColumnSpec(id='commit', label='Commit', description='', max_width=None, optional=False, sortable=True), ColumnSpec(id='run-status', label='Run status', description='', max_width=None, optional=False, sortable=True), ColumnSpec(id='verdict', label='Verdict', description='', max_width=None, optional=False, sortable=True))¶
Column definitions for the
ci-statustable, filled byRunStatus.cells().
- repomatic.github.ci_status.LOG_NAME_RE = re.compile('[^\\w.-]+')¶
What a log filename drops from a workflow or job name: spaces, glyphs, slashes.
- repomatic.github.ci_status.RUN_FIELDS = ('conclusion', 'headSha', 'jobs', 'status', 'workflowName')¶
The
gh run view --jsonfields aRunStatusis built from.
- class repomatic.github.ci_status.JobStatus(name, status, conclusion, job_id=0)[source]¶
Bases:
objectOne job of one workflow run.
- property unexpanded: bool¶
Whether the job is a matrix GitHub never evaluated.
An expanded cell carries its values in its name. One that still holds its
name:expression,${{and all, is a stage the run never reached: no cell of it ran, whatever the run’s own conclusion says.
- property required: bool¶
Whether a failure here gates a merge.
Looks for the glyph anywhere in the raw name rather than at its start. The two shapes disagree on where it sits:
tests.yamlleads with it (⁉️ ubuntu-26.04 / py3.15-dev) while the release engine prefixes the workflow first (release / ⁉️ windows-11-arm, abc1234 build). A leading-position test passes the first and silently files the second as required; splitting on" / "gets it wrong the other way round. Containment is the one form both satisfy, and the templates emit exactly one glyph per expanded name.A name that holds both glyphs is a matrix GitHub never expanded: the job keeps its
name:expression, which spells the two of them. A run stopped before the matrix got its cells leaves one. Some of those cells are required, so the job is.
- class repomatic.github.ci_status.RunStatus(workflow, run_id, head_sha, status, conclusion, jobs=())[source]¶
Bases:
objectThe latest run of one workflow on one branch.
- property workflow_level_failure: bool¶
Whether the run failed around its jobs rather than inside one.
No job log explains this one: read the run’s error annotations and fix the workflow itself.
- property cancelled: bool¶
Whether the run was stopped before its required jobs all finished.
A newer push to the same concurrency group does that to the run of the commit under it. A run stopped before GitHub created any job counts too.
- property skipped: bool¶
Whether the run ran no job at all.
A job-level
if:ruled every one of them out, which GitHub concludes as askippedrun.
- property inconclusive: bool¶
Whether the run settled without a result for its commit.
Neither red nor green: this workflow says nothing about the tree the run was created for, so nothing here holds up a merge and nothing clears it.
- cells()[source]¶
The row of the
ci-statustable, inCI_STATUS_HEADER_DEFSorder.
- class repomatic.github.ci_status.CIStatus(branch, tip_sha='', runs=<factory>, skipped_on_tip=<factory>)[source]¶
Bases:
objectEvery monitored workflow’s latest run on a branch.
- tip_sha: str = ''¶
Commit at the tip of the branch when the runs were read.
Empty when the branch history could not be read.
- skipped_on_tip: list[str]¶
Workflow filenames that ran no job on the tip.
Only the ones read on an earlier commit: the row of each says nothing about the tip. A workflow with no other run in the window keeps its tip run, whose verdict says the same.
- repomatic.github.ci_status.workflow_files(workflow_dir)[source]¶
Every workflow in the directory that has runs of its own.
monitored_workflows()narrows this to the ones a push starts, which is the right default for a status report and the wrong set to accept: a schedule-only or dispatch-only workflow has runs worth reading too. A reusable workflow is excluded for the reason it is there, since its jobs only ever appear under a caller’s run.
- repomatic.github.ci_status.monitored_workflows(workflow_dir)[source]¶
Every workflow a push to the default branch can start.
Derived from the tree rather than listed by hand, so a workflow added later is watched without anyone remembering to add it here. A reusable workflow is excluded: it has no runs of its own, only the ones its callers create.
- repomatic.github.ci_status.latest_run(workflow, branch)[source]¶
Read a workflow’s most recent run on branch, jobs included.
Lists the workflow’s runs through
workflow_runs(), which sends acreatedfilter to keep a stale snapshot out.read_ci_status()asks it only about a workflow with no run on the newest commits.- Parameters:
- Return type:
- Returns:
The run, or
Nonewhen the workflow has none in the listing window. An empty listing is not proof the workflow was filtered out: GitHub can sit on a push event for hours before materializing a run.
- repomatic.github.ci_status.read_ci_status(workflows, branch)[source]¶
Read the latest run of each workflow on branch.
Anchored on the branch tip: reads the runs of each of the newest commits, tip first, and stops as soon as every workflow has one. A workflow whose
paths:filter skipped the tip is found on the commit that last started it. Only a workflow with no run in the whole window falls back tolatest_run().A run that ran no job counts as no run at all for that walk, so a workflow that skipped every job on the tip is also read on an earlier commit. A cancelled run stops the walk instead: the runs under it are older than the commits it left without a result, and a green one among them would hide that. A workflow with nothing but skipped runs in the window is reported on the newest of them.
Warning
The branch-wide run listing can answer with a stale snapshot: runs from commits months old, presented as the latest ones. Reading runs commit by commit, from a commit list that comes from git data rather than from the run index, keeps that snapshot out. The fallback listing sends a
createdfilter for the same reason.
- repomatic.github.ci_status.write_failed_logs(status, directory, *, fetch=None)[source]¶
Write the log of every failed required job of status under directory.
One file per job, named after its workflow and job, readable the moment the job settled: a failed cell is diagnosed while its slowest sibling still builds, instead of after the whole run drains. The control sequences are stripped by
job_log().- Parameters:
- Return type:
- Returns:
The files written, in report order.
- class repomatic.github.ci_status.WatchState(path=None, seen=<factory>)[source]¶
Bases:
objectThe verdicts a watch has reported, so a re-armed one repeats none.
Keyed by workflow and commit: a run moving from running to green is one change, and the run of a newer commit is another.
- path: Path | None = None¶
The JSON file holding the verdicts across invocations.
Nonekeeps them in memory, for the span of one watch.
- repomatic.github.ci_status.watch_ci_status(workflows, branch, *, state, interval, until_change=False, timeout=0, sleep=None, clock=None)[source]¶
Poll the runs of branch until something is worth acting on.
Each poll yields the status read and the runs whose verdict state had not seen. The watch ends on the first poll where a run blocks a merge, since that is the moment to act and never one to wait through, or where every run settled and the tip has a run of its own. Until then GitHub may still be creating the tip’s runs, and a settled report would read as green for a commit nothing has tested yet.
With until_change, it ends after the first poll reporting a change, so a watcher re-armed on the same state file wakes once per event. With timeout, it ends once that many seconds elapsed, whatever the state.
Every poll pays the REST quota of
read_ci_status(), a handful of calls, and interval is what keeps a long watch inside it.- Parameters:
branch (
str) – Branch to read runs from.state (
WatchState) – The verdicts already reported, updated on each poll.interval (
float) – Seconds between two polls.until_change (
bool) – Stop after the first poll that reports a change.timeout (
float) – Seconds after which the watch stops,0for no limit.sleep (
Callable[[float],None] |None) – Waits between two polls. Defaults totime.sleep(), resolved at call time so a test can stand in for it.clock (
Callable[[],float] |None) – Reads a monotonic clock, for timeout. Defaults totime.monotonic(), resolved the same way.
- Yields:
The status of each poll, and the runs whose verdict changed.
