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.yaml emits ✅ ubuntu-26.04 / py3.11 while the release engine emits workflow / ✅ 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 conclusion is success while a continue-on-error probe inside it crashed, and its status still reads queued while a dozen of its jobs have already finished. Neither answers “is anything broken”.

  • A run that failed around its jobs. A failure conclusion with no failed job is a workflow-level error: an invalid strategy.matrix expression, 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 bundled tests.yaml does 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 to latest_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-ship skill 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-status table, filled by RunStatus.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 --json fields a RunStatus is built from.

class repomatic.github.ci_status.JobStatus(name, status, conclusion, job_id=0)[source]

Bases: object

One job of one workflow run.

name: str

The job’s name, glyph included.

status: str

queued, in_progress or completed.

conclusion: str

success, failure, cancelled, skipped, or empty while running.

job_id: int = 0

The job’s numeric ID, for its log endpoint. 0 when the listing had none.

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.yaml leads 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.

property failed: bool

Whether this job reached a failing conclusion.

property running: bool

Whether this job has yet to reach a terminal state.

property skipped: bool

Whether this job never ran: its if:, or a job it needs, ruled it out.

property cancelled: bool

Whether this job was stopped before it finished.

class repomatic.github.ci_status.RunStatus(workflow, run_id, head_sha, status, conclusion, jobs=())[source]

Bases: object

The latest run of one workflow on one branch.

workflow: str

Workflow name, as GitHub reports it.

run_id: int

Numeric run ID, for gh run view.

head_sha: str

Commit the run was created for.

status: str

The run’s own status. Lags its jobs, so it never gates anything here.

conclusion: str

The run’s own conclusion, empty while it is still going.

jobs: tuple[JobStatus, ...] = ()

Every job of the run.

property failed_required: tuple[JobStatus, ...]

Failing jobs that gate a merge.

property failed_probes: tuple[JobStatus, ...]

Failing jobs allowed to fail.

property running_jobs: tuple[JobStatus, ...]

Jobs that have not settled yet.

property unexpanded_jobs: tuple[JobStatus, ...]

Matrix jobs the run never expanded into cells.

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 blocking: bool

Whether this run holds up a merge.

property cancelled_required: tuple[JobStatus, ...]

Required jobs stopped before they finished.

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 a skipped run.

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.

property verdict: str

One-phrase outcome, for the table’s last column.

cells()[source]

The row of the ci-status table, in CI_STATUS_HEADER_DEFS order.

Return type:

tuple[str, str, str, str]

findings()[source]

What the reader must know about this run, one line each.

An error gates a merge: a workflow-level failure or a failed required job. A warning does not: a failed probe, or a run with no result for its commit.

Yields:

(level, message) pairs, errors first.

class repomatic.github.ci_status.CIStatus(branch, tip_sha='', runs=<factory>, skipped_on_tip=<factory>)[source]

Bases: object

Every monitored workflow’s latest run on a branch.

branch: str

Branch the runs were read from.

tip_sha: str = ''

Commit at the tip of the branch when the runs were read.

Empty when the branch history could not be read.

runs: list[RunStatus]

One entry per workflow that has a run, newest first.

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.

property blocking: list[RunStatus]

Runs holding up a merge.

property runs_on_tip: list[RunStatus]

Runs created for the tip commit itself.

Empty right after a push, while GitHub has yet to create the tip’s runs: every run listed then belongs to an earlier commit.

property settled: bool

Whether every run reached a terminal state.

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.

Parameters:

workflow_dir (Path) – Directory holding the workflow files.

Return type:

tuple[str, ...]

Returns:

Workflow filenames, sorted.

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.

Parameters:

workflow_dir (Path) – Directory holding the workflow files.

Return type:

list[str]

Returns:

Workflow filenames, sorted.

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 a created filter to keep a stale snapshot out. read_ci_status() asks it only about a workflow with no run on the newest commits.

Parameters:
  • workflow (str) – Workflow filename, like tests.yaml.

  • branch (str) – Branch to read runs from.

Return type:

RunStatus | None

Returns:

The run, or None when 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 to latest_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 created filter for the same reason.

Parameters:
  • workflows (Iterable[str]) – Workflow filenames to read.

  • branch (str) – Branch to read runs from.

Return type:

CIStatus

Returns:

The collected status.

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:
  • status (CIStatus) – The runs to read the failed jobs from.

  • directory (Path) – Where the files go, created when missing.

  • fetch (Callable[[int], str] | None) – Reads one job’s log by ID. Defaults to job_log(), resolved at call time so a test can stand in for it.

Return type:

list[Path]

Returns:

The files written, in report order.

class repomatic.github.ci_status.WatchState(path=None, seen=<factory>)[source]

Bases: object

The 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.

None keeps them in memory, for the span of one watch.

seen: dict[str, str]

Verdict by workflow@commit.

classmethod load(path)[source]

Read the verdicts path holds, or start empty.

A missing or unreadable file starts empty too: the first poll then reports every run, which is the baseline a fresh watch wants.

Return type:

WatchState

record(status)[source]

Note the verdict of each run, and write the file when one is new.

Parameters:

status (CIStatus) – The runs of one poll.

Return type:

list[RunStatus]

Returns:

The runs not seen before, or seen with another verdict.

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:
  • workflows (Iterable[str]) – Workflow filenames to read.

  • 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, 0 for no limit.

  • sleep (Callable[[float], None] | None) – Waits between two polls. Defaults to time.sleep(), resolved at call time so a test can stand in for it.

  • clock (Callable[[], float] | None) – Reads a monotonic clock, for timeout. Defaults to time.monotonic(), resolved the same way.

Yields:

The status of each poll, and the runs whose verdict changed.