Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

GitHub Workflow Status

Tom’s Pants backends

This is the documentation for my Pants backend, which I use for my own projects - especially managing my own cloud, but also building open-source software.

Please see the documentation for more information!

See CONTRIBUTING.md for verifying changes and the Pants upgrade checklist.

Binary tool versions

The plugin defaults and download metadata were refreshed on 2026-10-16; crane was added on 2026-10-21 and zstd on 2026-10-22. Each known-version entry pins both SHA-256 and byte size. Versions can be selected with the corresponding subsystem’s --version option.

ToolDefaultAdditional release lines covered
kubectl1.37.11.36.5, 1.35.9, 1.34.12
Kustomize5.8.35.7.1, 5.0.3, 4.5.7
crane0.22.1Latest only
mdBook0.5.4Latest 0.4 (0.4.52), plus existing historical entries
Bitwarden CLI2026.10.02026.9.1, plus existing historical entries
zstd1.5.6-bcr2Latest only

The OCI plugin uses crane to pull, publish, and mirror images. It writes packaged images as OCI archives itself, without an external tool.

Kubernetes and Kustomize

For kubectl, record the latest patch of each currently maintained minor and remove entries when that minor reaches EOL. Consult the official release and EOL dates on every refresh. Kubernetes 1.34 remains in the table until its EOL on 2026-10-27.

The selected kubectl must be within one minor of the cluster’s API server, according to the version skew policy. For example, use --kubectl-tool-version=v1.35.9 for a cluster requiring that client minor.

Standalone Kustomize has its own release versions. The Kustomize embedded in kubectl is a separate dependency; see the upstream kubectl/Kustomize version mapping. The standalone table intentionally retains the final 4.x release and selected 5.x minors for compatibility. Inclusion in the table does not promise ongoing upstream maintenance of an older release line.

Binary providers and platforms

Kubernetes, Kustomize, crane, mdBook, and Bitwarden use official release artifacts. crane comes from the go-containerregistry release tarballs. zstd’s upstream publishes Windows binaries only, so its downloads use the static builds from aspect-build/zstd-prebuilt, which Bazel’s bazel-lib uses as well. The OCI plugin runs it to read zstd-compressed .deb packages, such as Ubuntu’s.

The refreshed cross-platform tools include Linux and macOS on x86-64 and ARM64. Recent mdBook releases use native Apple Silicon binaries, while its retained older releases keep their Intel/Rosetta download mapping. mdBook 0.5 rejects the removed [book].multilingual setting; remove it from existing book configurations when switching from 0.4. Odin and odinfmt are unchanged by this refresh.

Verification

The subsystem download tests cover every recorded version/platform pair and exercise Pants’ URL generation, hash/size verification, and archive extraction. Run the five subsystem_test.py files under k8s, kustomize, oci, mdbook, and bitwarden after updating the tables. Successful downloads do not establish runtime compatibility on a different host architecture.

Kustomize backend for Pants

This release targets Pants 2.31.0. Install it with pants-backend-kustomize==0.7.0 in [GLOBAL].plugins in pants.toml.

Warning This plugin is in development. No stability is guaranteed! Contributions welcome.

This backends implements targets for kustomize templates.

  • kustomize for overlaying state ontop of raw kubernetes files

Tool versions

The default is standalone Kustomize v5.8.3. Versions v5.7.1, v5.0.3, and v4.5.7 are also recorded for Linux/macOS on x86-64 and ARM64. Select one with --kustomize-tool-version=v4.5.7 or [kustomize-tool].version. Standalone Kustomize versions are independent of the Kustomize embedded in kubectl; older entries provide compatibility choices rather than a promise of ongoing upstream maintenance.

Planned and missing features

  • Key/secret/… generation from built artifacts

Targets

There’s currently one target.

kustomize

A code-generation target for converting a bundle of kubernetes files into a single multi-docuent YAML file with state injected from other Pants targets.

