Test matrixยถ

repomatic builds two GitHub Actions test matrices for every project: a full matrix (pushes to the default branch and scheduled runs) and a reduced pull-request matrix (fast feedback on PRs). Both are pre-computed by the metadata job from the projectโ€™s [tool.repomatic.test-matrix.*] configuration, so a project shapes its matrix without hand-editing workflow YAML.

This page is the guide: how to decide what the matrix should test, which GitHub-hosted runners exist and how they trade off on speed, and a worked example. For the per-key configuration reference (types, defaults), see the configuration page.

How the matrix is builtยถ

The base axes are os and python-version, seeded from repomaticโ€™s defaults (TEST_RUNNERS_FULL/TEST_RUNNERS_PR and TEST_PYTHON_FULL/TEST_PYTHON_PR in repomatic/test_matrix.py). A project then reshapes the matrix through a fixed chain of transformations, each a [tool.repomatic.test-matrix.*] key, applied in this order:

  1. replace: swap axis values in place.

  2. remove: drop axis values from an axis.

  3. variations: add extra axis values (full matrix only), including brand-new axes.

  4. exclude: remove specific combinations.

  5. include: add or augment combinations. GitHub processes include after exclude. A directive that merges into at least one surviving job augments those jobs only; a directive that matches no surviving job (because it fully re-specifies an excluded combination) is appended as a new standalone job. A partial include does not resurrect excluded slices.

A separate unstable pass (full matrix only) flags matching combinations continue-on-error. Because the order is fixed, the transforms compose predictably. variations and unstable touch only the full matrix, keeping the PR matrix a small curated set. See workflows ยง Dynamic test matrices for why this exists (GitHubโ€™s static strategy.matrix cannot express it) and the configuration reference for each key.

Inspect the computed matrixยถ

To see the matrix your configuration actually produces, render it as a grid with repomatic show-test-matrix: one row per Python version, one column per runner, each cell flagging whether that job runs stable, unstable (continue-on-error), or is absent (โ€”).

$ repomatic show-test-matrix full
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Python โ”‚ ubuntu-24.04-arm โ”‚ ubuntu-slim โ”‚ macos-26    โ”‚ macos-26-intel โ”‚ windows-11-arm โ”‚ windows-2025 โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ 3.10   โ”‚ โœ… stable        โ”‚ โœ… stable   โ”‚ โœ… stable   โ”‚ โœ… stable      โ”‚ โ€”              โ”‚ โœ… stable    โ”‚
โ”‚ 3.14   โ”‚ โœ… stable        โ”‚ โœ… stable   โ”‚ โœ… stable   โ”‚ โœ… stable      โ”‚ โœ… stable      โ”‚ โœ… stable    โ”‚
โ”‚ 3.15   โ”‚ โ‰๏ธ unstable      โ”‚ โ‰๏ธ unstable โ”‚ โ‰๏ธ unstable โ”‚ โ‰๏ธ unstable    โ”‚ โ‰๏ธ unstable    โ”‚ โ‰๏ธ unstable  โ”‚
โ”‚ 3.14t  โ”‚ โœ… stable        โ”‚ โ€”           โ”‚ โ€”           โ”‚ โ€”              โ”‚ โ€”              โ”‚ โ€”            โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

With no [tool.repomatic.test-matrix.*] overrides, this is the built-in default: the six runners from the inventory below, the default Python versions, and the rows the transform chain contributes: the 3.15 prerelease flagged unstable, the free-threaded 3.14t build pinned to a single runner as a stable smoke test, and windows-11-arm dropped on 3.10.

The reduced pull-request matrix keeps one runner per OS and two Python versions, for faster feedback:

$ repomatic show-test-matrix pr
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Python โ”‚ ubuntu-24.04-arm โ”‚ macos-26  โ”‚ windows-2025 โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ 3.10   โ”‚ โœ… stable        โ”‚ โœ… stable โ”‚ โœ… stable    โ”‚
โ”‚ 3.14   โ”‚ โœ… stable        โ”‚ โœ… stable โ”‚ โœ… stable    โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

The grid honors the global --table-format option, so the same view renders as GitHub-flavored Markdown, CSV, JSON, and the rest. For the raw GitHub Actions matrix the metadata job hands to CI: the os and python-version axes plus the include/exclude directives that shape them, request the test_matrix (or test_matrix_pr) key from repomatic metadata:

$ repomatic metadata test_matrix --format json

Choosing what to testยถ

A matrix is a budget. Every cell costs runner minutes and adds to wall-clock. Spend the budget where a failure is both likely and informative; keep everything speculative cheap.

Cover the shipped configuration broadlyยถ

The combination your users actually install โ€” released dependencies on a stable Python โ€” earns the widest spread of operating systems and Python versions. This is the core of the matrix: a regression here reaches everyone, so it is worth catching on every platform.

Probe forward-looking axes narrowlyยถ

Anything not yet shipped is an early-warning signal, not a support promise: a prerelease Python, a dependencyโ€™s development branch, an unreleased build. Run each on a single runner. If it breaks you want a heads-up, not a cross-platform report, and once that version ships the broad shipped-config coverage picks it up anyway. Flag these jobs continue-on-error through test-matrix.unstable so an expected breakage does not fail the build.

Smoke-test released build flavors on one runnerยถ

A free-threaded build (the t suffix, officially supported since 3.14) is a released flavor of a version the shipped-config slice already covers on every platform: the same interpreter, just compiled without the GIL. Because it is released, it is expected to work, so it runs stable, not continue-on-error like a prerelease. But re-running the whole suite on every platform buys little: one runner catches a free-threading-specific break, and the base versionโ€™s cross-platform coverage handles the rest. So a flavor takes the narrow spread of a forward-looking probe with the stable outcome of shipped config. repomatic pins 3.14t to its fastest Linux runner by default; to add another flavor, give it a python-version variation pinned to one runner with exclude (the worked exampleโ€™s pattern) and leave it out of test-matrix.unstable.

Pin the dependency floor and any known-regression releaseยถ

When a project supports a range of a core dependency (say >= 2.3), CI by default only ever exercises whatever the lockfile resolves to, usually the newest version. The floor is declared but never verified, so it rots silently until a downstream user on an older version hits the break. Add the floor as an explicit matrix value so the bottom of the range runs on every CI pass.

Add any single mid-range release whose behavior a workaround specifically targets, too. That release is the one version where the shim is load-bearing, so it is the one version that catches the shim regressing: bracketing the range with floor and latest alone would miss it.

When the dependencyโ€™s patch releases are not reliably behavior-stable โ€” some projects re-cut a patch to fix a mid-stream regression โ€” go further and pin every release in the range, not just the floor and the one regression you happen to know about. You cannot predict which patch shifts behavior, so testing each release is the only way to bound the perimeter. The newest is covered by the moving released value; pin every earlier one. That list grows by one each time the dependency ships, so back it with a test (see Guard the matrix with a test below) that fails when the matrix falls behind.

Pin each dependency-version to one Pythonยถ

A pinned dependency-version is there to test the dependency, and a dependencyโ€™s behavior rarely turns on the Python version: its shims are version-of-the-dependency logic, not version-of-Python logic. Python compatibility is already covered broadly by the shipped-config slice (every Python on the released dependency). So run each pinned version on a single Python rather than the full set. The floor Python is the natural pick: min-dependency ร— min-Python is the realistic oldest-environment corner, and pinning to one Python keeps the dependency ร— Python product from multiplying.

For the same reason, keep pinned (old) dependency-versions off the prerelease Python. โ€œOldest supported dependency ร— a Python that is not released yetโ€ is a combination no user runs; reserve the prerelease-Python probe for the released dependency.

