repomatic.github.job_timings module

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

Runner selection is supposed to rest on measurement rather than on architecture folklore, and this repository has already paid for the alternative: the lean ubuntu-slim image held every mechanical job for years on a benchmark that timed only the tool pass, where it looked near-parity. Timed end to end it was 20-56% slower, because most of the difference sat in checkout and install, which that measurement could not see.

The counter-measure was a prose warning plus a two-command gh recipe and a median taken by hand. This module is that recipe, which makes the rule mechanical rather than advisory: the jobs API reports startedAt and completedAt, so a duration read from it is whole-job by construction and the mistake above is not expressible.

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[tuple[str, str], ...] = (('Runner', 'runner'), ('Jobs', 'jobs'), ('Median', 'median'), ('Slowest job', 'slowest-job'), ('Slowest', 'slowest'))

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, and it is the figure that settled the ubuntu-slim question when the tool-pass figure could not.

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.

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
job_count: int
median_seconds: float
slowest_job: str
slowest_seconds: float
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.