repomatic.github.job_timings module

Measure how long each runner image actually takes, from finished runs.

Runner selection rests on measurement, and the measurement that matters is the whole job. A benchmark that times only the tool pass misses checkout and install, which is where most of the difference between two images sits. The jobs API reports startedAt and completedAt, so a duration read from it is whole-job by construction, and that mistake cannot be made here.

Note

Why the job name, and not a second API call

Attributing a duration to an image needs no extra request, because the matrix job names already carry it (⁉️ ubuntu-26.04 / py3.15-dev). Matching them against KNOWN_RUNNERS is the same technique literal_runners() uses on runs-on: values, and it degrades honestly: a job whose name carries no known image is reported under UNATTRIBUTED rather than guessed at.

Caution

These numbers are a sample of a shared, noisy fleet, not a benchmark. A cold image, a queue stall or a flaky network inflates a single cell, which is why the report is a median across several runs rather than a mean of one. Read a gap of a few percent as noise and act only on the systematic ones.

repomatic.github.job_timings.UNATTRIBUTED = '(no image in job name)'

Bucket for a job whose name matches no known runner image.

Non-matrix jobs land here by design: they carry no image in their name because they never had a choice of one. Keeping them visible rather than dropping them is what stops the report reading as though it covered the whole workflow.

repomatic.github.job_timings.JOB_TIMINGS_HEADER_DEFS: tuple[ColumnSpec, ...] = (ColumnSpec(id='runner', label='Runner', description='', max_width=None, optional=False, sortable=True), ColumnSpec(id='jobs', label='Jobs', description='', max_width=None, optional=False, sortable=True), ColumnSpec(id='median', label='Median', description='', max_width=None, optional=False, sortable=True), ColumnSpec(id='slowest-job', label='Slowest job', description='', max_width=None, optional=False, sortable=True), ColumnSpec(id='slowest', label='Slowest', description='', max_width=None, optional=False, sortable=True))

Column definitions for the job-timings table.

class repomatic.github.job_timings.JobTiming(name, runner, seconds)[source]

Bases: object

One finished job, and how long it occupied a runner.

name: str

The job’s name, glyph and matrix cell included.

runner: str

Runner image matched out of name, or UNATTRIBUTED.

seconds: float

Whole-job wall-clock: the completedAt minus startedAt delta.

Deliberately not the compute time. This is what the run costs in billed minutes and in wall-clock waiting.

repomatic.github.job_timings.match_runner(job_name)[source]

Attribute a job to the runner image named in it.

Parameters:

job_name (str) – Job name as GitHub reports it.

Return type:

str

Returns:

The matched image, or UNATTRIBUTED.

repomatic.github.job_timings.fetch_job_timings(workflow, branch='main', limit=5)[source]

Read finished job durations from the most recent successful runs.

Only successful runs are sampled. A failed run’s jobs stop early, so their durations measure where the failure landed rather than what the image costs, and a cancelled matrix reports whatever fraction ran before the cancellation swept it. The runs come from workflow_runs(), whose created filter keeps a stale snapshot of old runs out of the sample.

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

  • branch (str) – Branch whose runs to sample.

  • limit (int) – How many successful runs to sample. A median over several is what smooths a queue stall into noise.

Return type:

list[JobTiming]

Returns:

One JobTiming per finished job across the sampled runs.

class repomatic.github.job_timings.RunnerReport(runner, job_count, median_seconds, slowest_job, slowest_seconds)[source]

Bases: object

Aggregated timings for one runner image.

runner: str

The runner image, or UNATTRIBUTED.

job_count: int

How many finished jobs the image ran across the sample.

median_seconds: float

Median whole-job wall-clock of those jobs.

slowest_job: str

Name of the slowest of those jobs.

slowest_seconds: float

Whole-job wall-clock of the slowest job.

cells()[source]

The row of the report, in JOB_TIMINGS_HEADER_DEFS order.

Return type:

tuple[str, str, str, str, str]

repomatic.github.job_timings.summarize(timings)[source]

Aggregate per-job timings into one row per runner image.

Sorted slowest-median first: the question this answers is which image is holding the matrix up, and that one belongs at the top rather than alphabetically buried.

Parameters:

timings (Iterable[JobTiming]) – Job timings, typically from fetch_job_timings().

Return type:

list[RunnerReport]

Returns:

One RunnerReport per image seen.

repomatic.github.job_timings.format_duration(seconds)[source]

Render a duration zero-padded to a fixed width.

The padding is not cosmetic. --sort-by orders the rendered table lexicographically, so an unpadded 21s sorts between 1m37s and 2m03s and the default view reads as though a 21-second job were slower than a 97-second one. Fixed-width 00m21s makes the string order the chronological one, for every column and both directions, without the table layer needing to know these cells are durations.

Return type:

str

repomatic.github.job_timings.render_markdown(reports, workflow, runs)[source]

Render a report as a Markdown table, for pasting into documentation.

Emitted on request rather than written by a sync job. Timings move on every run, so a job regenerating a checked-in table would open a pull request forever and never converge: this is a measurement to take when a decision needs one, not a file to keep in sync.

Parameters:
Return type:

str

Returns:

A Markdown table, newline-terminated.