Pinning a value to a single cell is verbose in the exclude model. Say you carry a floor (4.2) and one regression-prone release (5.0) of acme, and want each on a single cell: the floor Python of the fastest runner. You add them as matrix values, which multiplies them across every OS and Python, then exclude every combination but the one you want, including the prerelease Python (per the rule above). With the slow-architecture twins removed (as in the worked example below) four runners and three Pythons remain, so each pinned version costs five excludes:

[tool.repomatic]
# Released acme everywhere, plus the floor and the regression release.
test-matrix.variations.acme-version = ["4.2", "5.0", "released"]
# Pin 4.2 and 5.0 each to (ubuntu-24.04-arm, 3.10) by dropping every other cell.
test-matrix.exclude = [
  { "os" = "ubuntu-slim", "acme-version" = "4.2" },
  { "os" = "macos-26", "acme-version" = "4.2" },
  { "os" = "windows-2025", "acme-version" = "4.2" },
  { "python-version" = "3.14", "acme-version" = "4.2" },
  { "python-version" = "3.15", "acme-version" = "4.2" },
  { "os" = "ubuntu-slim", "acme-version" = "5.0" },
  { "os" = "macos-26", "acme-version" = "5.0" },
  { "os" = "windows-2025", "acme-version" = "5.0" },
  { "python-version" = "3.14", "acme-version" = "5.0" },
  { "python-version" = "3.15", "acme-version" = "5.0" },
]

test-matrix.full-include states each cell directly instead, dropping the acme-version axis altogether: released becomes the default and each pin is one explicit exception that lists only what differs from the shipped configuration (unset axes inherit the defaults: released dependencies, stable state). The variation and its ten excludes become a one-line include and two rows:

[tool.repomatic]
# Released acme everywhere (the broad shipped-config slice)...
test-matrix.include = [{ "acme-version" = "released" }]
# ...plus the floor and regression release pinned to one cell each.
test-matrix.full-include = [
  { "os" = "ubuntu-24.04-arm", "python-version" = "3.10", "acme-version" = "4.2" },
  { "os" = "ubuntu-24.04-arm", "python-version" = "3.10", "acme-version" = "5.0" },
]

Both produce the same jobs: released acme across every runner and Python, plus 4.2 and 5.0 on the single floor cell. The full-include rows join the full matrix only; the PR matrix ignores them. Reach for variations plus exclude when a pinned version should instead span every Python, as in the worked example below.

Select runners by measured speed, not architectureยถ

When you reduce to one runner per OS, pick the fastest one for your workload, measured from your own CI. Do not reflexively choose the ARM image because it is โ€œthe futureโ€: architecture speed is not uniform across operating systems (see the inventory below), and the faster choice differs per platform. When you do not need to test both architectures of an OS, drop the slower twin entirely rather than carrying it.

The phrase for your workload is load-bearing. The architecture gap is wide for a parallel, compute-heavy job (a pytest --numprocesses=auto suite that scales with cores) and narrow-to-nonexistent for a job dominated by checkout and dependency install. So the right runner differs by job type, not just by project: see ยง Architecture speed is workload-dependent for the split repomatic measured between its heavy test suite and its light mechanical jobs.

For a compute-bound parallel workload, that measurement keeps landing on the same runner: ubuntu-24.04-arm is the fastest repomatic has measured and sits in the cheapest tier (GitHub bills hosted macOS at roughly 10x Linux minutes, and ARM Linux matches or beats x86 Linux on both speed and price). So when you need a single fast runner โ€” the PR Linux slot, a single-runner flavor smoke test, or a pinned dependency cell โ€” ubuntu-24.04-arm is the default pick. macos-26 is fast too, but its minute multiplier makes it a poor default; reserve it and the Windows runners for the OS coverage only they provide.

Guard the matrix with a testยถ

A test matrix is configuration, and configuration rots silently: a new dependency release, a raised floor, or a typoโ€™d runner name does not announce itself. Back the matrix with a unit test that re-derives what should be tested from the projectโ€™s own metadata and compares it to what the matrix does test, turning drift into a failing CI check instead of a bug a user reports later.

The highest-value check ties a pinned dependency axis to its declared specifier: assert that the pinned versions equal the releases the specifier allows (reading the release list from the package index), minus the newest, which the released value already covers. A freshly published release then fails the test until it is pinned; a pin that drops below a raised floor, or that gets yanked, fails until it is removed. A cheaper, network-free companion asserts the lowest pinned version equals the specifierโ€™s floor, catching a floor change that forgot the matrix even when the index is unreachable.

The same spirit covers the matrixโ€™s other invariants: its lowest Python should equal the projectโ€™s requires-python floor, and every exclude should reference a real axis value: a misspelled runner silently excludes nothing and runs the job anyway, which repomaticโ€™s lint-repo check flags as a no-op exclude.

GitHub-hosted runner inventoryยถ

repomaticโ€™s full matrix spans both architectures of each OS; the reduced PR set keeps one per OS. The runners (defined in repomatic/test_matrix.py):

Runner

OS

Architecture

In PR set

Notes

ubuntu-24.04-arm

Linux

ARM64

yes

Fastest measured on the parallel suite, cheapest tier; default single-runner pick (PR Linux slot, flavor smoke tests, pinned cells).

ubuntu-slim

Linux

x86-64

no

Lean light-job default; full test matrix only; slowest on a heavy suite.

macos-26

macOS

ARM64 (Apple silicon)

yes

Faster macOS image, fast overall, but billed at ~10x Linux minutes; use only when macOS coverage is needed.

macos-26-intel

macOS

x86-64

no

Legacy Intel; ~2x slower than macos-26.

windows-11-arm

Windows

ARM64

no

Compute ties windows-2025; full-matrix only, for native ARM64 execution coverage.

windows-2025

Windows

x86-64

yes

Compute tied with windows-11-arm; the PR-set Windows pick.

Speed tendenciesยถ

Relative speed is workload-dependent, so the only authoritative numbers are your own. The tendencies below come from repomaticโ€™s own full test suite, taken as the median across the five most recent successful runs on all six runners. Two numbers matter and can disagree: job wall-clock (the startedAt/completedAt delta, what you pay in CI minutes) and compute (just the test-execution steps, with checkout, environment setup, and coverage upload stripped out). When they diverge, a non-compute step is the cause.

  • Linux: ARM is much faster. ubuntu-24.04-arm ran the suite two to three times faster than ubuntu-slim (median 2.9x on job wall-clock, at every Python version), and the gap holds on compute alone. ubuntu-slim is a deliberately lean image (repomaticโ€™s default for light mechanical jobs, where small size and tool availability matter more than throughput), and for a heavy suite it is the slowest tier overall. Its free-threaded 3.14t cell was the single slowest in the matrix at ~250s, which is one reason that flavor now runs on the faster ARM Linux runner instead of the full spread (see ยง Smoke-test released build flavors on one runner).

  • macOS: Apple silicon beats Intel by roughly 1.8-2x (about 1.8x on job wall-clock, about 2x on compute), not a single-digit margin. macos-26 is in fact one of the fastest runners overall; macos-26-intel is the slow one. macOS as a tier does not gate the matrix: ubuntu-slim does.

  • Windows: compute is a tie. On test-execution time the two images sit within ~6% (ARM is marginally ahead on the prerelease Python). The two used to diverge on per-job wall-clock because windows-11-arm paid a systematic Codecov-upload penalty (~56s versus ~6s: the uploader is slow on ARM64 Windows); coverage now uploads only from the one-runner-per-OS set, so ARM Windows no longer uploads and that gap is gone. windows-2025 stays the PR-set Windows pick.

Caution

These figures are one projectโ€™s, and they drift. Runner images are re-provisioned, new images appear and old ones are retired, and an outlier can be a transient stall rather than a property of the image. Some gaps are systematic, though: the 2-3x ARM-versus-x86 Linux ratio above shows up in every run. Per-job wall-clock also folds in checkout, setup, and upload, so isolate the test steps before attributing a gap to the imageโ€™s compute. Treat all of this as a starting hypothesis, not a constant, and re-confirm against your own timings.

