Shipping automation: locks, artifacts and runtimes
Declare, lock, build, verify, distribute and isolate: wheels, zipapps and containers.
tar -xzf scr-package.tar.gz, which creates scr-package/. SHA-256: 02194ae9575ded6a43a33c09f96b99530f9c480701c76653f874c19efb5d3e16A tool you ship runs on machines you do not control, weeks after you tested it, and what runs there should be the thing you tested. You will lock a project with uv for every platform at once and catch a lock that no longer matches its pyproject.toml, export the lock to the PEP 751 pylock.toml standard and install from it with pip, choose between a wheel, a zipapp and a container for delivery (and see exactly where a zipapp breaks), install the tool for people who only run it, and build a container image from a base pinned by digest that runs as a non-root user and refuses a dependency that does not match its lock.
Refresher: py-sec "Packaging, pinning and auditing your tool" owns the basics, and this lesson assumes them: a src layout, pyproject.toml with dependency ranges, a console-script entry point, python -m build, a hash lock from pip-compile --generate-hashes installed with pip install --require-hashes, pip-audit and pipx. The tool is certcheck, which prints how many days each PEM certificate has left. The container part assumes you can read a Containerfile (FROM picks the base image, RUN runs a build command, COPY adds files, USER and ENTRYPOINT set who runs what); the site's "Docker for beginners" course covers them. Unpack the lesson files in your home directory: scr-package/ holds certcheck's source, pyproject.toml, the Containerfile, containers.conf, pyz_main.py and a copy of blsync. Commands run in ~/scr-package, on Ubuntu 26.04 with its Python 3.14.4 (the same lab passes on upstream 3.14.7).
Six jobs between source and a running tool
Shipping breaks down into six jobs. Declare: pyproject.toml says what the tool needs, as ranges. Lock: a lock file records the exact versions and file hashes that were resolved and tested. Build: the source becomes an artifact, here a wheel. Verify: the installer checks every file against the lock and stops on a mismatch. Distribute: the artifact reaches its users as a wheel, a single-file zipapp or a container image. Isolate: the tool runs in its own environment (a venv, a tool environment or a container) with no more privilege than it needs.
"""Print the days each certificate has left; exit 1 if any is inside the warning window."""import argparseimport sysfrom datetime import UTC, datetimefrom pathlib import Pathfrom cryptography import x509OK, EXPIRING, USAGE, UNREADABLE = 0, 1, 2, 3 # USAGE is argparse's own exit statusdef days_left(pem: bytes, now: datetime) -> int:cert = x509.load_pem_x509_certificate(pem)return (cert.not_valid_after_utc - now).daysdef main(argv: list[str] | None = None) -> int:parser = argparse.ArgumentParser(prog="certcheck", allow_abbrev=False,epilog="exit status: 0 ok, 1 expiring, 2 usage, 3 unreadable (3 wins over 1)")parser.add_argument("--warn-days", type=int, default=30, metavar="N")parser.add_argument("certs", nargs="+", type=Path, metavar="CERT")args = parser.parse_args(argv)now = datetime.now(UTC)status = OKfor path in args.certs:try:left = days_left(path.read_bytes(), now)except (OSError, ValueError) as err: # missing file, or not a PEM certificateprint(f"certcheck: {path}: {err}", file=sys.stderr)status = UNREADABLE # keep going: one bad file must not hide the certificates after itcontinueflag = ""if left < args.warn_days:status, flag = max(status, EXPIRING), " (expiring)"print(f"{path.name}: {left} days left{flag}")return status
[build-system]requires = ["hatchling==1.32.4"]build-backend = "hatchling.build"[project]name = "certcheck"version = "0.1.0"description = "Report how many days each PEM certificate has left"requires-python = ">=3.14"dependencies = ["cryptography>=50"][project.scripts]certcheck = "certcheck.cli:main"[tool.hatch.build.targets.sdist]include = ["/src"]
certcheck has one dependency, cryptography, declared as a floor (>=50) and no ceiling; the next lesson explains where the floor comes from. That dependency matters here because it is not pure Python: it ships compiled code, which decides what kind of artifact can carry it. Two throwaway certificates to check, one valid for 400 days and one for 10 (-keyout /dev/null discards the private keys; the ----- lines openssl prints while generating them are left out):
A lock for every platform, with uv
uv is a Python package and project manager written in Rust. It is not in the Ubuntu archive, so install one pinned release the way you would in CI: the official release file from GitHub, checked against a SHA-256 you recorded, unpacked into the project's bin/ instead of system-wide. (Piping the documented installer script into sh runs whatever the server sends that day.)
uv lock resolves the ranges in pyproject.toml and writes uv.lock:
Four packages: the project, cryptography and the two packages it needs, cffi and pycparser. The last number counts the cryptography wheels in the lock: 39 of them, for Linux on x86-64, ARM and other CPUs (glibc and musl), macOS, Windows and the free-threaded 3.14t build, each with its SHA-256. The difference from the pip-compile lock in py-sec is the resolution, not the hashes. pip-compile resolves for the Python and platform it runs on, so a dependency that applies only elsewhere (an environment marker such as sys_platform == "linux" in some package's metadata) is simply missing from a lock made on a Mac. uv resolves once for every platform and Python version the project allows and keeps the markers, so one lock is valid on the laptop and on the server. uv sync --locked then builds the venv from the lock, and refuses to run if the lock is out of date:
uv sync created .venv, took the wheels for this machine out of the lock, and installed certcheck itself from the source tree. The tool exits 1 because api.pem is inside the 30-day window. With a missing file first in the list it still checks api.pem, then exits 3: one unreadable file must not hide a certificate that expires tomorrow, and "could not check" outranks "expiring". The last command shows the two compiled extension modules that came with cryptography; they come back when we try a zipapp. Now the failure a lock gate exists for: someone adds a dependency to pyproject.toml and forgets to relock.
uv lock --check resolves again, sees that the result would differ from uv.lock, and exits 1 without touching the file (the step then put the original pyproject.toml back). Run it in CI, and a change to the declared dependencies without a new lock cannot merge.
pylock.toml: the lock format any installer can read
uv.lock is uv's own format. PEP 751 defines a standard one, pylock.toml, so a lock made by one tool can be installed by another. uv keeps uv.lock as its source of truth and exports the standard file on demand. pip 26.1 and later can install from it, marked experimental. The first attempt fails in a useful way:
By default the export includes the project itself as an editable install of the current directory. A lock with hashes switches pip into hash-checking mode, and a directory has no single file to hash, so pip refuses. Export only the dependencies (--no-emit-project) and install the project separately, from its wheel:
pip warned that pylock.toml support is experimental, then chose the one cffi and one cryptography wheel that fit this machine from the list in the file and checked their hashes. Because the format may still change in pip, keep uv.lock (or a pip-compile requirements.txt) as the lock you review and commit, and treat pylock.toml as an export. The container build below uses the oldest and most widely understood export, a hashed requirements.txt:
Three pinned lines and 91 hashes (every wheel and sdist of each version). Whichever lock you keep, choose one as the source of truth and generate the rest from it. pip-compile from py-sec is still a sound choice for a single-platform service; pip lock writes pylock.toml directly but, like reading it, is experimental and resolves only for the current platform.
Artifacts: a wheel, a zipapp, and where a zipapp stops
uv build built an sdist and a wheel with the pinned hatchling backend. The wheel is py3-none-any: pure Python, any platform, because it contains only certcheck's own three modules and its metadata. Its dependencies are not inside; they are installed next to it, from the lock, for the target's platform. That is why a wheel plus a lock is the default artifact for Python tools.
A zipapp is the standard library's single-file format: a zip archive with a __main__.py and a shebang line, which Python runs directly (python3 -m zipapp, no extra tool). It still needs a Python on the target, and it has two sharp edges. The first shows with blsync, the standard-library-only tool from "Designing a CLI tool" earlier in this course:
The same missing config file gives exit status 78 (EX_CONFIG) from python3 -m blsync and 0 from the zipapp. With -m blsync.cli:main, zipapp writes a __main__.py that calls main() and throws away its return value, so every failure that main() reports by returning a number looks like success to cron or CI. Supply your own __main__.py that passes the value on:
# __main__.py for a zipapp: pass main()'s return value on as the exit status.from blsync.cli import mainraise SystemExit(main())
Exit status 78 again, from a 13.6 KB file you can copy to any machine with Python 3.14. The second edge is compiled code. Here is certcheck with cryptography installed into the archive from the hashed lock:
Python imports modules from a zip archive only when they are Python source or bytecode; it cannot load an extension module (_rust.abi3.so) from inside one. The import found only a directory of type stubs named _rust and failed. A zipapp is for pure-Python tools. pex goes further: a .pex file is a zipapp that carries dependency wheels, compiled ones included, and unpacks them into a cache (PEX_ROOT) to run them, so it works only on the platforms you built it for (--platform adds more). That is one more tool and one more cache on every host, worth it only when a single file is a hard requirement. (shiv, which older guides use for the same job, has had no release since November 2024.) For a tool with compiled dependencies, ship the wheel and its lock, or a container.
For people who only run the tool
py-sec showed pipx: one venv per application, only its commands on PATH. uv tool install is the same idea with the uv you already have:
uv created a venv for certcheck under ~/.local/share/uv/tools and put one launcher in ~/.local/bin; the warning says that directory is not on this shell's PATH yet (Ubuntu's ~/.profile adds it at the next login once it exists). Like pipx, uv tool install resolved the wheel's ranges when it ran; it did not read uv.lock, so the versions match the lock today only because nothing newer exists. For a laptop that is fine. For a server, install from the lock.
A container image you can rebuild
A container ships the runtime too: the interpreter, the OS libraries and the tool, in one image. This lab uses podman 5.7.0 from the Ubuntu archive (sudo apt install podman), running rootless as deploy. A rootless podman normally asks the user's systemd instance to manage its cgroups; this shell (like cron, su or many CI runners) has no systemd user session, so a two-line containers.conf tells podman to manage cgroups itself:
# Rootless podman from a shell with no systemd user session (cron, su, runuser, many CI runners):# manage cgroups directly instead of asking a per-user systemd that is not there.[engine]cgroup_manager = "cgroupfs"
cgroupfs and rootless=true confirm the setup. The one warning is podman failing to reach a per-user D-Bus that this session does not have; containers run normally without it. Next, the base image. A tag such as python:3.14-slim is a name that the publisher moves on every rebuild; a digest is the SHA-256 of the content and cannot move. Resolve the tag to its digest once, record it, and build from the digest:
podman pull printed the local image ID, then the image carries two digests. The first, sha256:51daf..., is the index: the list of per-architecture images the official python image on Docker Hub publishes under one name. The second is the ARM64 image inside it that this machine pulled; the listing of the index shows it next to the amd64, s390x and other builds. The Containerfile pins the index digest the tag pointed to when this lab was written (the same value podman reports above), so one line works on every architecture and never changes under you. To take a newer base, you resolve and review the new digest on purpose. That cuts both ways: a pinned base also keeps its old OS packages, security fixes included, until someone moves the pin. Let a bot such as Renovate or Dependabot propose the new digest as a reviewed change, rebuild on a schedule, and scan the image's OS packages, which the next lesson counts in its SBOM.
# python:3.14-slim from Docker Hub, pinned to the index digest the tag pointed to on 2026-09-28.# The tag moves on every rebuild; the digest names these exact bytes for every architecture.FROM docker.io/library/python:3.14-slim@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d# The account the tool runs as: no login shell, no home, not root.RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin certcheck# Dependencies from the hash lock: a file whose SHA-256 is not in the lock fails the build.# The venv gets no pip of its own; the base image's pip installs into it (--python), so the# runtime environment holds only the tool and what it needs.COPY requirements.txt /tmp/requirements.txtRUN python -m venv --without-pip /opt/certcheck \&& python -m pip --python /opt/certcheck/bin/python install --no-cache-dir \--disable-pip-version-check --require-hashes -r /tmp/requirements.txt# Then the tool's own wheel, with nothing else resolved.COPY dist/certcheck-0.1.0-py3-none-any.whl /tmp/RUN python -m pip --python /opt/certcheck/bin/python install --no-cache-dir \--disable-pip-version-check --no-deps /tmp/certcheck-0.1.0-py3-none-any.whl \&& rm /tmp/certcheck-0.1.0-py3-none-any.whl /tmp/requirements.txtUSER 10001ENTRYPOINT ["/opt/certcheck/bin/certcheck"]
# The build sees only what the Containerfile copies: no venvs, caches, keys or certificates.*!requirements.txt!dist/certcheck-0.1.0-py3-none-any.whl
The image gets a user with UID 10001, no home and no login shell, and USER 10001 makes every process in it run as that user. Dependencies come from the hashed requirements.txt into a venv, then the wheel goes in with --no-deps, as on a server. The venv is created without pip, and the base image's pip installs into it with --python, so the runtime environment carries no installer it does not need. .containerignore is an allow-list: the build context contains only the lock and the wheel, so .venv, the caches and the certificates in this directory never reach the image.
The image runs the tool as uid=10001 with exit status 1 for the expiring certificate, reading the certificates through a read-only mount. Now the failure the lock is there for. A teammate "just bumps" a version in requirements.txt by hand, without regenerating the lock:
pip downloaded cryptography 50.0.0, found its hash among none of the 40 the lock allows for that line (they belong to 50.0.1), and stopped. The RUN step failed, so the build failed and no image was tagged. The same check stops a file that was swapped on a mirror. It cannot stop a package that was already malicious when you locked it: the lock records what was resolved that day, and the next lesson adds the gates that look at what the lock contains.
Try this
Raise the floor to cryptography>=50.0.1 in pyproject.toml, run ./bin/uv lock --check and confirm it exits 1, run ./bin/uv lock, and check that --check now exits 0 and that the requires-dist line in uv.lock shows the new specifier. Then prove the image's user cannot change the installed tool: run podman run --rm --entrypoint /opt/certcheck/bin/python certcheck:0.1.0 -c "open('/opt/certcheck/x', 'w')". Expect PermissionError: [Errno 13] Permission denied and exit status 1, because /opt/certcheck was created by root during the build and the container runs as UID 10001.
When you are done, remove the image; the next lesson reuses podman, containers.conf and the base image, and removes them at its end:
Takeaway
Keep one lock as the source of truth, fail CI when it no longer matches pyproject.toml, and install only from it with hashes, in a venv, a tool environment or an image built from a base pinned by digest that runs as a non-root user. Ship a zipapp only for pure-Python tools, with a __main__.py that passes on the exit status.
pip-compile --generate-hashes on macOS laptops. One dependency declares pyinotify; sys_platform == "linux" in its metadata. On the Linux server, pip install --require-hashes -r requirements.txt refuses to install. What is the cause, and the fix?./tool.pyz --config /etc/tool.toml, built with python3 -m zipapp src -m tool.cli:main. The config file was deleted weeks ago, main() prints an error and returns 78, yet the scheduler shows every run as successful. Why?__main__.py produces; raise SystemExit(main()) gives 78.__main__.py that ends with raise SystemExit(main()).Containerfile starts FROM python:3.14-slim, and a rebuild of an unchanged repository today produces an image with different OS libraries than last month's. What is the most direct fix?