Skip to content

Packaging ​

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. The packaging check settles that cost: it inspects the built artifact — the thing you actually publish — and fails if any test file shipped.

Why the built artifact, not the source tree ​

Every other check reads the source tree; this one reads the wheel, tarball, or crate. That's the only honest place to check: 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.

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.

One signal: the file name ​

A built distribution arrives as a file, and the only thing that file carries about the ecosystem that produced it is its name. So the check reads the extension — .whl and .tar.gz are Python, .tgz is TypeScript, .crate is Rust, the names uv build, npm pack and cargo package write — and one invocation over a directory covers everything built into it, whatever mix of the three it holds. A directory holding none of them fails rather than passing, because a build that wrote nothing is the failure this check exists to catch.

The rule reads the same in all three languages, and it has one edge worth naming: a Python sdist and an npm tarball are the same format, a gzipped tar, and nothing inside either says which registry it is bound for. The extension is the whole distinction. Point the check at a gzipped tar named the other ecosystem's way and --language is how you say so.

How the workflow gets an artifact to scan ​

In the workflow the check is build-then-scan: the packaging job derives the distribution build from the package's own manifest, runs it, and scans what it wrote. The build the tool derives, from source and the manifest alone:

  • Python (a pyproject.toml with a [project] table) → uv build, writing dist/*.whl and *.tar.gz. The PEP 517 build resolves its own build dependencies and compiles a maturin/PyO3 core along the way.
  • TypeScript (a package.json) → <pnpm|npm> pack --pack-destination dist, which runs the package's own prepare / prepack lifecycle. The job installs the package's dependencies first, so those hooks run against a populated node_modules. A compile that lives in a bare build script — a name npm doesn't standardize — is named once in [typescript].build_command and runs between the install and the pack.
  • Rust (a Cargo.toml with a [package] table) → cargo package, writing target/package/*.crate. When the crate is a member of an ancestor Cargo workspace, Cargo always resolves the target directory — and so cargo package's output — at the workspace root, regardless of the invoking working directory. The derived command detects that from the manifests alone and redirects with --target-dir target, so the crate lands at the package's own target/package/, exactly where the job already scans, rather than at the workspace root where the scan would never see it.

So a native monorepo adopts the gate with gates: ["packaging"] and no bespoke build job: the job provisions the toolchain, builds, and scans on its own. The primary language is derived from the manifest — a PyO3 binding (pyproject.toml + Cargo.toml) publishes a Python wheel, a napi binding (package.json + Cargo.toml) publishes an npm tarball — so a binding's second, private manifest doesn't misroute the build.

The job still runs when you supply a prebuilt distribution instead: a built artifact you upload and name via the packaging_artifact input is scanned as-is, and a conventional dist/ already committed in the checkout is scanned in place. When the manifest structurally can't state a build (a workspace-only Cargo.toml, a non-[project] pyproject), the job falls back to that committed dist/. It is skipped — never failed — when none of the three holds, so the drop-in is safe on a repository that hasn't built anything yet. A named artifact holding no recognized distribution is a misconfigured upload, and that fails.