Architecture speed is workload-dependentยถ

The ratios above are the test suiteโ€™s, and they do not generalize to every job. The suite runs pytest --numprocesses=auto, so it parallelizes across cores and leans on Python startup and subprocess spawns: exactly where ARM pulls ahead. repomaticโ€™s light mechanical jobs (the linters and formatters that run on every push) behave differently, and a controlled A/B shows why.

Three Linux runners ran the real tool commands on the same commit, which separates two effects the headline โ€œ2.9xโ€ had conflated:

  • Leanness: ubuntu-slim (lean x86) versus ubuntu-24.04 (full x86).

  • Architecture: ubuntu-24.04 (full x86) versus ubuntu-24.04-arm (full ARM).

Only one mechanical job is compute-bound enough to matter: mdformat (the autofix Format Markdown job, which spawns one mdformat process per file).

Step

Runner

mdformat

baseline

ubuntu-slim (lean x86)

110s

remove leanness

ubuntu-24.04 (full x86)

97s (1.13x)

remove x86

ubuntu-24.04-arm (full ARM)

77s (1.26x)

Of the 1.43x end-to-end gain, most is architecture (1.26x) and a little is leanness (1.13x). Every other tool (ruff, mypy, gitleaks, actionlint, zizmor, typos, yamllint) finished in 1-4s on all three runners, within noise: those jobs are dominated by checkout and uv install (~15-20s), which a faster CPU barely touches, and ARM setup was if anything marginally slower. Those linters all ran on ARM Linux with no missing binaries, but mdformat is the exception that matters (see the decision below).

The decisions that follow:

  • Test PR slot uses ubuntu-24.04-arm. The heavy parallel suite genuinely runs ~2-3x faster on ARM, so PR feedback is quicker; x86 Linux stays covered in the full matrix.

  • Light mechanical jobs keep ubuntu-slim. They are setup-bound, so ARM buys ~nothing while adding an architecture variable across the whole fleet. The real lever for them is caching setup, not the CPU.

  • The one compute-heavy light job, Format Markdown, uses ubuntu-24.04 (full x86). It cannot use the lean image (it needs shfmt), and ARM runs its per-file pass ~1.26x faster โ€” but mdformat-config pulls taplo, which ships no linux-aarch64 wheel and has a broken 0.9.3 sdist, so a fresh ARM install fails to build it. It stays on full x86 until taplo ships an aarch64 wheel.

Note

The mechanical-job split is a single controlled run, where the test-suite ratios are medians of several: treat the 1.13x/1.26x decomposition as one measurement to re-confirm. And always include a full-x86 runner in an architecture A/B: comparing only the lean x86 image against full ARM credits the architecture for the imageโ€™s leanness too.

Measuring your ownยถ

Read the per-job durations from a recent full-matrix run and compare the same configuration across architectures:

$ gh run list --workflow=tests.yaml --event=push --status=success --limit=5
$ gh run view {run-id} --json jobs

Each job carries startedAt and completedAt; the difference is its wall-clock. Compare cells that differ only in os (same Python, same dependency versions) to isolate the architectureโ€™s effect, and prefer the median across a few runs to smooth out stalls.

Worked example: widening a dependencyโ€™s supported rangeยถ

Suppose a project lowers its floor on a core dependency acme from >= 5 to >= 4.2 to install in more environments. It carries small shims for APIs that changed in acme 5.0, and one of those shims works around a regression that existed only in acme 5.0 (fixed in 5.0.1). The matrix should verify the whole >= 4.2 range without ballooning, and keep the speculative jobs fast.

[tool.repomatic]
# Drop the slower-architecture runner of each OS, keeping the faster twin
# (measured here: Intel macOS and ARM Windows finish each job slower).
test-matrix.remove.os = ["macos-26-intel", "windows-11-arm"]

# Add the floor (4.2), the regression release (5.0), and the development
# branch alongside whatever the lockfile resolves to.
test-matrix.variations.acme-version = ["4.2", "5.0", "released", "main"]

# Pin the floor, the regression release, and the dev branch to the single
# fastest runner; the shipped config (released acme) keeps the full spread.
# After the remove above, the non-pinned runners are ubuntu-slim, macos-26,
# and windows-2025.
test-matrix.exclude = [
  { "os" = "ubuntu-slim", "acme-version" = "4.2" },
  { "os" = "macos-26", "acme-version" = "4.2" },
  { "os" = "windows-2025", "acme-version" = "4.2" },
  { "os" = "ubuntu-slim", "acme-version" = "5.0" },
  { "os" = "macos-26", "acme-version" = "5.0" },
  { "os" = "windows-2025", "acme-version" = "5.0" },
  { "os" = "ubuntu-slim", "acme-version" = "main" },
  { "os" = "macos-26", "acme-version" = "main" },
  { "os" = "windows-2025", "acme-version" = "main" },
]

# The unreleased acme branch is an early-warning probe: never fail the build on it.
test-matrix.unstable = [{ "acme-version" = "main" }]

On top of the built-in 3.14t flavor smoke test (stable, on ubuntu-24.04-arm with released acme), the acme config resolves to three slices:

Slice

Runs on

continue-on-error

released acme (the shipped config)

all four retained OSes ร— every base Python

no

4.2 floor and 5.0 regression

ubuntu-24.04-arm ร— every base Python

no

main acme (dev-branch early warning)

ubuntu-24.04-arm ร— every base Python

yes

The shipped configuration is exercised everywhere a regression would reach a user; the floor and the one regression-prone release are verified cheaply on the fastest runner; and the development branch gives a heads-up without the power to redden the build. The PR matrix stays the curated reduced set for fast feedback, since variations and unstable apply to the full matrix only. The same shape extends to a prerelease Python (add it as a python-version variation, pin it to one runner with exclude, and mark it unstable) or to a released free-threaded flavor (the same, but stable: leave it out of unstable, as ยง Smoke-test released build flavors on one runner explains).

repomatic.test_matrix APIยถ

Test matrix constants for CI workflows.

Defines the GitHub-hosted runner images and Python versions used to build test matrices. Separating these from repomatic.metadata makes the CI matrix configuration self-contained and easier to update when runner images or Python releases change.

repomatic.test_matrix.TEST_RUNNERS_FULL = ('ubuntu-24.04-arm', 'ubuntu-slim', 'macos-26', 'macos-26-intel', 'windows-11-arm', 'windows-2025')ยถ

GitHub-hosted runners for the full test matrix.

Two variants per platform (one per architecture). See available images.

Note

Architecture speed is not uniform across platforms

When reducing to one runner per OS, choose by measured speed, not architecture (see Test matrix). Tendencies from repomaticโ€™s own full test suite: ARM Linux (ubuntu-24.04-arm) runs two to three times as fast as the lean x86 ubuntu-slim, the slowest tier overall; Apple-silicon macos-26 beats macos-26-intel by ~2x; the two Windows images tie on compute (windows-2025 is the PR pick). Per-job wall-clock folds in setup and upload, so isolate the test steps before blaming the image. These figures drift as images are re-provisioned, so re-confirm against your own job timings.

repomatic.test_matrix.TEST_RUNNERS_PR = ('ubuntu-24.04-arm', 'macos-26', 'windows-2025')ยถ

Reduced runner set for pull request test matrices.

One runner per platform: ARM Linux (ubuntu-24.04-arm) and Apple-silicon macOS (macos-26) are the fastest of their platform on the test workload, plus x86 Windows (windows-2025, where the two Windows images tie on compute). x86 Linux stays covered by the full matrix (TEST_RUNNERS_FULL).

Note

Why ARM Linux for the PR slot

