The package-content policy

A release publishes wheels of two kinds and one sdist. This page states what a wheel may hold, what it may never hold, and what has to be there; .github/scripts/verify_wheel_contents.py enforces every rule down to the last section, and tests/wheel_contents_test.py compares the two in both directions for the member lists, so this page cannot state a member rule the script does not have, and the script cannot grow one this page does not state. The section on what the compiled file is built with is not compared with the script; the hardening tests in the same file test the script alone. The prose around the lists is prose, and is checked by nobody.

test.yml’s check-dist job runs the script on every wheel it downloads from build-cibuildwheel, build-dynamic and build-windows — the artifacts release.yml’s own test job produces and the publish jobs upload, so this judges the files that actually reach PyPI rather than a copy of them.

Two kinds of wheel

scripts/hatch_build.py builds one or the other, never both, and tags each so the difference is legible from the file name: a static wheel carries one compiled extension, _btclib_secp256k1, linked against libsecp256k1 and tagged for the interpreter that built it (cpNN-cpNN-<platform>); a dynamic wheel compiles no C at all, carries the ABI-mode module _btclib_secp256k1.py and the shared libsecp256k1 library it dlopens at import, and is tagged py3-none-<platform> because neither file is interpreter-specific. The script reads the wheel’s own file name to tell them apart — the same distinction scripts/hatch_build.py draws when it writes the tag in the first place.

What may never be in the wheel

Checked against every member of btclib_secp256k1/ and the top-level artifact, not against .dist-info — already validated member by member, against its own allowlist, above: FORBIDDEN_SUFFIXES and FORBIDDEN_NAMES.

  • .pth — executed at interpreter startup, before the first import

  • .pyc — a compiled module whose source nobody reviewed

  • .egg, .tar.gz, .whl, .zip — an archive inside an archive, that is, a second package installing with the first

  • sitecustomize.py, usercustomize.py — the two names Python imports for their side effects alone, at startup, from the root of site-packages

A __pycache__ directory is refused too, by name rather than by suffix.

What the wheel may hold

Three regions, and nothing outside them.

Under btclib_secp256k1-<version>.dist-info/ — WHEEL_METADATA_FILES, under licenses/, WHEEL_LICENSE_FILES, and, under sboms/, WHEEL_SBOM_FILES:

  • METADATA, RECORD, WHEEL — what hatchling writes for a package configured as this one is. No top_level.txt: that file is setuptools’s, and this build backend is hatchling

  • AUTHORS.md, LICENSE — project.license-files in pyproject.toml, copied into licenses/ verbatim. Not COPYRIGHT: that is a repository file, and pyproject.toml says why beside the setting

  • secp256k1/COPYING — libsecp256k1’s MIT notice, by the same setting, under its own directory inside licenses/. secp256k1-zkp’s notice is not listed, and pyproject.toml says why beside the setting

  • btclib_secp256k1.cdx.json — the CycloneDX bill of materials PEP 770 places there, written while the wheel is built: the distribution, its dependencies and the vendored libraries this build compiled, each at the commit its gitlink pins. .github/scripts/generate_sbom.py says what it holds, and scripts/hatch_build.py why a build from the sdist writes none

Under btclib_secp256k1/ — exactly the files this checkout’s own src/btclib_secp256k1/ directory has, source and py.typed alike. The script reads that directory fresh rather than carrying a copy of its file list, which is what check-wheel-contents --package btclib_secp256k1 would check too, asked here by hand instead of through that flag: the flag also judges the third region below, which is not part of any package tree and is not a mistake for being outside one.

At the wheel’s own root, one artifact set per kind, and nothing else — a second extension, a stray file, a test package shipped by accident, all fail, whichever kind the wheel is.

A static wheel: exactly one file whose name starts with _btclib_secp256k1 and ends in a compiled-extension suffix, EXTENSION_SUFFIXES:

  • .so — Linux and macOS

  • .pyd — Windows

A dynamic wheel: exactly _btclib_secp256k1.py, the ABI-mode module scripts/cffi_build.py emits, and exactly one file whose name starts with libsecp256k1 and ends in a shared-library suffix, SHARED_LIBRARY_SUFFIXES:

  • .so — Linux

  • .dylib — macOS

  • .dll — Windows

Never a versioned name from the symlink chain CMake also leaves behind, such as libsecp256k1.so.2 — scripts/cffi_build.py itself skips those when it copies the artifact into the wheel, keeping to one shared library rather than the chain of names one file on disk can answer to.

What has to be in there

Every file btclib_secp256k1/ may hold, per the diff above, and the kind-appropriate top-level artifact, checked for size as well as presence: an artifact scripts/cffi_build.py failed to finish copying would still be a member of the archive, at zero bytes, and no other check here or in check-wheel-contents reads a member’s size at all.

What the compiled file has to be built with

The extension of a static wheel and the shared library of a dynamic one are read back for the hardening scripts/cffi_build.py builds them with, from the ELF, Mach-O or PE file itself. hardening_gaps names one phrase per property missing, and a wheel that lacks any is refused:

  • Linux, ELF: the stack protector (an import of __stack_chk_fail), _FORTIFY_SOURCE (an import of a __*_chk function), an immediate binding (-z now), a read-only relocation segment, and the CET marking on x86_64. A musllinux wheel is not asked for the fortified function, which musl does not have. The marking is asked only of a wheel whose C runtime carries it, since the linker writes it only if every input does: of an x86_64 glibc wheel, and of no musllinux or aarch64 wheel

  • macOS, Mach-O, every architecture of a universal file: the stack protector and _FORTIFY_SOURCE

  • Windows, .pyd from MSVC: address space layout randomization, DEP, high entropy address space layout, control flow guard and the security cookie. The cookie is in every image the C runtime links, so it shows /GS only loosely

  • Windows, .dll from mingw-w64: the first three of those

The sdist is not this policy’s subject

A MANIFEST.in-based sdist is an include list: a file the tree gains and MANIFEST.in does not name is a file the sdist silently drops, which is the class of check an include list needs and this repository does not. [tool.hatch.build.targets.sdist] exclude in this repository’s pyproject.toml is the opposite shape — a file the tree gains ships by default, and has to be named to be left out — so the failure mode here is an sdist too wide rather than one silently narrow, and modelling the members the vendored secp256k1/ submodule and this repository’s own tooling contribute would be an allowlist nobody could read, let alone maintain, for a question the exclude list itself already answers by construction. What that leaves unchecked — an exclude pattern naming a file a build still needs — fails the build outright rather than shipping silently, which is a different question with a different check to answer it.