kustomize(
    name="kustomize",
    sources=[
        "deployment.yaml",
        "server.py",
        "service.yaml",
        "namespace.yaml",
		"kustomization.yaml",
    ],
    dependencies=[":bin"],
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
sourcesResources used by this targetRequired
dependenciesTargets to package and pass to the build context, as well as bases[]
decsriptionA description of the target
tagsList of tags[]

For dependencies, the builder will replace labels in the kustomization.yaml with the path of the built package.

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

0.7.0 - 2026-10-17

  • Target Pants 2.31.0.

  • Require pants_backend_k8s>=0.7.0.

  • Replace remaining union-dispatched Get calls with polymorphic call-by-name rules.

  • Default to Kustomize v5.8.3; backfill v5.7.1 and v5.0.3, and retain the final 4.x release, v4.5.7, for all four Linux/macOS architectures.

  • Add download checks for every recorded version/platform pair.

0.6.1 - 2026-10-16

  • Replace pdm-pep517 with pdm-backend to produce correctly normalized source archive names for PyPI.
  • Require pants_backend_k8s>=0.6.1.

0.6.0 - 2026-10-16

  • Now targets pants version 2.30.
  • Migrate rules to the Pants call-by-name API.

0.5.0 - 2025-05-14

  • Now targets pants version 2.24.

0.4.0 - 2024-09-19

  • Now targets pants version 2.22.
  • Add tailor support
  • Import git to sandbox during expansion to support github downloads
    • submodule check seems to fail so it might help to add ?submodules=false to the link to disable it

[0.3.0] - 2023-11-19

  • Target pants version is now 2.18.0

[0.2.0] - 2023-06-18

  • Target pants version is now 2.16.0, with support for 2.15.0.

[0.1.2] - 2022-12-06

  • Add support for specializing how other packages are injected into Kustomize files.

[0.1.1] - 2022-11-17

  • Move to new repository

[0.1.0] - 2022-11-13

Initial release.

Kubernetes backend for Pants

This release targets Pants 2.31.0. Install it with pants-backend-k8s==0.7.0 in [GLOBAL].plugins in pants.toml.

Warning This plugin is in development. No stability is guaranteed! Contributions welcome.

This backends implements targets for working with Kubernetes clusters using raw YAML.

Tool versions

The default is kubectl v1.37.1, with v1.36.5, v1.35.9, and v1.34.12 also recorded for Linux/macOS on x86-64 and ARM64. Select a version with --kubectl-tool-version=v1.35.9 or [kubectl-tool].version in pants.toml. Follow the Kubernetes release/EOL dates when choosing a minor; kubectl must be within one minor of the API server. The 1.34 entries are due for removal after their 2026-10-27 EOL.

Targets

There’s currently three targets for pants-backend-k8s:

k8s_source

A file that can be passed into other k8s fields that are not source fields. For example, k8s_object.template.

k8s_source(
    name="namespace.yaml",
    source="namespace.yaml",
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
sourceThe raw fileRequired
decsriptionA description of the target
tagsList of tags[]

This’ll eventually be automated like other rules once a suitable heuristic for generation with tailor is found. PRs welcome!

k8s_object

Input for a kubernetes command, either generated via kustomize or via k8s_source.

k8s_object(
    name="k8s",
    description="the chat backend"
    template=[":kustomize"],
    namespace="chat-app",
    cluster="prod",
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
templateThe target to act onRequired
namespaceNamespace to targetOptional, will use default kubectl namespace
clustercluster to targetOptional, will use default kubectl cluster
decsriptionA description of the target
tagsList of tags[]

k8s_object is a generator for kubernetes target parametrized by the potential commands that are available: apply, create, get, describe, replace, and delete.

k8s_objects

A collection of kubernetes objects that should be managed together.

k8s_objects(
    name="my-service",
    description="all components of service-x"
    objects=[":namespace", ":deployment"],
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
objectsk8s_object targets that should be managedRequired
decsriptionA description of the target
tagsList of tags[]

Like k8s_object, k8s_objects is a generator for parametrized targets for the commands that are available: apply, create, get, describe, replace, and delete.

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

0.7.0 - 2026-10-17

  • Target Pants 2.31.0.

  • Replace remaining union-dispatched Get calls with polymorphic call-by-name rules.

  • Default to kubectl v1.37.1; include the latest patches of maintained minors v1.36.5, v1.35.9, and v1.34.12, and remove EOL v1.25.2.

  • Include Linux ARM64 binaries alongside Linux x86-64 and both macOS architectures.

  • Verify every recorded version/platform download, including macOS checksums that were incorrect in the previous table.

  • Track Kubernetes EOL dates when refreshing the table; 1.34 reaches EOL on 2026-10-27.

0.6.1 - 2026-10-16

  • Replace pdm-pep517 with pdm-backend to produce correctly normalized source archive names for PyPI.

0.6.0 - 2026-10-16

  • Now targets pants version 2.30.
  • Migrate rules to the Pants call-by-name API.

0.5.0 - 2025-05-14

  • Now targets pants version 2.24.

0.4.0 - 2024-09-19

  • Now targets pants version 2.22.
  • Kubeconfig files are no longer automatically picked up from the host. In order to support scripted provisioning and local configuration files, all targets now take a kubeconfig field. This can point to either kubeconfig target which uses a straight source or a generated, or a host_kubeconfig target which will attempt to load from ~/.kube/config. Both of these also allow you to specify default namespaces, contexts, clusters and users. All these fields can now also be specified on the object, whereas only cluster/context could before.

[0.3.0] - 2023-11-19

  • Target pants version is now 2.18.0

[0.2.0] - 2023-06-18

  • Target pants version is now 2.16.0, with support for 2.15.0.

[0.1.1] - 2022-11-17

  • Move to new repository

[0.1.0] - 2022-11-12

  • Initial release.

OCI backend for Pants

This release targets Pants 2.31.0. Install it with pants-backend-oci==0.10.0 in [GLOBAL].plugins in pants.toml.

Warning This plugin is in development. No stability is guaranteed! Contributions welcome.

This is a backend implementing support for building OCI images in pants; running them, and publishing them to container registries. Images are assembled by the plugin itself: layers, config, manifest and index are written by a small Python script run in a sandboxed process, deterministically and without any container tool. Beyond that, this plugin uses these tools:

  • oci-builder, a scie the plugin builds with pex from its own builder scripts and a pinned python-build-standalone Python. It is built once per machine and downloads that Python and pex’s science tool from GitHub the first time; on first use it unpacks into the oci_builder named cache (pants run uses ~/.cache/nce and ~/.cache/pex instead)
  • podman 4.0 or newer, installed by the user, for pants run, OCI repl and scripted build steps (on macOS, run and repl only, through podman machine); or Docker 23.0 or newer instead, with [oci].runner = "docker" (see Running with Docker)
  • crane for pulling, pushing and mirroring images

pants package writes each oci_image_build to dist/ as an OCI archive, <name>.tar, with the image named repository:tag. Load it with podman load -i <archive> or docker load -i <archive>.

Running images

Install podman on Linux and configure rootless execution, including /etc/subuid and /etc/subgid mappings for your user. Run Pants as an unprivileged user. Declarative packaging and registry publishing do not require podman; scripted build steps do (see below).

pants run examples/oci/debian:image runs the image’s entrypoint and default command. Arguments after -- replace its command arguments while keeping the entrypoint, for example pants run examples/oci/debian:image -- --help. terminal=True allocates a TTY; pants repl --shell=oci <image-target> also attaches stdin. Repl runs the image’s configured command, so select an image whose command provides the interaction you need.

Podman uses a dedicated oci_podman/v2 store under Pants’ named-cache directory, with the portable vfs storage driver. Its runtime state lives in your runtime directory, $XDG_RUNTIME_DIR (or /run/user/<uid>), named by a hash of the store path to keep podman’s paths short; podman ties a store to that location, so pants run and scripted build steps must see the same XDG_RUNTIME_DIR. Exited containers are removed; imported images and layers remain cached across runs. This store can grow and is separate from your default podman store. A run or build step that is interrupted (Ctrl-C, a timeout) can also leave its container and image behind. To clear all of it, imported bases included, run this while no Pants build is running; the files belong to your subordinate user ids, hence podman unshare:

podman unshare rm -rf <named-caches-dir>/oci_podman

The plugin’s own flags select the store, runtime directory, vfs driver and cgroupfs cgroup manager, and override any podman configuration. Otherwise pants run and scripted build steps read the same podman configuration: the system files and your ~/.config/containers. Scripted build steps don’t see environment variables that select other configuration files, such as CONTAINERS_CONF or XDG_CONFIG_HOME. Configuration isn’t part of a build step’s cache key, so after changing it, rebuild with --no-local-cache. CONTAINER_HOST and CONTAINER_CONNECTION are ignored: [oci].podman_connection alone decides whether podman runs locally or through a connection.

Running through podman machine (macOS) or another connection

On macOS, pants run and OCI repl run images through podman machine:

brew install podman
podman machine init
podman machine start

Podman is found on [system-binaries].system_binary_paths, which includes Homebrew’s /opt/homebrew/bin and /usr/local/bin. The podman.io installer and Podman Desktop install to /opt/podman/bin; add it there:

[system-binaries]
system_binary_paths = ["/opt/podman/bin", "<PATH>"]

With [oci].podman_connection empty, the default, podman’s default connection is used, which podman machine init sets up. Set it to a name from podman system connection list to use another machine or a remote podman service, on any platform; on Linux, run and repl then use it instead of the local store described above, while scripted build steps keep using the local store. The option only applies with [oci].runner = "podman". If the connection can’t be reached, the run fails with podman’s error and, when no podman machine exists or none is running, what to start.

Podman’s remote client can’t run an OCI archive directly, so each image is first loaded into the connection’s own store as localhost/pants-oci-run:<image digest>, then run with --rm. Arguments, terminal, stdin and exit status behave as with local podman. Loaded images are kept, so running the same image again starts without transferring it (two first runs of an image at once may both load it). Nothing else in that store is touched. These images accumulate as images change; remove them while nothing runs from them:

podman image ls --filter reference=localhost/pants-oci-run
podman image rm $(podman image ls --quiet --filter reference=localhost/pants-oci-run)

Limits: scripted build steps (commands=, oci_build_layer, oci_extract) still need local Linux podman and fail on macOS, so images that use them can’t be built there; declarative packaging and publishing need no podman. The first run of each image copies its archive into the machine, which takes a while for large images. The image must suit the machine’s architecture, as there is no multi-platform support.

Running with Docker

Teams without podman can run images and scripted build steps with Docker instead. Enable Pants’ Docker backend and this plugin’s Docker runner, and select it:

[GLOBAL]
backend_packages.add = ["pants.backend.docker", "pants_backend_oci.docker"]

[oci]
runner = "docker"

Docker is found and configured by Pants’ Docker backend, through its [docker] options: executable_search_paths finds docker, env_vars passes variables such as DOCKER_HOST and DOCKER_CONFIG (for pants run your whole environment is passed as well), tools puts extra tools such as credential helpers on its PATH, and run_args adds arguments to docker run, such as published ports. If the Docker backend’s experimental podman support is on, it may pick podman’s Docker-compatible command instead.

Docker 23.0 or newer is required: scripted build steps use BuildKit, Docker’s default builder from 23.0, to build from the loaded image and export files. docker load reads the plugin’s archives on any version, as they also carry Docker’s manifest.json.

Each pants run and build step loads the image into the Docker daemon as pants-<digest>:latest. Built images are removed, but loaded ones stay in the daemon’s store, where they can be shared by later runs and steps, and accumulate. A build step that is killed can also leave its pants-step-* image behind. Remove them all, while no Pants build is running, with docker images --filter=reference='pants-*' -q | xargs docker rmi. Ownership inside the container is whatever your daemon gives it: a rootful daemon keeps the image’s owners as they are. Scripted build steps aren’t keyed on the daemon’s configuration, only on the docker binary, so after changing the daemon rebuild with --no-local-cache.

Pants store size

Every layer and image the plugin builds is a file in the Pants store (~/.cache/pants/lmdb_store, or your local_store_dir), and every rebuild after a change adds new ones. With multi-GB images that adds up quickly: one repo went from 27 GB to 80 GB while benchmarking. Only pantsd trims the store: an hour after it starts and hourly after that, it shrinks the store to a tenth of the store limits combined, about 29 GB by default. With --no-pantsd, as is common in CI, it is never trimmed. Lowering the files limit lowers that target:

[GLOBAL]
local_store_files_max_size_bytes = 64_000_000_000

This trims the store to about 10 GB. Files over 512 KiB, such as layers, are kept outside the store’s LMDB shards, so the limit doesn’t cap the size of a single layer. To reclaim the space at once, delete the store directory while no Pants process is running.

Scripted build steps

commands= on oci_image_build and oci_build_layer runs a command inside the image. It is an escape hatch: everything else is built declaratively, without running anything, and is reproducible. A scripted step is only as reproducible as its command.

Scripted steps run through podman build, from the image as an OCI archive and with --timestamp 0, as root inside the container. They need the same local, rootless Linux podman as pants run and share its store. Network access follows podman’s defaults. With [oci].runner = "docker" they run through docker build instead, from the image loaded into the daemon, and the layer a step adds is rewritten with zeroed timestamps, as podman’s --timestamp 0 gives; network access follows Docker’s defaults.

A string command runs through [oci].command_shell (/bin/sh -c by default), so the base image must have that shell. On a base without one, such as a distroless image, give a list of strings instead: like a Dockerfile’s exec-form RUN, it runs as that argv with no shell, so there are no pipes, && or variable expansion, and the program is looked up on the image’s PATH. examples/oci/multistage builds a PEX venv this way on the distroless Python base:

oci_build_layer(
    name="app-deps",
    base=["//examples/oci:python3-d12#latest"],
    packages=[":binary-deps"],
    commands=["python", "/examples.oci.multistage/binary-deps.pex", "venv", "--scope=deps", "--compile", "/bin/app"],
    env=["PEX_TOOLS=1"],
    outputs=["bin/app"],
)
  • oci_build_layer keeps only its outputs and packages them as a layer tarball for other images, with the owners and modes they have in the image and zeroed timestamps. An output may be a glob, matched one path segment per segment (usr/lib/*/libfoo.so); matches keep their paths.
  • oci_image_build with commands= adds what the command changed as one more layer on top of the image’s layers, which are kept as they are.
  • oci_extract copies its outputs out of an image without running anything, but still reads the image through podman, or Docker.

A failing command or a missing output fails the build. examples/oci/scripted shows all three.

Tool versions

The defaults are crane v0.22.1 and zstd v1.5.6-bcr2. crane uses the official go-containerregistry release archives. zstd, used by oci_deb_layer, oci_cacerts_layer and oci_locale_layer to read zstd-compressed packages such as Ubuntu’s, comes from the static builds of aspect-build/zstd-prebuilt for Linux/macOS on x86-64 and ARM64. It is downloaded like the other tools, not installed as a Python dependency. Use [crane].version or [zstd].version to select another recorded version.

oci-builder runs on CPython 3.11.17 from python-build-standalone release 20261009; set [oci].builder_python_version and [oci].builder_pbs_release to use another. Layer compression depends on that Python’s zlib, so a different one may change layer digests (older ones may also be slower: 3.11.10 from 20241008 gzips at about half the speed). Unlike crane and zstd, which Pants checks against the hashes recorded in known_versions, the Python and pex’s science tool are checked only against the checksum files published in the same GitHub releases, through pex; no hash in this repository or in pants.toml pins them.

Layers are gzipped at level 6 in 1 MiB blocks spread over up to 16 cores, like pigz. The blocks make the output independent of how many cores ran it; like the rest of compression, it still depends on oci-builder’s zlib. [oci].layer_compression_level (1 to 9) trades build time for size; changing it changes layer digests.

Registry credentials are read the way crane reads them, from the first of these that exists: ~/.docker/config.json (or $DOCKER_CONFIG/config.json) with its credential helpers, then $REGISTRY_AUTH_FILE, then podman’s containers/auth.json under $XDG_RUNTIME_DIR or $XDG_CONFIG_HOME (~/.config). A docker config therefore hides a podman login. Pulled images are fetched for the target’s os/architecture, defaulting to linux and the host architecture, and Docker v2 manifests are converted to OCI.

Kustomize injection publishes locally built image dependencies to their configured registry before inserting their image digest. An omitted registry tag defaults to latest.

Planned and missing features

  • Currently there’s no support for pulling tags, as that would break determinism
  • Multi-platform SHA/.sig is untested/unsupported
  • Scripted build steps require local Linux podman; only pants run and OCI repl work through podman machine or another podman connection.

Targets

There’s eleven targets currently implemented, of which ten are generic:

  • oci_pull_image
  • oci_pull_images
  • oci_image_build
  • oci_image_empty
  • oci_build_layer
  • oci_deb_layer
  • oci_passwd_layer
  • oci_group_layer
  • oci_cacerts_layer
  • oci_locale_layer

And one with some special language semantics:

  • oci_python_image - this is the same as oci_image_build, but will prefer to set the entrypoint to .pex files.

oci_pull_image

Pull an image from a repository with a specific digest.

oci_pull_image(
    name="base-python",
    repository="docker.io/library/python",
    sha="b78b777208be08edd8f297035cdfbacddb45170ad778fd643c792ee045187e39"
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
repositoryFully qualified repository nameRequired
shaThe digest of the image, minus the @sha: prefix.Required
anonymousWhether to pull the image anonymously.false
descriptionA description of the target
tagsList of tags[]

oci_pull_images

Pull multiple shas for an image, generating a target for each. In the below example, we’d get the targets :python#slim and :python#buster.

oci_pull_image(
    name="python",
    repository="docker.io/library/python",
    variants={
       "slim": "f8fbb2370c6314c806b2ddbec8d94375987e16bc122379bef979c6fc5e962920",
       "buster": "97c123c899c8c9ca46248f4002ec4173322e0a1086b386efefac163c64967ba2"
    }
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
repositoryFully qualified repository nameRequired
variantsDictionary with local tags to the remote shaRequired
anonymousWhether to pull the image anonymouslyfalse
descriptionA description of the target
tagsList of tags[]

oci_build_image

Build an image with the provided packages embedded.

oci_image_build(
    name="my-server",
    base=":python#slim",
    repository="my-registry.example.com/a-namespace/an-image",
    tag="latest",
    packages=[":my_pex"]
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
baseThe base image to use. Matches the FROM directive in a DockerfileRequired
packagesPackaged targets to include. The first element will be used as the entrypoint.[]
repositoryFully qualified repository nameRequired when publishing
tagRemote tag to useRequired when publishing
userThe user to run as: a name or uid, optionally : and a group name or gid. Not checked at build time; the runtime resolves names when the container starts.The base image’s user
descriptionA description of the target
tagsList of tags[]

oci_python_image

Build a Python image with the provided packages embedded.

oci_python_image(
    name="my-server",
    base=":python#slim",
    repository="my-registry.example.com/a-namespace/an-image",
	main="/app/server/start.py",
    tag="latest",
    packages=[":my_pex"]
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
baseThe base image to use. Matches the FROM directive in a DockerfileRequired
packagesPackaged targets to include. The first element will be used as the entrypoint.[]
python_mainThe main file to runThe last .pex in the dependency list
repositoryFully qualified repository nameRequired when publishing
tagRemote tag to useRequired when publishing
descriptionA description of the target
tagsList of tags[]

oci_image_empty

An empty base image with no contents at all. This is declared as //:empty automatically, but you can use this to create new targets.

oci_image_empty(
    name="empty",
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
descriptionA description of the target
tagsList of tags[]

oci_extract

Extract one or more files from another container image. Nothing is run, but the image is read through podman; see Scripted build steps.

oci_extract(
    name="my-app-binary"
	base=[":some-app"],
	outputs=['/usr/bin/local/the-app'],
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
outputsPaths to capture into the built layer.[]
excludeGlobs to not include in the output.[]
descriptionA description of the target
output_pathThe output path during pants packageA variant generated from the target name and directory
tagsList of tags[]

oci_build_layer

Run a command in an image, and capture the configured output into a layer artifact, that can be injected into other images. This matches the COPY --from workflows. The command runs through podman; see Scripted build steps.

oci_build_layer(
    name="layer"
	base=[":rust-1-70"],
    packages=[":files"],
    env=['RUSTC_OPTS=...'],
	commands='cd /my-package && cargo build --release',
	outputs=['/my-package/target/release/my-package'],
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
commandsThe command to run in the container, through [oci].command_shell
packagesPackaged targets to include. The first element will be used as the entrypoint.[]
envEnvironment variables to set. Does not support interpolation.[]
outputsPaths to capture into the built layer.[]
excludeGlobs to not include in the output.[]
descriptionA description of the target
output_pathThe output path during pants packageA variant generated from the target name and directory
tagsList of tags[]

oci_deb_layer

Build a layer from Debian .deb packages without running dpkg, apt or any maintainer scripts. Package contents are extracted into the layer with zeroed timestamps, and each package is recorded in /var/lib/dpkg/status.d/<package> (plus <package>.md5sums), the same layout distroless images use, so scanners such as Trivy, Syft and Grype see the packages. Add it to an image through layers.

The layer gets the transitive closure of the debs targets. To have apt dependencies resolved, use deb_packages from pants-backend-distroless, which locks a package set against a snapshot and generates a target per package that brings the .deb files of its whole locked closure. Otherwise list every package the layer needs on top of the base image as file targets, pinned to a snapshot.debian.org or snapshot.ubuntu.com URL so the layer stays reproducible. Debian and Ubuntu packages are both supported; members compressed with gzip, xz or bzip2 are read directly, and zstd-compressed ones (as Ubuntu uses) are piped through the zstd tool. A path shipped by more than one package must be identical in each (type, mode, ownership and content or link target), as dpkg requires; directories keep the metadata of the first package by name. Hardlinked files are compared by name, so two packages that ship the same hardlink pair but store it the other way around (which name is the file and which is the link) are rejected. Files with extended attributes (such as file capabilities) are rejected, since the layer can’t carry them. Packages for more than one architecture are rejected; Architecture: all packages go with any.

Things to be aware of:

  • No maintainer scripts run, so nothing a postinst would do happens: no ldconfig, no update-alternatives links, no triggers, and no generated files such as /etc/ssl/certs/ca-certificates.crt (use oci_cacerts_layer for that one).
  • dpkg and apt inside the image read /var/lib/dpkg/status, not status.d, so they don’t see these packages. A package that is also in the base image is reported twice by scanners.
  • merged_usr defaults to True, which is right for Debian 12 (bookworm) and later. Set it to False for older bases where /bin and /lib are real directories.
  • Moving a Python app from a python base to one built from packages changes the interpreter’s path; see “Moving from a Python base image” in the pants-backend-distroless README.
  • A package that is also in the base image replaces the base’s files silently. If the two were built from different archive states, the image then mixes package versions. pants lint checks every oci_image_build for this (the deb-layer-overwrites linter): it fails when a deb layer replaces a regular file from a lower layer with different content, and names the packages and versions on both sides. Identical files aren’t reported. Each layer, including each of the base image’s, is indexed once in its own cached process, so a pinned base is only read the first time. dpkg’s own records under /var/lib/dpkg aren’t compared. The check builds (or pulls) the base of every image with a deb layer, so pants lint needs the same access as pants package for those. Skip it with --deb-layer-overwrites-skip.
file(
    name="jq_deb",
    source=http_source(
        url="https://snapshot.debian.org/archive/debian/20241001T000000Z/pool/main/j/jq/jq_1.6-2.1_amd64.deb",
        sha256="d1833fdd7a1340b59c71c223178940044fc41a8c8e28861bc03ab258d791d303",
        len=64932,
        filename="jq_1.6-2.1_amd64.deb",
    ),
)

oci_deb_layer(
    name="jq",
    debs=[":jq_deb", ":libjq1_deb", ":libonig5_deb"],
)

oci_image_build(
    name="image",
    base=[":debian#bookworm-20241016-slim"],
    layers=[":jq"],
    entrypoint="/usr/bin/jq",
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
debsThe .deb files to extract, with their dependencies: deb_packages targets or file targets.Required
merged_usrWrite contents of /bin, /sbin and /lib* under /usr, for bases where those are symlinks.True
descriptionA description of the target
output_pathThe output path during pants packageA variant generated from the target name and directory
tagsList of tags[]

oci_passwd_layer and oci_group_layer

Build layers with /etc/passwd and /etc/group, the declarative replacement for useradd and groupadd in a scripted build. oci_passwd_layer also creates each user’s home directory, owned by the user with mode 0700; homes of / and /nonexistent are never created. Lines are written in the order given, and the layers are reproducible. Set user on oci_image_build to run as one of the users.

Each file replaces the base image’s, so on a Debian base also list root and any other users and groups the base needs. Layers apply in the order of layers, so put these after any layer that ships its own /etc/passwd or /etc/group, or that one wins. Only the files and home directories are written, not their parent directories, so the base image’s /etc, /home and the like keep their ownership and modes; missing parents are created when the image is unpacked. No /etc/shadow is written; a distroless image doesn’t need one to resolve users.

The two targets are independent, so nothing checks that a user’s gid or a group’s members exist in the other.

oci_passwd_layer(
    name="passwd",
    users=[
        {"name": "root", "uid": 0, "gid": 0, "home": "/root", "shell": "/bin/sh"},
        {"name": "nonroot", "uid": 65532, "gid": 65532},
    ],
)

oci_group_layer(
    name="group",
    groups=[
        {"name": "root", "gid": 0},
        {"name": "nonroot", "gid": 65532, "members": ["nonroot"]},
    ],
)

oci_image_build(
    name="image",
    base=[":debian#bookworm-20241016-slim"],
    layers=[":passwd", ":group"],
    user="nonroot",
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
usersoci_passwd_layer: dicts with name, uid, gid, and optionally home, shell, gecos, create_home.Required
groupsoci_group_layer: dicts with name, gid, and optionally members, a list of user names.Required
descriptionA description of the target
output_pathThe output path during pants packageA variant generated from the target name and directory
tagsList of tags[]

A user’s home defaults to /home/<name>, shell to /sbin/nologin, gecos to empty and create_home to True. Names must match [a-z_][a-z0-9_.-]*, names and ids must be unique, and no value may contain : or a newline.

oci_cacerts_layer

Build a layer with the CA bundle /etc/ssl/certs/ca-certificates.crt from the ca-certificates package, without running update-ca-certificates: every certificate under /usr/share/ca-certificates is concatenated in path order, as the package’s postinst and update-ca-certificates would. The package’s copyright file is included too. Like distroless images, the layer has no per-certificate files or OpenSSL hash links in /etc/ssl/certs, so tools that look certificates up by hash in a directory rather than reading the bundle file don’t find them.

debs is searched for the ca-certificates package, so it can point at a target generated by deb_packages; the packages it depends on are ignored. Debian’s and Ubuntu’s packages both work.

The bundle is reproducible: certificates are concatenated in a bytewise sort of their paths, so it depends only on the package. It is not byte-for-byte what Debian would produce, though: the package’s postinst sorts the list by locale, so a scripted build can order the certificates differently, and ! deselections in /etc/ca-certificates.conf and certificates added under /usr/local/share/ca-certificates are not supported.

oci_cacerts_layer(
    name="cacerts",
    debs=[":bookworm#amd64/ca-certificates"],
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
debsTargets bringing exactly one ca-certificates .deb.Required
descriptionA description of the target
output_pathThe output path during pants packageA variant generated from the target name and directory
tagsList of tags[]

oci_locale_layer

Build a layer with only the selected compiled locales under /usr/lib/locale, such as C.utf8 from libc-bin, without the rest of the package and without running locale-gen or localedef. glibc only has the ASCII C and POSIX locales built in, so a program that sets LANG=C.UTF-8 or calls setlocale with it needs these files, or it silently falls back to ASCII. Distroless images ship C.utf8 the same way.

debs is searched for packages shipping the requested locales as directories under /usr/lib/locale, so it can point at a target generated by deb_packages. Each locale must be in exactly one package, and each package a locale comes from adds its copyright file. Locales are looked up like glibc does: by the name as given, then with its codeset normalized, so C.UTF-8 selects C.utf8. C and POSIX are built into glibc and rejected. Since nothing is compiled, only locales a package ships compiled can be selected: libc-bin ships C.utf8 on Debian and Ubuntu, and the error lists the locales the packages do ship. A locale-archive file, as locale-gen writes, is not read. Hardlinks between locales become regular files. The locale directories are written with mode 0755, but not /usr/lib/locale itself, so the base image’s is kept.

oci_locale_layer(
    name="locales",
    debs=[":bookworm#amd64/libc-bin"],
    locales=["C.UTF-8"],
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
debsTargets bringing the .debs that ship the locales.Required
localesThe compiled locales to keep.["C.utf8"]
descriptionA description of the target
output_pathThe output path during pants packageA variant generated from the target name and directory
tagsList of tags[]

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

  • Layers build much faster cold. A layer of files and packages is tarred and gzipped in one step, without capturing an uncompressed tar, and every layer is gzipped on up to 16 cores in fixed-size blocks, like pigz. On a 1.5 GB layer, tar and gzip went from 79 s to 12 s. Layer blobs come out about 0.4% larger, and every layer and image digest changes once; layer diff_ids don’t change. The new [oci].layer_compression_level (default 6) sets the gzip level.

  • oci_image_build stacks layers= in the order they are listed. They were stacked in address order, so where two layers wrote the same path the wrong one could win. Images whose layers are not listed in address order get new digests.

  • pants run and OCI repl work on macOS through podman machine, and on any platform through a named podman connection with the new [oci].podman_connection option. The image is loaded into the connection’s store as localhost/pants-oci-run:<digest> and kept for later runs; a missing or stopped machine is reported with the command to fix it. Scripted build steps still need local Linux podman. See “Running through podman machine” in the README.

  • [oci].runner = "docker" runs pants run, OCI repl and scripted build steps with Docker instead of podman, for teams without podman. It needs Docker 23.0 or newer and the pants.backend.docker and pants_backend_oci.docker backends; Docker is found and configured by the Docker backend’s [docker] options. podman stays the default. See “Running with Docker” in the README.

  • Scripted build steps rerun when the podman binary changes, instead of reusing layers cached with the old podman. They also read podman’s configuration from your home directory, like pants run, instead of from an empty private one. pants run no longer rejects CONTAINER_HOST / CONTAINER_CONNECTION: both it and build steps ignore them and run podman locally. Podman’s runtime state moves from private directories under /tmp to your runtime directory ($XDG_RUNTIME_DIR, else /run/user/<uid>), which build steps now see (it is part of their cache key); the store moves to oci_podman/v2 to go with it, so bases are imported again once. Remove the old oci_podman/v1 store and /tmp/oci-podman-* directories with podman unshare rm -rf <named-caches-dir>/oci_podman/v1 /tmp/oci-podman-$(id -u)-*. The README shows how to clear the store, including what an interrupted build leaves behind.

  • The sandboxed builders run as one oci-builder executable, a scie built in the rules with pex from the plugin’s builder scripts and a pinned python-build-standalone Python (3.11.17), instead of as scripts run by Pants’ own Python at its absolute path under the user’s home. Builder processes now name nothing on the host, so their cache keys are the same on every machine and builds share remote-cache hits; only building oci-builder itself, once per machine, does not. Building it downloads that Python and pex’s science tool from GitHub the first time. It unpacks once per machine into the new oci_builder named cache, so builders need no home directory. Layer compression no longer depends on the Python Pants runs on. The plugin registers Pants’ pex rules for this. Image digests are unchanged. [oci].builder_python_version and [oci].builder_pbs_release select that Python.

  • New deb-layer-overwrites linter: pants lint fails when an oci_deb_layer in an oci_image_build replaces a regular file from a lower layer, such as the base image, with different content, naming the packages and versions involved. Identical files aren’t reported. Each layer is indexed once, in its own cached process. Linting builds or pulls the base of every image with a deb layer.

  • oci_passwd_layer and oci_group_layer are built in a sandboxed process instead of in the rule. Their layer digests are unchanged.

  • commands= on oci_image_build and oci_build_layer also takes a list of strings, run as that argv without [oci].command_shell, so scripted steps work on bases with no shell, such as distroless ones. A string still runs through the shell. examples/oci/multistage uses it and packages again.

  • Write the packaged-files layer (layers/image_bundle.tar) and the archive pants run hands to podman with the plugin’s Python instead of the host tar. GNU tar before 1.35 exits 1 with “file changed as we read it” when Pants hardlinks the same large file into another sandbox while it is read, which failed cold builds of large images intermittently. macOS no longer needs gtar for this. Files in the layer are 0755 or 0644 by their executable bit; before, files of 512KB or more came out 0555 from the Pants store. Hardlinked paths are stored as copies. The packaged-files layer’s digest changes once. [oci].unsafe_tar_ignore_file_changed is deprecated and has no effect.

  • deb_layer with merged_usr=True (the default) writes the /bin -> usr/bin style links for the directories it moves under /usr, so it works on an empty base. Before, an image built only from deb layers had no /lib64 and its dynamic loader couldn’t be found.

  • Compress each layer once, in its own cached process, so assembly only links existing blobs. Editing one layer no longer re-compresses the unchanged ones, such as a large dependencies layer.

  • Add oci_locale_layer, which builds a reproducible layer with only the selected compiled locales, such as C.utf8 from libc-bin, without running locale-gen or localedef.

  • Breaking: pants package writes an oci_image_build as a deterministic OCI archive, <name>.tar, instead of an <name>.d OCI layout directory. The archive holds only the image’s blobs, names the image repository:tag and includes a Docker manifest.json, so podman load and docker load keep the tag. It is written by the plugin’s Python, so skopeo is no longer downloaded and the [skopeo] options are removed.

  • Breaking: Remove the unused [umoci] and [runc] tools and the [oci].rootless, uid_map and gid_map options; setting them in pants.toml or on the command line is now an error.

  • Run scripted build steps (commands= on oci_image_build and oci_build_layer) and oci_extract through podman build (podman 4.0 or newer) with --timestamp 0 instead of umoci and runc. A failing command or missing output now fails the build instead of producing an empty artifact, and a scripted image step adds one layer on top of the unchanged base layers. Layer and extract outputs keep the owners and modes they have in the image, globs in outputs keep their paths, and empty outputs is an error.

  • Run images and OCI repl through user-installed rootless podman on Linux, using OCI archives and separate Pants-managed storage. Non-root ownership and groups are preserved. Remote podman and macOS execution are not supported yet.

  • Align the Python OCI examples with a pinned Debian 12 / Python 3.11 base.

  • Assemble images in Python instead of with umoci: adding layers, editing the config, creating the empty base image and tagging pulled images now run a small Python script in a sandboxed process. Each image is assembled in one process instead of several umoci calls per layer. Built image digests change once: layers are gzipped differently, history entries no longer carry stray quotes, and empty User/WorkingDir fields from the base config are kept.

  • oci_build_layer adds a layer for each of its dependencies, not only the first.

  • oci_deb_layer and oci_cacerts_layer read zstd-compressed packages, such as Ubuntu’s, by piping them through a new [zstd] tool: a static zstd binary of about 1 MB downloaded from aspect-build/zstd-prebuilt, not a Python dependency. oci_cacerts_layer is now built in a process instead of in the rule.

  • Add oci_passwd_layer, oci_group_layer and oci_cacerts_layer, which build /etc/passwd (with home directories), /etc/group and a CA bundle from the ca-certificates package as reproducible layers, without running useradd or update-ca-certificates.

  • Add user to oci_image_build.

  • Add oci_deb_layer, which builds a layer from .deb packages without running dpkg or maintainer scripts, recording each package in /var/lib/dpkg/status.d/. Package contents are streamed, files shipped identically by several packages are accepted, and extended attributes are rejected.

  • oci_deb_layer includes the transitive closure of its debs, so it accepts the targets deb_packages from pants-backend-distroless generates from a Debian lockfile. A file target in debs that has its own dependencies now brings them into the layer too, and they must be .deb files as well. Packages for more than one architecture are rejected.

  • Stop setting a layer target’s entrypoint to a nested layer tarball.

  • Pull, publish and mirror images with crane instead of skopeo. Credentials come from the docker config and its credential helpers when one exists, and only otherwise from REGISTRY_AUTH_FILE or podman/skopeo’s containers/auth.json; a podman login alongside a docker config is no longer used.

  • Pulled images are converted from Docker v2 to OCI in the rule; an omitted os now defaults to linux.

  • oci_mirror_image now copies the whole manifest list and keeps the source digest, instead of a single re-encoded platform image.

  • The README explains how rebuilding large images grows the Pants store, and suggests a lower local_store_files_max_size_bytes.

0.10.0 - 2026-10-17

  • Target Pants 2.31.0.

  • Require pants-backend-kustomize>=0.7.0.

  • Replace remaining union-dispatched Get calls with polymorphic call-by-name rules.

  • Default to umoci v0.6.0, using official Linux x86-64/ARM64 binaries and refreshed macOS x86-64/ARM64 builds from tgolsson/umoci-binary. Retain the existing pinned nightly and v0.4.7 entries; CI examples now use the stable default.

  • Default to Skopeo v1.24.1 from lework/skopeo-binary for all four platforms; remove the old version entries and provider-routing branches.

  • Default to runc v1.5.2; include v1.4.3, v1.3.6, and v1.2.9 for Linux x86-64/ARM64, replacing the old v1.1.4 entries.

  • Fix Kustomize OCI injection to supply the image layout/reference when publishing local bundles and default an omitted registry tag to latest.

  • Check every recorded binary download and cover local/remote Kustomize injection with regression tests that mock registry operations.

0.9.0 - 2026-10-16

  • Now targets pants version 2.30.
  • Migrate rules to the Pants call-by-name API, including the OCI mirror plugin.

0.8.1 - 2025-05-15

  • Add pants_backend_oci.utility.mirror plugin to mirror images between repositories.

0.8.0 - 2025-05-14

  • New target: oci_extract. Allows pulling out individual files from another image; without executing the image itself.
  • Now targets pants version 2.24.

0.7.0 - 2024-09-19

  • Now targets pants version 2.22.

0.6.2 - 2024-09-19

  • Adds [oci].unsafe_tar_ignore_file_changed flag that will set --warning=no-file-changed when invoking tar

0.6.1 - 2024-03-28

  • Fix a bug where the plugin would try to publish OCI images without a repository.
  • Will now default to “latest” as a tag if none is specified.

0.6.0 - 2024-03-21

  • Adding layers to an image is changing. As a preparatory step, there’s now an oci_layer target. Add these to the layers field of an oci_image_build, instead of dependencies. These will process a bit better, and gives more control over what goes into each layer.
  • Mac support has improved. In order to provide determinism, the gtar (GNU tar) binary has to be available.
  • Both umoci and skopeo are now supported on Mac M1 and x86_64. This plugin does not support image run steps on Mac still.
  • When pulling multi-arch images you can now specify os and architecture on the oci_pull_image target
  • Fix a bug where OCI layer building would fail if no dependencies were specified

0.5.0 - 2023-11-19

  • Fix a crash when image pulling fails
  • Add nightly version for umoci with experimental support for Mac
  • Add v1.13.3 version for skopeo with experimental support for Mac

0.4.0 - 2023-06-18

  • Improved support for very large layers > 2GB. A lot of layers will now be compressed in transit. This adds some overhead later when injecting them into image, but fixes some crashes inside Pants.

  • Improved support for multi-stage builds (COPY --from)

    There is now support for building artifacts in one container and copying them into a new container. To do this, use oci_build_layer, and configure the output files and directories. The files and data will be inserted verbatim into the downstream container.

  • Adds support for empty base images (FROM scratch)

    This change enables you to use base=["//:empty"] to start from a completely empty container. This can be useful to produce containers with statically linked binaries that require no runtime environment at all.

    • To change the target name, set [oci].empty_image_target in pants.toml.

0.3.1 - 2023-03-16

  • Handle files when building layers

0.3.0 - 2023-03-14

  • [breaking] Change output format when publishing

0.2.0 - 2023-02-10

  • [bugfix] Ensure layer tars has no metadata or user information
  • [bugfix] Properly pass args to runc
  • [bugfix] Fix log output in package_oci_image
  • [feature] Add proper metadata where possible
  • [feature] Add support for anonymous image pulling (anonymous=true)
  • [feature] Add support for passing image SHA along to Kustomize
  • [feature] Add python_image_target which will set entrypoint appropriately
  • [feature] Forward PATH, HOME, XDG_RUNTIME_DIR env variables to publish step to make credHelpers work

[0.1.1] - 2022-11-17

  • Move to new repo
  • Compatibility fixes for 2.15.0a0

[0.1.0] - 2022-11-13

Initial release.

Mirroring backend

Utility backend for mirroring images from one registry to another, or internally in one registry.

Activate with:

backend_packages = [
    ...,
    "pants_backend_oci.utility.mirror",
]

Targets

oci_mirror_image

Mirror a single image.

oci_mirror_image(
    name="base-python",
    repository="docker.io/library/python",
    sha="b78b777208be08edd8f297035cdfbacddb45170ad778fd643c792ee045187e39"
)
ArgumentMeaningDefault value
nameThe target nameThe directory name
source_repositoryFully qualified repository name to pull fromRequired
destination_repositoryFully qualified repository name to pull fromRequired
shaThe digest of the image, minus the @sha: prefix.Required
tagOptional tag to add when pushing.
decsriptionA description of the target
tagsList of tags[]

oci_mirror_images

Mirror multiple shas for one image.

oci_mirror_images(
    name="base-python",
    repository="docker.io/library/python",
	variants={
	    "some-variant": "b78b777208be08edd8f297035cdfbacddb45170ad778fd643c792ee045187e39",
	    "other-tag": "b78b777208be08edd8f297035cdfbacddb45170ad778fd643c792ee045187e39",
    },
)
ArgumentMeaningDefault value
nameThe target nameThe directory name
source_repositoryFully qualified repository name to pull fromRequired
destination_repositoryFully qualified repository name to pull fromRequired
variantsDictionary with tags-to-sha.Required
decsriptionA description of the target
tagsList of tags[]

Distroless backend for Pants

This release targets Pants 2.31.0. Install it with pants-backend-distroless==0.1.0 in [GLOBAL].plugins in pants.toml, and enable pants_backend_distroless in [GLOBAL].backend_packages.

PyPI

Warning This plugin is in development. No stability is guaranteed! Contributions welcome.

This backend builds the parts of distroless-style images that come from Debian or Ubuntu packages, in the spirit of rules_distroless: declare the top-level packages against an apt repository snapshot, and pants generate-lockfiles resolves their dependencies into a committed lockfile. The locked packages feed oci_deb_layer from pants-backend-oci, which builds a layer from them without running apt, dpkg or maintainer scripts. pants-backend-oci also has the helper layers such an image needs: oci_passwd_layer, oci_group_layer, oci_cacerts_layer and oci_locale_layer.

Targets

deb_packages

A set of Debian packages resolved into a lockfile. Each resolve is named in [apt].resolves, which maps it to its lockfile, and is declared by exactly one deb_packages target.

[apt.resolves]
bookworm = "3rdparty/debian/bookworm.lock.json"
deb_packages(
    name="bookworm",
    resolve="bookworm",
    sources=["https://snapshot.debian.org/archive/debian/20241001T000000Z bookworm main"],
    architectures=["amd64", "arm64"],
    packages=["jq"],
    # Already in the base image.
    exclude=["libc6"],
    # From the `debian-archive-keyring` package.
    keyrings=["debian-archive-keyring.gpg"],
)

oci_deb_layer(
    name="jq",
    debs=[":bookworm#amd64/jq"],
)

Run pants generate-lockfiles --resolve=bookworm to write the lockfile, and again whenever the target changes. Until then, and while the lockfile is out of date, Pants warns and generates no packages, so anything using them fails with an error that :bookworm#amd64/jq was not generated. The warning is only printed when the targets are generated, so with pantsd a later run may show just that error; regenerating the lockfile fixes it.

The target generates:

  • a deb_package target named <arch>/<package> for every locked package, which depends on the pinned .deb of the package and of every locked package in its closure, so :bookworm#amd64/jq brings jq and its whole closure;
  • a deb_package_file target named <arch>/<package>/deb for every locked package, which downloads only its pinned .deb;
  • a deb_package_group target named <arch>, depending on all the requested packages, so :bookworm#amd64 is everything.

Packages don’t depend on each other’s targets, only on .deb files, and the lockfile records each package’s closure. Apt dependency cycles are therefore fine: libc6 and libgcc-s1 depend on each other in Debian, and :bookworm#amd64/libc6 brings both.

A layer is for one architecture, so always pick one: debs=[":bookworm"] would bring the packages of every architecture, which oci_deb_layer rejects.

ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
resolveThe resolve name, a key of [apt].resolves.Required
sourcesThe repositories, each <uri> <suite> <component>... like a sources.list line without deb.Required
architecturesThe Debian architectures to lock, e.g. amd64 and arm64.Required
packagesThe top-level packages.Required
excludePackages treated as already installed, typically those the base image ships.[]
keyringsOpenPGP keyrings (.gpg or .asc) to verify the repositories’ Release signatures with.Required unless allow_unsigned=True
allow_unsignedResolve without verifying signatures. Can’t be combined with keyrings.False
descriptionA description of the target
tagsList of tags[]

Sources

Pin each URI to a snapshot, such as https://snapshot.debian.org/archive/debian/<timestamp>, so the lockfile can be regenerated and the packages stay downloadable. Several sources can be combined, for example to add bookworm-updates, or bookworm-security from https://snapshot.debian.org/archive/debian-security/<timestamp>. Every Packages index is checked against the SHA256 in its suite’s signed Release file, see Signature verification.

Ubuntu works the same way, from snapshot.ubuntu.com. Its updates and security fixes are separate suites of the same archive, and unlike the live archive, where arm64 lives on ports.ubuntu.com, the snapshot serves arm64 packages under /ubuntu too, so one set of sources covers both architectures. Phased-Update-Percentage is ignored: a snapshot lock takes a phased update like any other version. Ubuntu’s packages are zstd-compressed, which oci_deb_layer reads with its zstd tool.

deb_packages(
    name="noble",
    resolve="noble",
    sources=[
        "https://snapshot.ubuntu.com/ubuntu/20241001T000000Z noble main",
        "https://snapshot.ubuntu.com/ubuntu/20241001T000000Z noble-updates main",
        "https://snapshot.ubuntu.com/ubuntu/20241001T000000Z noble-security main",
    ],
    architectures=["amd64", "arm64"],
    packages=["jq", "ca-certificates"],
    # Already in the ubuntu:noble base image.
    exclude=["libc6", "debconf", "libssl3t64"],
    # From the `ubuntu-keyring` package.
    keyrings=["ubuntu-archive-keyring.gpg"],
)

Signature verification

Like rules_distroless’s gpg_keys, generate-lockfiles verifies every suite’s InRelease, or its Release and Release.gpg when there’s no InRelease, against keyrings, and fails unless at least one signature is good and made by a key in them. A bad signature from one of their keys fails too. The hashes of the Packages indices are only taken from the verified file. It also fails unless the signed file’s Suite or Codename is the suite asked for, so another suite’s validly signed Release can’t stand in for it. This is apt’s rule, though apt only warns: a path like debian-security’s old buster/updates matches by what comes before its last /. Either keyrings or allow_unsigned=True must be set.

Use the archive’s own keyring, from the snapshot you resolve against so it’s pinned too: /usr/share/keyrings/debian-archive-keyring.gpg from the debian-archive-keyring package, or /usr/share/keyrings/ubuntu-archive-keyring.gpg from ubuntu-keyring. Copy it next to the deb_packages target, since sources can’t come from another directory.

Verification needs no gpgv or sqv: it’s built into the resolver, and supports what Debian and Ubuntu sign with, v4 RSA and Ed25519 keys with SHA-2 hashes. Older snapshots signed only with SHA-1 need allow_unsigned=True, which turns signature verification off entirely; the suite is still checked. Every key in the keyrings is trusted as given: expiry and revocation aren’t checked, since a snapshot’s Release files are historical. For the same reason, freshness (Valid-Until) isn’t checked either: the snapshot URL is what pins it. The keys aren’t recorded in the lockfile, so changing keyrings doesn’t make it out of date.

Resolution

The Pre-Depends and Depends of the requested packages are resolved transitively, for each architecture separately, much like rules_distroless does:

  • A package resolves to its highest version, across all sources, that satisfies the constraint. When sources have the same version, the first source wins.
  • For alternatives (a | b), a package already in the closure is preferred; otherwise the first alternative that exists wins.
  • A virtual package (Provides) resolves to a provider already in the closure, else the only provider, else a Priority: required one, else the highest version, else the first by name.
  • The result doesn’t depend on the order of packages.

Recommends and Suggests are not installed, and Conflicts and Breaks are not checked.

Packages already in the base image

A layer replaces any file the base image has at the same path, and nothing warns about it. If a locked package is also in the base, its files from the snapshot overwrite the base’s copies, which can mix versions when the base image was built from a different archive state than the snapshot. List such packages in exclude: they count as installed, satisfy any dependency on them, and are not traversed, so whatever only they need is left out as well. Images built on an empty base leave exclude empty.

Moving from a Python base image

A few things change when a Python app moves from a pulled base such as python:3.11-slim to one built from Debian or Ubuntu packages.

The interpreter is /usr/bin/python3.X. The official Python images install it under /usr/local/bin and have /usr/bin/env. A package’s interpreter is /usr/bin/python3.X, where 3.X is the suite’s Python: 3.11 in Debian bookworm, 3.13 in trixie, 3.12 in Ubuntu noble. Build the pex for that version, since its interpreter constraints and wheels must match. An image built from packages has no env unless it locks coreutils, and a pex’s default shebang is #!/usr/bin/env python3.X, so the pex fails to start. Point the shebang at the interpreter, and make the pex the entrypoint with oci_image_build, whose first package is the entrypoint:

pex_binary(
    name="app",
    entry_point="app.py:main",
    shebang="/usr/bin/python3.11",
)

oci_image_build(
    name="image",
    base=[...],
    layers=[":python"],
    packages=[":app"],
)

oci_python_image doesn’t use the shebang: it runs the pex as python <pex>, and no Debian or Ubuntu package but python-is-python3 provides /usr/bin/python. Lock that package to keep using oci_python_image. python3-minimal, which python3 pre-depends on, adds /usr/bin/python3.

pex warns that ldd is missing. On startup a pex may print Failed to detect if this interpreter is running under musl libc: [Errno 2] No such file or directory: 'ldd'. pex runs ldd to tell musl from glibc and, when it can’t, assumes glibc, which is right for Debian and Ubuntu. The warning is harmless. To hide it, set emit_warnings=False on the pex_binary, or PEX_EMIT_WARNINGS=false in the image’s environment; either hides all of pex’s warnings. Locking libc-bin doesn’t help in an image without a shell, since Debian’s ldd is a bash script.

The Pants store grows quickly. Rebuilding multi-GB images adds new layers to the Pants store on every change. See Pants store size in the OCI README for a setting that keeps it in check.

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

  • Add deb_packages, which resolves Debian packages against apt repository snapshots into a lockfile with pants generate-lockfiles, configured by [apt].resolves. It generates a target per locked package and architecture for oci_deb_layer, which depends on the .deb of every package in its locked closure, so apt dependency cycles like libc6 and libgcc-s1 are fine.
  • Ubuntu snapshots from snapshot.ubuntu.com resolve too; the README has an example.
  • generate-lockfiles verifies the OpenPGP signature of every suite’s InRelease (or Release and Release.gpg) against the keyrings in the new keyrings field of deb_packages. Breaking: deb_packages needs either keyrings or allow_unsigned=True.
  • generate-lockfiles fails unless each suite’s Release names it as its Suite or Codename, by apt’s rule (where apt only warns).
  • The README covers moving from a Python base image: the /usr/bin/python3.X pex shebang and pex’s harmless ldd warning.

MDBook backend for Pants

This release targets Pants 2.31.0. Install it with pants-backend-mdbook==0.8.0 in [GLOBAL].plugins in pants.toml.

Warning This plugin is in development. No stability is guaranteed! Contributions welcome.

This provides a tool for building mdbook targets with pants. There is currently a single very simple rule:

md_book(
    name="my-docs",
    sources=["book.toml", "src/*"],
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
sourcesFiles included when building the bookbook.toml and the src directory
decsriptionA description of the target""
tagsList of tags[]

Tool versions

The default is mdBook v0.5.4. The latest 0.4 release, v0.4.52, and existing historical versions remain selectable via --mdbook-version=v0.4.52 or [mdbook].version. The new versions include native Linux/macOS binaries for x86-64 and ARM64; retained older macOS ARM64 entries still use Rosetta.

When upgrading a book to mdBook 0.5, remove [book].multilingual from book.toml: that obsolete setting is now rejected. Select 0.4.52 if a book or preprocessor still requires the 0.4 series.

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

0.8.0 - 2026-10-17

  • Target Pants 2.31.0.

  • Default to mdBook v0.5.4; add the latest 0.4 release, v0.4.52, while retaining existing historical versions.

  • Use native Apple Silicon archives for the new versions, preserving the Rosetta mapping for retained older releases.

  • Check downloads for every recorded version/platform pair.

  • Remove the obsolete [book].multilingual setting from project documentation; mdBook 0.5 rejects this setting, so existing books may need the same migration.

0.7.0 - 2026-10-16

  • Now targets pants version 2.30.
  • Migrate rules to the Pants call-by-name API.

0.6.0 - 2025-05-14

  • Now targets pants version 2.24.

0.5.0 - 2024-09-19

  • Now targets pants version 2.22.

[0.4.1] - 2024-04-12

  • Will now use x86_64 mdbook binary on arm64, relying on Rosetta.

[0.4.0] - 2024-04-05

  • Will now also include codegen sources, allowing integration with adhoc_tool and other generators. Note that only file and direct mdbook sources are included.

[0.3.0] - 2023-11-21

  • Target pants version is now 2.18.0, with support for 2.17.0.
  • Updated mdbook version to 0.4.35
  • Now includes support for arm64 on Linux

[0.2.0] - 2023-06-18

  • Target pants version is now 2.16.0, with support for 2.15.0.

[0.1.3] - 2022-11-18

  • Raise error if book.toml is not found
  • Handle all sources in dependencies

[0.1.2] - 2022-11-17

  • Merge with all other backends
  • Fix README links

[0.1.1] - 2022-11-12

  • Fix README.

[0.1.0] - 2022-11-12

  • Initial release.

Secrets backend for Pants

This release targets Pants 2.31.0. Install it with pants-backend-secrets==0.7.0 in [GLOBAL].plugins in pants.toml.

PyPI

Warning This plugin is in development. No stability is guaranteed! Contributions welcome.

This backends implements utilities for handling secrets.

Planned and missing features

  • Setting and creating secrets via Pants

Targets

env_secret

A secret to be read from the environment.

env_secret(
    name="bw_session_key",
    key="BW_SESSION",
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
keyThe environment variable to read.Required
decsriptionA description of the target
tagsList of tags[]

Goals

decrypt

Decrypts and prints a secret.

BW_SESSION="..." pants decrypt //examples/bitwarden:pypi_token
22:29:42.17 [INFO] Completed: Decrypting examples/bitwarden:pypi_token
Secret examples/bitwarden:pypi_token from BitWarden: pypi-...

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

0.7.0 - 2026-10-17

  • Target Pants 2.31.0.
  • Replace remaining union-dispatched Get calls with polymorphic call-by-name rules.

0.6.1 - 2026-10-16

  • Replace pdm-pep517 with pdm-backend to produce correctly normalized source archive names for PyPI.

0.6.0 - 2026-10-16

  • Now targets pants version 2.30.
  • Migrate rules to the Pants call-by-name API.
  • Fix Twine PEX creation after the call-by-name migration.
  • Support skip_twine and distribution output_path on python_distribution_with_secret targets.

0.5.0 - 2025-05-14

  • Now targets pants version 2.24.

0.4.0 - 2024-09-19

  • Now targets pants version 2.22.

[0.3.0] 2023-11-21

  • Target pants version is now 2.18.0, with support for 2.17.0.

[0.2.0] - 2023-06-18

  • Target pants version is now 2.16.0, with support for 2.15.0.

0.1.0 - 2023-02-10

  • Initial release

BitWarden backend for Pants

This release targets Pants 2.31.0. Install it with pants-backend-bitwarden==0.7.0 in [GLOBAL].plugins in pants.toml.

PyPI

Warning This plugin is in development. No stability is guaranteed! Contributions welcome.

This backends implements targets for reading BitWarden secrets.

  • bw - the BitWarden CLI client

Tool versions

The default is Bitwarden CLI 2026.10.0. Version 2026.9.1 remains selectable with --bitwarden-version=2026.9.1 or [bitwarden].version. Both versions include native Linux/macOS binaries for x86-64 and ARM64; historical versions retain their existing platform coverage.

Planned and missing features

  • Setting and creating secrets via Pants

Targets

bw_item

Matches one entry in your vault.

bw_item(
    name="pypi",
    id="386c6037-cbdd-4aa3-ba80-9ed6661f751b",
    session_secret=":bw_session_key",
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
idItem id used by this target as seen in the address barRequired
item_nameThe name in the vault. If ambiguous this will fail. Prefer IDs.
session_secretThe secret to use for the BW_SESSION variable.env["BW_SESSION"]
decsriptionA description of the target
tagsList of tags[]

bw_password

The password of an item in your vault.

bw_password(
    name="pypi_password",
    item=[":pypi"],
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
itemThe item containing the password.Required
decsriptionA description of the target
tagsList of tags[]

bw_field

A field from an item in your vault. These are the “Custom Fields” at the bottom of an item, not to be confused with attachments.

bw_field(
    name="pypi_token",
	field_name="api_token"
    item=[":pypi"],
)
ArgumentMeaningDefault value
nameThe target nameSame as any other target, which is the directory name
itemThe item containing the password.Required
field_nameThe item containing the password.Required
decsriptionA description of the target
tagsList of tags[]

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

0.7.0 - 2026-10-17

  • Target Pants 2.31.0.

  • Require pants_backend_secrets>=0.7.0.

  • Replace remaining union-dispatched Get calls with polymorphic call-by-name rules.

  • Default to Bitwarden CLI 2026.10.0 and retain 2026.9.1 and existing historical versions.

  • Add native Linux ARM64 and macOS ARM64 binaries for both 2026.10.0 and 2026.9.1.

  • Check downloads for every recorded version/platform pair.

0.6.1 - 2026-10-16

  • Replace pdm-pep517 with pdm-backend to produce correctly normalized source archive names for PyPI.
  • Update the default Bitwarden CLI to 2026.9.1.
  • Require pants_backend_secrets>=0.6.1.

0.6.0 - 2026-10-16

  • Now targets pants version 2.30.
  • Migrate rules to the Pants call-by-name API.

0.5.0 - 2025-05-14

  • Now targets pants version 2.24.

0.4.0 - 2024-09-19

  • Now targets pants version 2.22.

[0.3.0] - 2023-11-21

  • Target pants version is now 2.18.0, with support for 2.17.0.
  • Default bitwarden version is now 2023.10.0

[0.2.0] - 2023-06-18

  • Target pants version is now 2.16.0, with support for 2.15.0.

0.1.0 - 2023-02-10

  • Initial release