The suite runs pytest --numprocesses=auto, so it scales with cores and favors ARM: ubuntu-24.04-arm runs it two to three times faster than the lean ubuntu-slim, for quicker PR feedback. That ratio is the heavy test suiteโ€™s, not a portable property: setup-bound light jobs (which run prebuilt single-threaded binaries) barely move between runners, so they keep the lean ubuntu-slim default. See Test matrix for the measurements.

repomatic.test_matrix.TEST_PYTHON_FULL = ('3.10', '3.14', '3.15')ยถ

Python versions tested across every runner in the full matrix.

Spans the supported range: the floor (3.10), the latest stable release (3.14), and the in-development version (3.15, flagged continue-on-error via UNSTABLE_PYTHON_VERSIONS). Intermediate releases (3.11, 3.12, 3.13) are skipped to reduce CI load. Released build flavors (free-threaded) are not full-spread; they get a single-runner smoke test instead, see SINGLE_RUNNER_PYTHON_VERSIONS.

repomatic.test_matrix.TEST_PYTHON_PR = ('3.10', '3.14')ยถ

Reduced Python version set for pull request test matrices.

Just the floor and the latest stable release, for fast PR feedback. The in-development version and released build flavors (free-threaded) are left to the full matrix.

repomatic.test_matrix.UNSTABLE_PYTHON_VERSIONS: Final[frozenset[str]] = frozenset({'3.15'})ยถ

Python versions still in development.

Jobs using these versions run with continue-on-error in CI. Contrast with SINGLE_RUNNER_PYTHON_VERSIONS, which are released and run stable.

repomatic.test_matrix.SINGLE_RUNNER_PYTHON_VERSIONS: Final[dict[str, str]] = {'3.14t': 'ubuntu-24.04-arm'}ยถ

Released Python build flavors smoke-tested on a single runner, mapped to it.

A free-threaded build (the t suffix, made officially supported in 3.14 by PEP 779) runs the same released interpreter as its base version, just without the GIL. The base version already gets the full cross-platform spread (TEST_PYTHON_FULL), so the library logic is covered everywhere; the flavor only needs one runner to catch a free-threading-specific break. These run stable (expected to pass), unlike the unreleased UNSTABLE_PYTHON_VERSIONS. The runner is ubuntu-24.04-arm, the default single-runner pick: the fastest measured on compute-bound parallel work and the cheapest tier, and free-threading targets server workloads where Linux/ARM is the norm (see Test matrix).

repomatic.test_matrix.MYPY_VERSION_MIN: Final = (3, 8)ยถ

Earliest version supported by Mypyโ€™s --python-version 3.x parameter.

Sourced from Mypy original implementation.

repomatic.metadata APIยถ

        classDiagram
  JSONEncoder <|-- JSONMetadata
  StrEnum <|-- Dialect
    

Extract metadata from repository and Python projects to be used by GitHub workflows.

This module solves a fundamental limitation of GitHub Actions: a workflow run is triggered by a singular event, which might encapsulate multiple commits. GitHub only exposes github.event.head_commit (the most recent commit), but workflows often need to process all commits in the push event.

This is critical for releases, where two commits are pushed together:

  1. [changelog] Release vX.Y.Z โ€” the release commit to be tagged and published

  2. [changelog] Post-release bump vX.Y.Z โ†’ vX.Y.Z โ€” bumps version for the next dev cycle

Since github.event.head_commit only sees the post-release bump, this module extracts the full commit range from the push event and identifies release commits that need special handling (tagging, PyPI publishing, GitHub release creation).

The following variables are printed to the environment file:

is_bot=false
new_commits=346ce664f055fbd042a25ee0b7e96702e95 6f27db47612aaee06fdf08744b09a9f5f6c2
release_commits=6f27db47612aaee06fdf08744b09a9f5f6c2
mailmap_exists=true
gitignore_exists=true
python_files=".github/update_mailmap.py" ".github/metadata.py" "setup.py"
json_files=
yaml_files="config.yaml" ".github/workflows/lint.yaml" ".github/workflows/test.yaml"
workflow_files=".github/workflows/lint.yaml" ".github/workflows/test.yaml"
doc_files="changelog.md" "readme.md" "docs/license.md"
markdown_files="changelog.md" "readme.md" "docs/license.md"
image_files=
zsh_files=
is_python_project=true
package_name=click-extra
project_description=๐Ÿ“ฆ Extra colorful clickable helpers for the CLI.
mypy_params=--python-version 3.7
current_version=2.0.1
released_version=2.0.0
is_sphinx=true
active_autodoc=true
release_notes=`๐Ÿ Available on PyPI <https://pypi.org/project/click-extra/2.21.3>`_.
new_commits_matrix={
    "commit": [
        "346ce664f055fbd042a25ee0b7e96702e95",
        "6f27db47612aaee06fdf08744b09a9f5f6c2"
    ],
    "include": [
        {
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "short_sha": "346ce66",
            "current_version": "2.0.1"
        },
        {
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "short_sha": "6f27db4",
            "current_version": "2.0.0"
        }
    ]
}
release_commits_matrix={
    "commit": ["6f27db47612aaee06fdf08744b09a9f5f6c2"],
    "include": [
        {
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "short_sha": "6f27db4",
            "current_version": "2.0.0"
        }
    ]
}
build_targets=[
    {
        "target": "linux-arm64",
        "os": "ubuntu-24.04-arm",
        "platform_id": "linux",
        "arch": "arm64",
        "extension": "bin"
    },
    {
        "target": "linux-x64",
        "os": "ubuntu-24.04",
        "platform_id": "linux",
        "arch": "x64",
        "extension": "bin"
    },
    {
        "target": "macos-arm64",
        "os": "macos-26",
        "platform_id": "macos",
        "arch": "arm64",
        "extension": "bin"
    },
    {
        "target": "macos-x64",
        "os": "macos-26-intel",
        "platform_id": "macos",
        "arch": "x64",
        "extension": "bin"
    },
    {
        "target": "windows-arm64",
        "os": "windows-11-arm",
        "platform_id": "windows",
        "arch": "arm64",
        "extension": "exe"
    },
    {
        "target": "windows-x64",
        "os": "windows-2025",
        "platform_id": "windows",
        "arch": "x64",
        "extension": "exe"
    }
]
nuitka_matrix={
    "os": [
        "ubuntu-24.04-arm",
        "ubuntu-24.04",
        "macos-26",
        "macos-26-intel",
        "windows-11-arm",
        "windows-2025"
    ],
    "entry_point": ["mpm"],
    "commit": [
        "346ce664f055fbd042a25ee0b7e96702e95",
        "6f27db47612aaee06fdf08744b09a9f5f6c2"
    ],
    "include": [
        {
            "target": "linux-arm64",
            "os": "ubuntu-24.04-arm",
            "platform_id": "linux",
            "arch": "arm64",
            "extension": "bin"
        },
        {
            "target": "linux-x64",
            "os": "ubuntu-24.04",
            "platform_id": "linux",
            "arch": "x64",
            "extension": "bin"
        },
        {
            "target": "macos-arm64",
            "os": "macos-26",
            "platform_id": "macos",
            "arch": "arm64",
            "extension": "bin"
        },
        {
            "target": "macos-x64",
            "os": "macos-26-intel",
            "platform_id": "macos",
            "arch": "x64",
            "extension": "bin"
        },
        {
            "target": "windows-arm64",
            "os": "windows-11-arm",
            "platform_id": "windows",
            "arch": "arm64",
            "extension": "exe"
        },
        {
            "target": "windows-x64",
            "os": "windows-2025",
            "platform_id": "windows",
            "arch": "x64",
            "extension": "exe"
        },
        {
            "entry_point": "mpm",
            "cli_id": "mpm",
            "module_id": "meta_package_manager.__main__",
            "callable_id": "main",
            "module_path": "meta_package_manager"
        },
        {
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "short_sha": "346ce66",
            "current_version": "2.0.0"
        },
        {
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "short_sha": "6f27db4",
            "current_version": "1.9.1"
        },
        {
            "os": "ubuntu-24.04-arm",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-linux-arm64.bin"
        },
        {
            "os": "ubuntu-24.04-arm",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-linux-arm64.bin"
        },
        {
            "os": "ubuntu-24.04",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-linux-x64.bin"
        },
        {
            "os": "ubuntu-24.04",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-linux-x64.bin"
        },
        {
            "os": "macos-26",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-macos-arm64.bin"
        },
        {
            "os": "macos-26",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-macos-arm64.bin"
        },
        {
            "os": "macos-26-intel",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-macos-x64.bin"
        },
        {
            "os": "macos-26-intel",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-macos-x64.bin"
        },
        {
            "os": "windows-11-arm",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-windows-arm64.bin"
        },
        {
            "os": "windows-11-arm",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-windows-arm64.bin"
        },
        {
            "os": "windows-2025",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-windows-x64.exe"
        },
        {
            "os": "windows-2025",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-windows-x64.exe"
        },
        {"state": "stable"}
    ]
}

