Skip to content

packaging ​

The built artifact — the wheel, tarball, or crate you actually publish — ships no test files. This page is the complete record of the check: why it reads the artifact instead of the source tree, what it scans for, how the job gets an artifact, and its configuration surface.

Why this check exists ​

Colocated unit tests are the standard's core structural move, and they have one cost: the tests live in src/, exactly where a naive build scoops them up. This check settles that cost — and it reads the built artifact because that's the only honest place: whether tests ship is a property of the build configuration (the wheel's include list, the npm files field, the Cargo exclude), and the artifact is where a build-config regression becomes visible. A source-tree check would pass forever while a changed manifest quietly started shipping *_test.py to every consumer.

The build-then-scan design (#335) makes the check run without a bespoke build job: the job derives the build from the package's own manifest, runs it, and scans what it wrote.

What it enforces ​

The check unpacks each distribution and scans for the language's test pattern:

  • Python — no *_test.py in the wheel (.whl) or sdist (.tar.gz).
  • TypeScript — no *.test.* in the npm pack tarball (.tgz).
  • Rust — no crate-root tests/ directory in the .crate. Inline #[cfg(test)] units compile out of the consumer artifact for free; only the integration tests/ needs a Cargo exclude.

How the job gets an artifact ​

In order:

  1. A named artifact. A built artifact you upload and name via the packaging_artifact input is downloaded and scanned as-is — the job builds nothing. An artifact holding no recognized distribution fails the job.

  2. The derived build. Otherwise the job derives the distribution build from the package's own manifest, provisions the toolchain, runs it at the derived package root, and scans the result:

    • Python (a pyproject.toml with a [project] table) — uv build, scanning dist/.
    • TypeScript (a package.json) — pnpm install --frozen-lockfile (or npm ci) at the package root, then <pnpm|npm> pack --pack-destination dist, which runs the package's own prepare / prepack lifecycle. The install runs for every TypeScript package the job builds, so a lifecycle hook that invokes a devDependency's bin finds it in node_modules.
    • Rust (a Cargo.toml with a [package] table) — cargo package, scanning target/package/, redirected with --target-dir target for a workspace member so the crate lands at the package's own target/package/.

    The primary language is derived from the manifest — a PyO3 binding publishes a Python wheel, a napi binding an npm tarball — so a binding's second, private manifest doesn't misroute the build.

  3. A committed dist/. When the manifest structurally can't state a build (a workspace-only Cargo.toml, a non-[project] pyproject), a conventional dist/ already committed at the package root is scanned in place.

When it runs ​

When any of the three sources above holds; skipped, never failed otherwise, so the drop-in is safe on a repository that hasn't built anything yet. The gates input names it packaging. It needs a testing-conventions release whose detect derives the build — a version pinned older falls back to locate-or-skip.

Discovering the distributions ​

Point the CLI at a directory and it finds every built distribution under it, recursively:

sh
npx testing-conventions packaging dist/

Each one is checked under the language its file name names:

File nameLanguageScanned for
*.whlPython*_test.py
*.tar.gzPython*_test.py
*.tgzTypeScript*.test.*
*.crateRusta crate-root tests/ directory

So one invocation covers a root holding a wheel, an sdist, a tarball and a crate at once, and the run ends by naming how many it checked. A root holding none of the four fails, naming the four it looked for: an empty dist/ is a misconfigured build, not a pass.

--language names the convention for a single path. Use it for an already-unpacked artifact root, which carries no extension to read, and to check a distribution whose name follows the other ecosystem's convention:

sh
npx testing-conventions packaging build/unpacked --language typescript
npx testing-conventions packaging dist/widget-0.1.0.tar.gz --language typescript

That second form is the one asymmetry here. A Python sdist and an npm tarball are the same format — a gzipped tar — and the file name is the only signal either carries, so .tar.gz reads as Python and .tgz as TypeScript, the names uv build and npm pack write. A tarball named against its ecosystem's convention is checked under the other language until you say otherwise.

Configuration ​

  • packaging_artifact — the workflow input naming an uploaded artifact to scan as-is.
  • build_command — the one-line declaration for a compile-before-pack step npm doesn't standardize: npm runs prepare / prepack on pack, but the build script's name (build in one package, compile in the next) is yours to state. The job provisions the toolchain of the language it's packaging; a build_command that reaches into a sibling package in another language names that ecosystem's own setup, inline, as part of the command.

The check honors no exemption rules — a test file in the artifact is always a violation.

Learn more ​