---
url: https://thekevinscott.github.io/testing-conventions/reference/workflow.md
description: >-
  The reusable GitHub Actions workflow — every input, every check and its run
  condition, and the @v0 versioning contract.
---

# Workflow

The reusable workflow is the adoption surface: one `uses:` call runs every check. The five static
source scans (colocated-test, its co-change mode, one-function-per-file, unit-lint, and
integration-lint) run as steps of one `Static checks (<language>)` job per language; the
toolchain-heavy suites each run as their own job. This page is the canonical record of its inputs, the checks it runs, and its versioning
contract.
To adopt it, start with [Getting Started](../getting-started) or [Adopt on a
monorepo](../monorepo).

```yaml
jobs:
  conventions:
    uses: thekevinscott/testing-conventions/.github/workflows/testing-conventions.yml@v0
```

## Inputs

| Input                | Default                    | Description |
| -------------------- | -------------------------- | ----------- |
| `languages`          | `''` (auto-detect)         | A restriction on auto-detection: empty runs every supported language with sources present under `source`; a JSON array (`python`, `typescript`, `rust`) narrows the run to those named. It is a JSON string rather than a list because `workflow_call` inputs have no list type, and the matrix reads it back with `fromJSON`. A language with no sources under `source` is dropped from the matrix and skipped, never failed. Rust is detected as a crate: a `Cargo.toml` **with** `.rs` sources under `source`. |
| `source`             | `src`                      | The scan root — the directory scanned recursively for sources, and the only scoping mechanism (see [Scoping and exemptions](../explanation/scoping)). |
| `config`             | `testing-conventions.toml` | The [config file](./config) supplying floors and exemptions. Discovery order: an explicit non-default value is used verbatim; otherwise a `testing-conventions.toml` at the derived [package root](../monorepo) wins if present, else the repo-root default. Absent (no file at any of those locations) means every check runs on its default. The resolved path reaches each check as its [`--config` flag](./checks/#running-a-check-directly). |
| `base`               | `origin/main`              | Base ref for the diff-scoped `--base` jobs, diffed as `<base>...HEAD`. The diff-scoped jobs run on `pull_request` only. |
| `version`            | latest                     | `testing-conventions` version to install (e.g. `0.1.0`). |
| `rust_toolchain`     | `false`                    | An [escape hatch](../monorepo#escape-hatches): `true` forces a stable Rust toolchain, with build caching (the cargo registry, and `target/` at the derived workspace-aware location — the workspace root's `target/` for a crate that's a workspace member, else the package root's own, keyed off `Cargo.lock`), in the suite-executing jobs before the derived [`build_command`](./config#build-command) runs. `unit-coverage` / changed-line coverage / `mutation` already auto-provision it when the package root's manifest declares a Rust-compiling build (a `Cargo.toml`, a maturin backend, a napi key — detect's `provision_rust`); set this by hand only for a build the manifest doesn't express. The `rust` matrix arm always carries its own toolchain. |
| `packaging_artifact` | `''`                       | Name of an uploaded build artifact holding built distributions; when set, the packaging check downloads and inspects it as-is, building nothing. When empty, the packaging job derives the distribution build from the package's own manifest (`uv build` / `<pm> pack` / `cargo package`), runs it, and scans what it wrote — or, when the manifest can't state a build, scans a conventional `dist/` already committed at the derived package root (`.` for a single-package repo — see [Adopt on a monorepo](../monorepo)). An artifact holding no recognized distribution fails the job. |
| `run_e2e`            | `false`                    | Forces the `e2e-verify` job on. It is already on when committed receipts (`e2e-attestations/`) are present at the package root. The job is diff-scoped (`--base`), so it runs on `pull_request` only; it needs full history. |
| `gates`              | `''` (all applicable)      | An [escape hatch](../monorepo#escape-hatches): a JSON array naming which checks run (`colocated-test`, `one-function-per-file`, `unit-lint`, `unit-coverage`, `mutation`, `integration-lint`, `packaging`, `workflow-lint`, `e2e-verify`, `changelog`), for the rare package where one genuinely cannot run. Empty runs every applicable check. Naming a check opts it in; it still applies its own conditions (language presence, the `pull_request` event, a discoverable dist, attestation or fragment directory). A named check's diff-scoped variant rides with it, and the allowlist is authoritative even when `run_e2e` / `packaging_artifact` is set. |

To build the distribution yourself and have the packaging check scan what you produced, upload it
and name the artifact:

```yaml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: uv build
      - uses: actions/upload-artifact@v7
        with: { name: dist, path: dist/ }
  conventions:
    needs: build
    uses: thekevinscott/testing-conventions/.github/workflows/testing-conventions.yml@v0
    with:
      packaging_artifact: dist
```

## The checks and when they run

Each check fails the build on a violation, with the offending files in the log. Each links to its
own page under [Checks](./checks/) — the complete per-check record: motivation, per-language
behavior, run conditions, and configuration surface. The five static source scans (colocated-test,
its co-change mode, one-function-per-file, unit-lint, integration-lint) run as steps of one
`Static checks (<language>)` job per language — each a sub-second scan, so one job's setup covers
all five; the toolchain-heavy suites (`unit-coverage`, its changed-line variant, `mutation`)
each run as their own job. Every check keeps its own
`gates` membership and `--base` semantics — a check left out of `gates` is skipped whether it runs as
a job or a step.

| Check | Runs | Notes |
| --- | --- | --- |
| [`colocated-test`](./checks/colocated-test) | always | Python, TypeScript, and Rust (inline `#[cfg(test)]` presence). Runs as a step of the `Static checks (<language>)` job. Scans `source`, leaving `<package root>/tests/` to the suite tiers. Plus the diff-scoped co-change (`--base`) step on pull requests, for Python and TypeScript — Rust units are inline, so a sibling test can't go stale and co-change doesn't apply. |
| [`one-function-per-file`](./checks/one-function-per-file) | always | Python and TypeScript at the default `max_lines = 1`; Rust when a `[rust].one_function_per_file` table names a threshold, and a run without one reports the check is off and passes. Runs as a step of the `Static checks (<language>)` job. Scans `source`, leaving `<package root>/tests/` to the suite tiers. |
| [`unit-coverage`](./checks/unit-coverage) | always | The language's [default floor](./config#coverage), plus the changed-line (`--base`) job on pull requests. |
| [`unit-lint`](./checks/unit-lint) | always | Python, TypeScript, Rust. Runs as a step of the `Static checks (<language>)` job. Scans `source`, leaving `<package root>/tests/` to the suite tiers. |
| [`integration-lint`](./checks/integration-lint) | always | Python, TypeScript, Rust. Runs as a step of the `Static checks (<language>)` job. Subjects derive from the [package root](../monorepo#source-vs-the-package-root): the `tests/integration/` and `tests/e2e/` suites (Rust: the crate root's `tests/`). A test file under `tests/` outside a standard tier is flagged (`unknown-tier`); a tree with no manifest is scanned at `source` directly. |
| [`mutation`](./checks/mutation) | pull requests only | Diff-scoped to the `<base>...HEAD` changed lines; a binary gate — any un-exempted survivor on a changed line fails. Installs and runs from the derived package root: TypeScript picks `npm ci` or `pnpm install --frozen-lockfile` from the package's own manifest/lockfile; Python is provisioned with uv, identically to the coverage jobs (see below). |
| [`packaging`](./checks/packaging) | when a build is derivable, an artifact is named, or a dist is committed | **Build-then-scan** (#335): derives the distribution build from the package's own manifest — `uv build` (Python `[project]`), `<pm> pack --pack-destination dist` (TypeScript), `cargo package` (Rust `[package]`) — provisions the toolchain, runs it at the derived package root, and scans the result (`dist/` for a wheel/sdist/tarball, `target/package/` for a crate). So a native monorepo adopts with `gates: ["packaging"]` and no bespoke build job. A named `packaging_artifact` is scanned as-is instead, and a committed `dist/` is scanned in place when the manifest can't state a build; **skipped, never failed** when none of the three holds. A `source`-scoped call builds and inspects only its own package. Needs a `testing-conventions` release whose `detect` derives `packaging_build` — a `version` pinned older falls back to locate-or-skip. |
| [`workflow-lint`](./checks/workflow-lint) | always | Runs as its own `Workflow lint` job, over the calling repository's `.github` — the subcommand's default path, independent of `source`, because the CI under test belongs to the repository rather than the package. Only the YAML GitHub executes is read: every file directly inside a `workflows/` directory, plus every `action.yml` / `action.yaml` at any depth. A repository with no workflows passes untouched. The check reads no configuration and honors no exemption; a consumer that needs it off leaves it out of `gates`. Needs a `testing-conventions` release carrying the `workflow-lint` subcommand — a `version` pinned older fails on an unknown subcommand. |
| [`e2e-verify`](./checks/e2e-verify) | when receipts are present, on pull requests | Runs when committed receipts (`e2e-attestations/`) sit at the [package root](../monorepo) (`source`'s nearest manifest directory, the repo root for a single-package repo); **skipped, never failed** otherwise. Diff-scoped like the changed-line coverage/mutation jobs, so it runs on `pull_request` only: both of its questions read the branch's content diff (`<base>...HEAD`) — a branch that left the scoped source untouched passes with no receipt owed, and a branch that changed it passes when its diff adds or updates a receipt. Content diffs are indifferent to commit identity, so rebases and squash merges never disturb a receipt (#319's diff-relative model, content-based). The scoped diff reads the caller's own `source` (#294) — a change outside `source` but inside the package root owes no decision. A package whose e2e artifact is compiled from a shared source tree beside it (a native core bound into several bindings) declares that tree as an [extra scope](../explanation/e2e#a-shared-source-tree-beside-the-package) — `[e2e] extra_scope` / `exclude` in its own `testing-conventions.toml` — so `e2e-verify` also accepts a repeatable `--extra-scope <dir>` / `--exclude <dir>` (#333), and the job appends the detected values as those flags — `detect` reads `[e2e] extra_scope` / `exclude` from the package's own config and renders them; a package declaring neither scopes the diff to `source` alone. `run_e2e` forces it on. Needs a `testing-conventions` release carrying the branch-keyed receipt contract — a `version` pinned older checks the single-file attestation's commit SHA instead. |
| [`changelog`](./checks/changelog) | pull requests only | Runs when the repository keeps fragment directories — `changelog.d/`, plus `migrations.d/` for a pull request whose commits carry a `breaking:` line; **skipped, never failed** otherwise, so the drop-in is safe on a repository that keeps no changelog this way. The layout is derived from where those directories sit: under `packages/<pkg>/` the check runs per package; a single directory outside any package pools them, where each fragment names the package it pays for (`YYYY-MM-DD-<pkg>-<slug>.md`) and public surface is scoped to the discovered package roots. Diff-scoped (`--base`) and filtered to additions, so a fragment satisfies it only when the pull request adds one; a `skip-changelog: <reason>` line on any commit bypasses it. Needs a `testing-conventions` release carrying the `changelog` subcommand — a `version` pinned older fails on an unknown subcommand. |

The suite-executing jobs (`unit-coverage`, changed-line coverage, `mutation`) install,
provision, and build at the [derived package root](../monorepo#source-vs-the-package-root) — `.`
for a single-package repo, so an existing single-package call is unaffected. Python is provisioned
by **uv** in all three jobs, with identical steps: an installable package (a `pyproject.toml` with
a `[project]` table) is synced first — `uv sync` installs the project's own dependencies and
builds/installs the project itself — while a plain package gets a fresh `uv venv`; the suite
toolchain (`coverage`, `pytest`, and the `testing-conventions` adapter wheel) then installs into
that same `.venv`, which goes on the suite's `PATH` — so cosmic-ray's spawned pytest (for
`mutation`) and the coverage run import the project's dependencies and the adapter together.
uv reads its own index/network configuration (`UV_INDEX_URL`, `uv.toml`); a private index is
declared there. TypeScript runs under `vitest`
v8 coverage, installed with the package's own lockfile (`pnpm install --frozen-lockfile` or
`npm ci`, per its manifest); for `mutation` those project dependencies must include
`@stryker-mutator/core` and a runner plugin. Rust runs under `cargo llvm-cov --lib --bins` (the
unit suite of the library and binary targets, leaving the `tests/` tier out) and needs
no install step of its own; a [`[rust].coverage` `branch` floor](./config#coverage) adds `--branch`,
which uses the nightly toolchain the crate pins in its own `rust-toolchain.toml` (with
`llvm-tools-preview`) — the coverage run reads that pin directly, so the job provisions nothing
extra for it.

A package whose suite imports a compiled module names its build in a
[`build_command`](./config#build-command) — under `[python]`, `[typescript]`, or `[rust]` — in its
own `testing-conventions.toml`, discovered at the package root like the config file itself. The
suite-executing jobs run that command after toolchain and dependency setup and before the suite,
and the packaging job runs it before the pack; the static checks parse source, so they run no
build. Where an ecosystem standardizes the build the tool derives it — an npm `prepare` / `prepack`
script and Cargo's `build.rs` compile during dependency install.

## A publish reaches every runner within five minutes

`version` is empty by default, so each job resolves `testing-conventions` to the newest release
its node satisfies. Every job that invokes the CLI provisions node 24 to match the package's
`engines.node`, so that is the newest release outright. Each job resolves it from a runner-owned
temp prefix, so the version it runs comes from the registry and this input, and a
`testing-conventions` entry in your own manifest is free to differ.
The packument that resolution reads is served by `registry.npmjs.org` with `cache-control: public,
max-age=300`, so a version becomes visible at a given CDN edge over the five minutes after it is
published. A run starting inside that window resolves the version before it and reports whatever
that build concludes.

The window is the registry's, and it closes on its own. Three things that look like levers over it
are not:

* **`--prefer-online`** asks for behavior the run already has. npm resolves a bare-name spec
  with `preferOnline` set — `libnpmexec`'s `getManifest` — so every `npm exec` of the CLI is
  already revalidating.
* **Clearing the npm cache** operates on a store that is already empty. GitHub-hosted runners are
  ephemeral, and this workflow's `setup-node` steps request no `cache:`, so a job's `~/.npm` starts
  cold and holds nothing that could be stale.
* **A `Cache-Control: no-cache` request header** is ignored by the CDN, which answers repeated
  requests carrying it from the edge with a climbing `age`.

So the two remedies are to wait, or to name the version:

```yaml
with:
  version: '0.0.92'
```

A pin resolves an exact version instead of a tag, which turns the race loud. While an edge still
lacks that version, the job stops with the cause on screen:

```
npm error notarget No matching version found for testing-conventions@0.0.92.
```

Set it to unblock work waiting on a specific release, and clear it once `latest` has caught up: an
empty `version` is the intended steady state, and it is what keeps a consumer on the rolling
release described next.

## Versioning: `@v0` is a moving tag

`@v0` is a **moving major tag**, not a frozen release. It always points at the latest released
`main`, so pinning `@v0` opts you into a rolling release: fixes and new checks reach your CI on the
next run, with no tag bump on your side. Breaking changes are coordinated across consumers rather
than held back by a semver pin.

The workflow *file* tracks `@v0`; the `testing-conventions` **binary** it runs is pulled
fresh from npm each run (the latest published version), and `@v0` only advances once that binary is
published — so the workflow and the binary it calls always match. To freeze the binary, set the
`version` input; the workflow file still tracks `@v0`.

### Which version ran

Every check names its own version on stderr before doing anything else, so each job's log opens
with the build that ran:

```
testing-conventions 0.0.92
```

The line comes from the binary itself rather than a separate lookup, so it is the version that
produced the result below it — including on a run that fails, and on one that fails to parse its
arguments at all. Read it whenever a check's behavior surprises you: it tells you in one line
whether `latest` handed this job the release you expected.