Warning

Fields with serialized lists and dictionaries, like new_commits_matrix, build_targets or nuitka_matrix, are pretty-printed in the example above for readability. They are inlined in the actual output and not formatted this way.

class repomatic.metadata.Dialect(*values)[source]ยถ

Bases: StrEnum

Output dialect for metadata serialization.

github = 'github'ยถ
github_json = 'github-json'ยถ
json = 'json'ยถ
repomatic.metadata.METADATA_KEYS_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Key', 'key'), ('Description', 'description'))ยถ

Column definitions for the metadata keys reference table.

repomatic.metadata.metadata_keys_reference()[source]ยถ

Build the metadata keys reference as table rows.

Returns a list of (key, description) tuples for all keys produced by Metadata.dump(), including [tool.repomatic] config fields that are exposed as metadata outputs. Rows are unsorted: sorting is handled by the CLIโ€™s SortByOption.

Return type:

list[tuple[str, str]]

repomatic.metadata.all_metadata_keys()[source]ยถ

Returns the set of all valid metadata key names.

Return type:

frozenset[str]

repomatic.metadata.HEREDOC_FIELDS: Final[frozenset[str]] = frozenset({'release_notes', 'release_notes_with_admonition'})ยถ

Metadata fields that should always use heredoc format in GitHub Actions output.

Some fields may contain special characters (brackets, parentheses, emojis, or potential newlines) that can break GitHub Actions parsing when using simple key=value format. These fields will use the heredoc delimiter format regardless of whether they currently contain multiple lines.

repomatic.metadata.is_version_bump_allowed(part)[source]ยถ

Check if a version bump of the specified part is allowed.

This prevents double version increments within a development cycle. A bump is blocked if the version has already been bumped (but not released) since the last tagged release.

For example: - Last release: v5.0.1, current: 5.0.2 โ†’ minor bump allowed - Last release: v5.0.1, current: 5.1.0 โ†’ minor bump NOT allowed (bumped) - Last release: v5.0.1, current: 6.0.0 โ†’ major bump NOT allowed (bumped)

Note

When tags are not available (e.g., due to race conditions between workflows), this function falls back to parsing version from recent commit messages.

Parameters:

part (Literal['minor', 'major']) โ€“ The version part to check (minor or major).

Return type:

bool

Returns:

True if the bump should proceed, False if it should be skipped.

repomatic.metadata.stale_axis_values(entry, axes)[source]ยถ

Return the entry key/value pairs absent from the matrix axes.

A non-empty result means a test-matrix.exclude entry can never match a combination: one of its keys is not a live axis, or its value is absent from that axis. See Metadata.stale_test_matrix_excludes().

Return type:

dict[str, str]

class repomatic.metadata.JSONMetadata(*, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, sort_keys=False, indent=None, separators=None, default=None)[source]ยถ

Bases: JSONEncoder

Custom JSON encoder for metadata serialization.

Constructor for JSONEncoder, with sensible defaults.

If skipkeys is false, then it is a TypeError to attempt encoding of keys that are not str, int, float, bool or None. If skipkeys is True, such items are simply skipped.

If ensure_ascii is true, the output is guaranteed to be str objects with all incoming non-ASCII and non-printable characters escaped. If ensure_ascii is false, the output can contain non-ASCII and non-printable characters.

If check_circular is true, then lists, dicts, and custom encoded objects will be checked for circular references during encoding to prevent an infinite recursion (which would cause an RecursionError). Otherwise, no such check takes place.

If allow_nan is true, then NaN, Infinity, and -Infinity will be encoded as such. This behavior is not JSON specification compliant, but is consistent with most JavaScript based encoders and decoders. Otherwise, it will be a ValueError to encode such floats.

If sort_keys is true, then the output of dictionaries will be sorted by key; this is useful for regression tests to ensure that JSON serializations can be compared on a day-to-day basis.

If indent is a non-negative integer, then JSON array elements and object members will be pretty-printed with that indent level. An indent level of 0 will only insert newlines. None is the most compact representation.

If specified, separators should be an (item_separator, key_separator) tuple. The default is (โ€™, โ€˜, โ€˜: โ€˜) if indent is None and (โ€˜,โ€™, โ€˜: โ€˜) otherwise. To get the most compact JSON representation, you should specify (โ€˜,โ€™, โ€˜:โ€™) to eliminate whitespace.

If specified, default is a function that gets called for objects that canโ€™t otherwise be serialized. It should return a JSON encodable version of the object or raise a TypeError.

default(o)[source]ยถ

Implement this method in a subclass such that it returns a serializable object for o, or calls the base implementation (to raise a TypeError).

For example, to support arbitrary iterators, you could implement default like this:

def default(self, o):
    try:
        iterable = iter(o)
    except TypeError:
        pass
    else:
        return list(iterable)
    # Let the base class default method raise the TypeError
    return super().default(o)
Return type:

Any

class repomatic.metadata.Metadata[source]ยถ

Bases: object

Metadata class.

Implemented as a singleton: every Metadata() call returns the same instance within a process. This is safe because env vars and project files do not change during a single CLI invocation. Use reset() in test teardown to discard the cached instance between tests.

Initialize internal variables.

classmethod reset()[source]ยถ

Discard the singleton so the next call creates a fresh instance.

Intended for test teardown only. Production code should never call this.

Return type:

None

pyproject_path = PosixPath('pyproject.toml')ยถ
sphinx_conf_path = PosixPath('docs/conf.py')ยถ
property github_event: dict[str, Any][source]ยถ

Load the GitHub event payload from GITHUB_EVENT_PATH.

GitHub Actions automatically sets GITHUB_EVENT_PATH to a JSON file containing the complete webhook event payload.

git_stash_count()[source]ยถ

Returns the number of stashes.

Return type:

int

git_deepen(commit_hash, max_attempts=10, deepen_increment=50)[source]ยถ

Deepen a shallow clone until the provided commit_hash is found.

Progressively fetches more commits from the current repository until the specified commit is found or max attempts is reached.

Returns True if the commit was found, False otherwise.

Return type:

bool

commit_matrix(commits)[source]ยถ

Pre-compute a matrix of commits.

Danger

This method temporarily modify the state of the repository to compute version metadata from the past.

To prevent any loss of uncommitted data, it stashes and unstash the local changes between checkouts.

The list of commits is augmented with long and short SHA values, as well as current version. Most recent commit is first, oldest is last.

Returns a ready-to-use matrix structure:

