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 or Adopt on a monorepo.
jobs:
conventions:
uses: thekevinscott/testing-conventions/.github/workflows/testing-conventions.yml@v0Inputs
| 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). |
config | testing-conventions.toml | The config file supplying floors and exemptions. Discovery order: an explicit non-default value is used verbatim; otherwise a testing-conventions.toml at the derived package root 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. |
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: 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 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). 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: 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:
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: distThe 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 — 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 | 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 | 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 | always | The language's default floor, plus the changed-line (--base) job on pull requests. |
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 | always | Python, TypeScript, Rust. Runs as a step of the Static checks (<language>) job. Subjects derive from 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 | 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 | 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 | 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 | when receipts are present, on pull requests | Runs when committed receipts (e2e-attestations/) sit at the package root (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 — [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 | 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 — . 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 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 — 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-onlineasks for behavior the run already has. npm resolves a bare-name spec withpreferOnlineset —libnpmexec'sgetManifest— so everynpm execof 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-nodesteps request nocache:, so a job's~/.npmstarts cold and holds nothing that could be stale. - A
Cache-Control: no-cacherequest header is ignored by the CDN, which answers repeated requests carrying it from the edge with a climbingage.
So the two remedies are to wait, or to name the version:
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.92The 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.