---
url: >-
  https://thekevinscott.github.io/testing-conventions/reference/checks/packaging.md
description: >-
  The packaging check — the built artifact ships no test files: what each
  registry's artifact is scanned for, the derived build per language, and the
  three ways the job gets an artifact.
---

# `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](/reference/workflow#inputs) 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](/monorepo#source-vs-the-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](/reference/workflow#inputs) 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 name | Language | Scanned for |
| --- | --- | --- |
| `*.whl` | Python | `*_test.py` |
| `*.tar.gz` | Python | `*_test.py` |
| `*.tgz` | TypeScript | `*.test.*` |
| `*.crate` | Rust | a 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`](/reference/workflow#inputs) — the workflow input naming an uploaded
  artifact to scan as-is.
* [`build_command`](/reference/config#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](/reference/config#a-command-naming-another-ecosystem-s-build), inline, as part of the
  command.

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

## Learn more

* [Explanation — Packaging](/explanation/packaging): the cost colocation imposes, in full.