{
    "commit": [
        "346ce664f055fbd042a25ee0b7e96702e95",
        "6f27db47612aaee06fdf08744b09a9f5f6c2",
    ],
    "include": [
        {
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "short_sha": "346ce66",
            "current_version": "2.0.1",
        },
        {
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "short_sha": "6f27db4",
            "current_version": "2.0.0",
        },
    ],
}
Return type:

Matrix | None

property event_type: WorkflowEvent | None[source]ยถ

Returns the type of event that triggered the workflow run.

Caution

This property is based on a crude heuristics as it only looks at the value of the GITHUB_BASE_REF environment variable. Which is only set when the event that triggers a workflow run is either pull_request or pull_request_target.

Todo

Add detection of all workflow trigger events.

property event_actor: str | None[source]ยถ

Returns the GitHub login of the user that triggered the workflow run.

property event_sender_type: str | None[source]ยถ

Returns the type of the user that triggered the workflow run.

property is_bot: bool[source]ยถ

Returns True if the workflow was triggered by a bot or automated process.

This is useful to only run some jobs on human-triggered events. Or skip jobs triggered by bots to avoid infinite loops.

property head_branch: str | None[source]ยถ

Returns the head branch name for pull request events.

For pull request events, this is the source branch name (e.g., update-mailmap). For push events, returns None since thereโ€™s no head branch concept.

The branch name is extracted from the GITHUB_HEAD_REF environment variable, which is only set for pull request events.

property event_name: str | None[source]ยถ

Returns the name of the event that triggered the workflow.

Reads GITHUB_EVENT_NAME. This is the raw event name (e.g., "push", "pull_request", "workflow_run"), as opposed to event_type which returns a WorkflowEvent enum based on heuristics.

property job_name: str | None[source]ยถ

Returns the ID of the current job in the workflow.

Reads GITHUB_JOB.

property ref_name: str | None[source]ยถ

Returns the short ref name of the branch or tag.

Reads GITHUB_REF_NAME.

property repo_name: str | None[source]ยถ

Returns the repository name without owner prefix.

Derived from repo_slug by splitting on /.

property is_awesome: bool[source]ยถ

Whether this is an awesome-list repository.

Detected by the awesome- prefix on the repository name.

property repo_owner: str | None[source]ยถ

Returns the repository owner.

Reads GITHUB_REPOSITORY_OWNER, falling back to the owner component of repo_slug.

property repo_slug: str | None[source]ยถ

Returns the owner/name slug for the current repository.

Resolution order: GITHUB_REPOSITORY env var (CI), gh repo view (authenticated local), git remote URL parsing (offline fallback).

property repo_url: str | None[source]ยถ

Returns the full URL to the repository.

Derived from server_url and repo_slug.

property run_attempt: str | None[source]ยถ

Returns the run attempt number.

Reads GITHUB_RUN_ATTEMPT.

property run_id: str | None[source]ยถ

Returns the unique ID of the current workflow run.

Reads GITHUB_RUN_ID.

property run_number: str | None[source]ยถ

Returns the run number for the current workflow.

Reads GITHUB_RUN_NUMBER.

property server_url: str[source]ยถ

Returns the GitHub server URL.

Reads GITHUB_SERVER_URL, defaulting to https://github.com.

property sha: str | None[source]ยถ

Returns the commit SHA that triggered the workflow.

Reads GITHUB_SHA.

property triggering_actor: str | None[source]ยถ

Returns the login of the user that initiated the workflow run.

Reads GITHUB_TRIGGERING_ACTOR. This differs from event_actor (GITHUB_ACTOR) when a workflow is re-run by a different user.

property workflow_ref: str | None[source]ยถ

Returns the full workflow reference.

Reads GITHUB_WORKFLOW_REF. The format is owner/repo/.github/workflows/name.yaml@refs/heads/branch.

property changed_files: tuple[str, ...] | None[source]ยถ

Returns the list of files changed in the current eventโ€™s commit range.

Uses git diff --name-only between the start and end of the commit range. Returns None if no commit range is available (e.g., outside CI).

property binary_affecting_paths: tuple[str, ...][source]ยถ

Path prefixes that affect compiled binaries for this project.

Combines the static BINARY_AFFECTING_PATHS (common files like pyproject.toml, uv.lock, tests/) with project-specific source directories derived from [project.scripts] in pyproject.toml.

For example, a project with mpm = "meta_package_manager.__main__:main" adds meta_package_manager/ as an affecting path. This makes the check reusable across downstream repositories without hardcoding source directories.

property head_commit_message: str[source]ยถ

Returns github.event.head_commit.message from the event payload.

Set for push events. Empty string for events that do not carry a head commit (pull_request, schedule, workflow_dispatch).

property yaml_changed: bool[source]ยถ

Returns True when the current eventโ€™s commit range touches at least one YAML file.

Lets per-job lint gates short-circuit on pushes / PRs that donโ€™t touch YAML. Falls back to โ€œrepo contains any YAML fileโ€ when the commit range is unavailable (workflow_dispatch), preserving the existing behavior of those manual runs.

property zsh_changed: bool[source]ยถ

Returns True when the current eventโ€™s commit range touches at least one Zsh file.

Falls back to โ€œrepo contains any Zsh fileโ€ when the commit range is unavailable.

property workflows_changed: bool[source]ยถ

Returns True when the current eventโ€™s commit range touches at least one GitHub workflow file.

Falls back to โ€œrepo contains any workflow fileโ€ when the commit range is unavailable.

property skip_binary_build: bool[source]ยถ

Returns True if binary builds should be skipped for this event.

Binary builds are expensive and time-consuming. This property identifies contexts where the changes cannot possibly affect compiled binaries, allowing workflows to skip Nuitka compilation jobs.

Three mechanisms are checked:

  1. Branch name โ€” PRs from known non-code branches (documentation, .mailmap, .gitignore, etc.) are skipped.

  2. Version-bump commit โ€” Push events whose head commit is a user-initiated version bump (Bump (major|minor) version to ``) are skipped: the bump merge changes only version strings and ``uv.lock, so the new binary differs from the previous one only in the baked-in version string. The [changelog] Post-release bump `` prefix is deliberately *not* checked here: the ``prepare-release merge bundles the release commit with the post-release-bump commit, and the release commit must still produce its binary.

  3. Changed files โ€” Push events where all changed files fall outside binary_affecting_paths are skipped. This avoids ~2h of Nuitka builds for documentation-only commits to main.

property commit_range: tuple[str | None, str] | None[source]ยถ

Range of commits bundled within the triggering event.

A workflow run is triggered by a singular event, which might encapsulate one or more commits. This means the workflow will only run once on the last commit, even if multiple new commits were pushed.

This is critical for releases where two commits are pushed together:

  1. [changelog] Release vX.Y.Z โ€” the release commit

  2. [changelog] Post-release bump vX.Y.Z โ†’ vX.Y.Z โ€” the post-release bump

Without extracting the full commit range, the release commit would be missed since github.event.head_commit only exposes the post-release bump.

This property also enables processing each commit individually when we want to keep a carefully constructed commit history. The typical example is a pull request that is merged upstream but weโ€™d like to produce artifacts (builds, packages, etc.) for each individual commit.

The default GITHUB_SHA environment variable is not enough as it only points to the last commit. We need to inspect the commit history to find all new ones. New commits need to be fetched differently in push and pull_request events.

See also

Pull request events on GitHub are a bit complex, see: The Many SHAs of a GitHub Pull Request.

property current_commit: Commit[source]ยถ

Returns the current Commit object.

Raises if HEAD cannot be resolved (an empty repository), mirroring the previous behavior where traversing an empty history raised too.

property current_commit_matrix: Matrix | None[source]ยถ

Pre-computed matrix with long and short SHA values of the current commit.

property new_commits: tuple[Commit, ...] | None[source]ยถ

Returns list of all Commit objects bundled within the triggering event.

This extracts all commits from the push event, not just head_commit. For releases, this typically includes both the release commit and the post-release bump commit, allowing downstream jobs to process each one.

Commits are returned in chronological order (oldest first, most recent last).

property new_commits_matrix: Matrix | None[source]ยถ

Pre-computed matrix with long and short SHA values of new commits.

property new_commits_hash: tuple[str, ...] | None[source]ยถ

List all hashes of new commits.

property release_commits: tuple[Commit, ...] | None[source]ยถ

Returns list of Commit objects to be tagged within the triggering event.

This filters new_commits to find release commits that need special handling: tagging, PyPI publishing, and GitHub release creation.

This is essential because when a release is pushed, github.event.head_commit only exposes the post-release bump commit, not the release commit. By extracting all commits from the event (via new_commits) and filtering for release commits here, we ensure the release workflow can properly identify and process the [changelog] Release vX.Y.Z commit.

We cannot identify a release commit based on the presence of a vX.Y.Z tag alone. Thatโ€™s because the tag is not present in the prepare-release pull request produced by the changelog.yaml workflow. The tag is created later by the release.yaml workflow, when the pull request is merged to main.

Our best option is to identify a release based on the full commit message, using the template from the changelog.yaml workflow.

property release_commits_matrix: Matrix | None[source]ยถ

Pre-computed matrix with long and short SHA values of release commits.

property release_commits_hash: tuple[str, ...] | None[source]ยถ

List all hashes of release commits.

property mailmap_exists: bool[source]ยถ
property gitignore_exists: bool[source]ยถ
property gitignore_parser: Parser | None[source]ยถ

Returns a parser for the .gitignore file, if it exists.

gitignore_match(file_path)[source]ยถ
Return type:

bool

glob_files(*patterns)[source]ยถ

Return all file path matching the patterns.

Patterns are glob patterns supporting ** for recursive search, and ! for negation.

All directories are traversed, whether they are hidden (i.e. starting with a dot .) or not, including symlinks.

Skips:

  • files which does not exists

  • directories

  • broken symlinks

  • files matching patterns specified by .gitignore file

Returns both hidden and non-hidden files.

All files are normalized to their absolute path, so that duplicates produced by symlinks are ignored.

File path are returned as relative to the current working directory if possible, or as absolute path otherwise.

The resulting list of file paths is sorted.

Return type:

list[Path]

property python_files: list[Path][source]ยถ

Returns a list of python files.

property json_files: list[Path][source]ยถ

Returns a list of JSON files.

Note

JSON5 files are excluded because Biome doesnโ€™t support them.

property yaml_files: list[Path][source]ยถ

Returns a list of YAML files.

property toml_files: list[Path][source]ยถ

Returns a list of TOML files.

property pyproject_files: list[Path][source]ยถ

Returns a list of pyproject.toml files.

property workflow_files: list[Path][source]ยถ

Returns a list of GitHub workflow files.

property doc_files: list[Path][source]ยถ

Returns a list of doc files.

property markdown_files: list[Path][source]ยถ

Returns a list of Markdown files.

property image_files: list[Path][source]ยถ

Returns a list of image files.

Covers the formats handled by repomatic format-images: JPEG, PNG, WebP, and AVIF. See repomatic.images for the optimization tools.

property shfmt_files: list[Path][source]ยถ

Returns a list of shell files that shfmt can reliably format.

shfmt supports the following dialects (-ln flag):

  • bash: GNU Bourne Again Shell.

  • posix: POSIX Shell (/bin/sh).

  • mksh: MirBSD Korn Shell.

  • bats: Bash Automated Testing System.

Zsh is excluded. shfmt added experimental Zsh support in v3.13.0 but it fails on common constructs: for var (list) short-form loops and for ... { } brace-delimited loops. See mvdan/sh#1203 for upstream tracking.

Files are excluded by extension (.zsh, .zshrc, etc.) and by shebang (any .sh file whose first line references zsh).

property zsh_files: list[Path][source]ยถ

Returns a list of Zsh files.

property is_python_project: bool[source]ยถ

Returns True if repository is a Python project.

Presence of a pyproject.toml file that respects the standards is enough to consider the project as a Python one. Delegates to repomatic.pyproject.is_python_project() so the detection rule has a single source of truth.

property pyproject_toml: dict[str, Any][source]ยถ

Returns the raw parsed content of pyproject.toml.

Returns an empty dict if the file does not exist.

property pyproject: StandardMetadata | None[source]ยถ

Returns metadata stored in the pyproject.toml file.

Returns None if the pyproject.toml does not exists or does not respects the PEP standards.

Warning

Some third-party apps have their configuration saved into pyproject.toml file, but that does not means the project is a Python one. For that, the pyproject.toml needs to respect the PEPs.

property config: Config[source]ยถ

Returns the [tool.repomatic] section from pyproject.toml.

Merges user configuration with defaults from Config.

property nuitka_entry_points: list[str][source]ยถ

Entry points selected for Nuitka binary compilation.

Reads [tool.repomatic].nuitka.entry-points from pyproject.toml. When empty (the default), deduplicates by callable target: keeps the first entry point for each unique module:callable pair, so alias entry points (like both mpm and meta-package-manager pointing to the same function) donโ€™t produce duplicate binaries. Unrecognized CLI IDs are logged as warnings and discarded.

property unstable_targets: set[str][source]ยถ

Nuitka build targets allowed to fail without blocking the release.

Reads [tool.repomatic].nuitka.unstable-targets from pyproject.toml. Defaults to an empty set.

Unrecognized target names are logged as warnings and discarded.

property package_name: str | None[source]ยถ

Returns package name as published on PyPI.

property project_description: str | None[source]ยถ

Returns project description from pyproject.toml.

property script_entries: list[tuple[str, str, str]][source]ยถ

Returns a list of tuples containing the script name, its module and callable.

Results are derived from the script entries of pyproject.toml. So that:

[project.scripts]
mdedup = "mail_deduplicate.cli:mdedup"
mpm = "meta_package_manager.__main__:main"

Will yields the following list:

(
    ("mdedup", "mail_deduplicate.cli", "mdedup"),
    ("mpm", "meta_package_manager.__main__", "main"),
    ...,
)

Each entry is validated against PEP 621 and PyPI conventions:

  • The script name (the dict key) must be non-empty, contain at least one non-dot character, and match [A-Za-z0-9._-]+. This mirrors the rule PyPI enforces on uploaded wheels and the check uv-build performs; rejecting names like ../escape, nested/script or . here keeps them from flowing into the binary file path template {{cli_id}}-{{current_version}}-{{target}}.{{extension}} and from there into shell-quoted artifact names, chmod, and attestation commands in the release workflow.

  • The script value must split on : into exactly two non-empty parts (module:object). Malformed values raise a descriptive ValueError instead of crashing with an unpacking error.

property mypy_params: list[str] | None[source]ยถ

Generates mypy parameters.

Mypy needs to be fed with this parameter: --python-version 3.x.

Extracts the minimum Python version from the projectโ€™s requires-python specifier. Only takes major.minor into account.

static get_current_version()[source]ยถ

Returns the current version as managed by bump-my-version.

Same as calling the CLI:

$ bump-my-version show current_version

Reads current_version from the first TOML file found in the current working directory: .bumpversion.toml (top-level table) or pyproject.toml ([tool.bumpversion]).

Return type:

str | None

property current_version: str | None[source]ยถ

Returns the current version.

Current version is fetched from the bump-my-version configuration file.

During a release, two commits are bundled into a single push event:

  1. [changelog] Release vX.Y.Z โ€” freezes the version to the release number

  2. [changelog] Post-release bump vX.Y.Z โ†’ vX.Y.Z โ€” bumps to the next dev version

In this situation, the current version returned is the one from the most recent commit (the post-release bump), which represents the next development version. Use released_version to get the version from the release commit.

property released_version: str | None[source]ยถ

Returns the version of the release commit.

During a release push event, this extracts the version from the [changelog] Release vX.Y.Z commit, which is distinct from current_version (the post-release bump version). This is used for tagging, PyPI publishing, and GitHub release creation.

Returns None if no release commit is found in the current event.

property is_sphinx: bool[source]ยถ

Returns True if the Sphinx config file is present.

property minor_bump_allowed: bool[source]ยถ

Check if a minor version bump is allowed.

This prevents double version increments within a development cycle.

property major_bump_allowed: bool[source]ยถ

Check if a major version bump is allowed.

This prevents double version increments within a development cycle.

property active_autodoc: bool[source]ยถ

Returns True if Sphinx autodoc is active.

property uses_myst: bool[source]ยถ

Returns True if MyST-Parser is active in Sphinx.

property nuitka_matrix: Matrix | None[source]ยถ

Pre-compute a matrix for Nuitka compilation workflows.

Combine the variations of: - release commits only (during releases) or all new commits (otherwise) - all entry points - for the 3 main OSes - for a set of architectures

Returns a ready-to-use matrix structure, where each variation is augmented with specific extra parameters by the way of matching parameters in the include directive.

{
    "os": [
        "ubuntu-24.04-arm",
        "ubuntu-24.04",
        "macos-26",
        "macos-26-intel",
        "windows-11-arm",
        "windows-2025",
    ],
    "entry_point": [
        "mpm",
    ],
    "commit": [
        "346ce664f055fbd042a25ee0b7e96702e95",
        "6f27db47612aaee06fdf08744b09a9f5f6c2",
    ],
    "include": [
        {
            "target": "linux-arm64",
            "os": "ubuntu-24.04-arm",
            "platform_id": "linux",
            "arch": "arm64",
            "extension": "bin",
        },
        {
            "target": "linux-x64",
            "os": "ubuntu-24.04",
            "platform_id": "linux",
            "arch": "x64",
            "extension": "bin",
        },
        {
            "target": "macos-arm64",
            "os": "macos-26",
            "platform_id": "macos",
            "arch": "arm64",
            "extension": "bin",
        },
        {
            "target": "macos-x64",
            "os": "macos-26-intel",
            "platform_id": "macos",
            "arch": "x64",
            "extension": "bin",
        },
        {
            "target": "windows-arm64",
            "os": "windows-11-arm",
            "platform_id": "windows",
            "arch": "arm64",
            "extension": "exe",
        },
        {
            "target": "windows-x64",
            "os": "windows-2025",
            "platform_id": "windows",
            "arch": "x64",
            "extension": "exe",
        },
        {
            "entry_point": "mpm",
            "cli_id": "mpm",
            "module_id": "meta_package_manager.__main__",
            "callable_id": "main",
            "module_path": "meta_package_manager",
        },
        {
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "short_sha": "346ce66",
            "current_version": "2.0.0",
        },
        {
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "short_sha": "6f27db4",
            "current_version": "1.9.1",
        },
        {
            "os": "ubuntu-24.04-arm",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-2.0.0-linux-arm64.bin",
        },
        {
            "os": "ubuntu-24.04-arm",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-1.9.1-linux-arm64.bin",
        },
        {
            "os": "ubuntu-24.04",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-2.0.0-linux-x64.bin",
        },
        {
            "os": "ubuntu-24.04",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-1.9.1-linux-x64.bin",
        },
        {
            "os": "macos-26",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-2.0.0-macos-arm64.bin",
        },
        {
            "os": "macos-26",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-1.9.1-macos-arm64.bin",
        },
        {
            "os": "macos-26-intel",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-2.0.0-macos-x64.bin",
        },
        {
            "os": "macos-26-intel",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-1.9.1-macos-x64.bin",
        },
        {
            "os": "windows-11-arm",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-2.0.0-windows-arm64.exe",
        },
        {
            "os": "windows-11-arm",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-1.9.1-windows-arm64.exe",
        },
        {
            "os": "windows-2025",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-2.0.0-windows-x64.exe",
        },
        {
            "os": "windows-2025",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-1.9.1-windows-x64.exe",
        },
        {
            "state": "stable",
        },
    ],
}
property test_matrix: Matrix[source]ยถ

Full test matrix for non-PR events.

Combines all runner OS images and Python versions, excluding known incompatible combinations. Marks development Python versions as unstable so CI can use continue-on-error, and adds released build flavors (free-threaded) as stable single-runner smoke tests. Per-project config from [tool.repomatic.test-matrix] is applied last.

When [tool.repomatic.test-matrix] full-include rows are configured, the matrix is emitted as a flat job list ({"include": [...]}) so each row is a standalone combination GitHub runs verbatim, rather than one that augments a base combo sharing its os and python-version.

property test_matrix_pr: Matrix[source]ยถ

Reduced test matrix for pull requests.

Skips experimental Python versions and redundant architecture variants to reduce CI load on PRs. Per-project config excludes and includes from [tool.repomatic.test-matrix] are applied, but variations are not (to keep the PR matrix small).

property coverage_cells: list[str][source]ยถ

Matrix cells eligible for Codecov coverage upload, as os|python-version.

Coverage is a function of OS family and Python version, never CPU architecture, so uploading from every cell of test_matrix just merges redundant reports (architecture twins cover identical lines). This returns the test_matrix_pr cell set (one runner per OS at released Python). On a push event the workflow uses it to select the coverage-distinct subset of the full matrix; on a pull request it matches every running cell. Tests still execute on every cell: only the upload is gated.

Emitted as os|python-version tokens so the workflow can test membership with contains(coverage_cells, format('{0}|{1}', ...)).

property stale_test_matrix_excludes: list[dict[str, str]][source]ยถ

User test-matrix.exclude entries matching no full-matrix axis value.

An exclude naming a value absent from every axis (like a renamed runner) can never match a combination, so Matrix.prune() drops it silently and its exclusion intent is lost. This drift is common after an upstream runner rename (such as macos-15-intel becoming macos-26-intel). The lint-repo check surfaces these so the drift fails loudly instead of silently.

Returns:

The offending exclude entries, in config order.

property release_notes: str | None[source]ยถ

Generate notes to be attached to the GitHub release.

Renders the github-releases template with changelog content for the version. The template is the single place that defines the release body layout.

property release_notes_with_admonition: str | None[source]ยถ

Generate release notes with a pre-computed availability admonition.

Builds the same body as release_notes, but injects a > [!NOTE] admonition linking to PyPI and GitHub even before fix-changelog has a chance to update changelog.md.

The engineโ€™s create-release job bakes this body into the GitHub release at draft-creation time, so the admonition is present from the start. Doing it there (rather than editing the release from the callerโ€™s fast publish-pypi lane) removes the cross-lane race where the edit ran before create-release had created the release, and so silently dropped the admonition under continue-on-error. The bake is optimistic: it assumes the parallel PyPI upload succeeds, which it does on the normal path; a failed upload surfaces as a red publish-pypi job, not as a wrong admonition the user must catch.

Returns None when the project is not on PyPI, has no changelog, or has no version to release, in which case create-release falls back to the plain release_notes.

static format_github_value(value)[source]ยถ

Transform Python value to GitHub-friendly, JSON-like, console string.

Renders:

  • str as-is

  • None into empty string

  • bool into lower-cased string

  • Matrix into JSON string

  • Iterable of mixed strings and Path into a serialized space-separated string, where Path items are double-quoted

  • other Iterable into a JSON string

Return type:

str

dump(dialect=Dialect.github, keys=())[source]ยถ

Returns metadata in the specified format.

Defaults to GitHub dialect. When keys is non-empty, only the requested keys are computed and included in the output. Filtered-out keys are never accessed, so callers requesting a small subset avoid triggering expensive dependent computations (git history walks, file system scans, build matrix expansion).

Return type:

str