Sick of manually orchestrating container-image builds in GitLab pipeline or GitHub Actions workflow files? This tool is for you.
platform-images is a lightweight way to keep multiple container images in one repository. The
images may be completely independent, share a common parent, or form deeper dependency chains. When
something changes in an image directory or in shared build configuration, the tool selects the
smallest affected subgraph and orchestrates its rebuild.
For a repository owner, it answers one operational question:
Given this Git change, which container images must be rebuilt, in what order, and with which exact versions of their local parent images?
A repository containing many Dockerfiles usually starts with one CI job per image. Over time that
becomes either wasteful (build every image on every commit) or unsafe (directory path rules rebuild
the directly edited image but miss its consumers). Independent images should remain independent;
related images should be rebuilt together only when their part of the graph changes. The dependency
information already exists in instructions such as FROM base, but normal CI path filters do not
understand it. They also do not solve how independently scheduled jobs pass the exact newly built
parent image to their consumers.
This tool discovers one target per direct directory containing a Dockerfile or Containerfile
beneath one or more configurable repository-relative roots (images by default). It infers
their build-time dependency graph, maps a Git diff onto that graph, and follows changes
downstream. It can render dynamic GitLab child-pipeline jobs or generate a complete GitHub Actions
workflow. Docker Buildx, Podman, Buildah, and nerdctl/BuildKit are supported explicitly. Every
build receives exact image references, and a default-branch pipeline promotes the affected graph
to stable tags only after the complete graph build succeeds. The pipeline also publishes a
commit-scoped manifest that tells later test, deployment, and release jobs exactly which immutable
image digest each target produced. Docker teams can additionally export the same local or CI plan
as a native Buildx Bake definition without maintaining another dependency file.
There is no second image catalogue or hand-maintained CI dependency list. The Dockerfiles or Containerfiles and Git history remain the source of truth.
The intended delivery path is equally small: CI builds a commit once, publishes
image-build-manifest.json, and every later job reads the new image's @sha256 reference from that
file. When the commit is ready to release, promote-manifest gives those same tested bytes a
semantic tag such as v1.2.3; it never rebuilds them. See
From commit build to semantic release.
The inferred structure is a directed acyclic graph (DAG). A dependency edge records that one target consumes another; validation rejects any directed cycle and prints its complete path before planning. Valid plans are deterministic topological projections of that DAG: dependencies build before consumers, while unrelated nodes remain parallelizable.
- What this solves and what it does not
- Installation
- Quick start
- Image names, local references, and published repositories
- The checked-in example
- Demonstration
- Worked examples
- From commit build to semantic release
- Configuration capabilities
- Configuration reconciliation
- Container backend compatibility
- Docker Buildx Bake export
- Recommended adoption flow
- Command guide
- CI operating model
- GitHub Actions workflow generation
- GitLab child pipelines
- Performance and scaling
- Quality and integration checks
- Support and contributing
- License
For a platform or DevOps team, it solves:
- discovering image targets by repository convention;
- inferring local dependencies from
FROM,COPY/ADD --from, andRUN --mount=from; - rebuilding a changed image and every transitive consumer, while leaving unrelated images alone;
- building an affected consumer against either its newly built parent or an immutable digest of the existing stable parent;
- turning the calculated graph into a GitLab child pipeline or dependency-layered GitHub Actions workflow;
- executing the same exact-reference build contract with Docker Buildx, Podman, Buildah, or nerdctl/BuildKit;
- exporting an optional Docker Buildx Bake definition whose target contexts preserve the same DAG;
- using unique merge-request and commit tags, with graph-wide stable promotion on
main; - publishing a verified commit-to-image manifest for downstream test, deployment, and release jobs;
- promoting the already-tested digest to a semantic version without rebuilding it;
- making retries safe by verifying image identity before reusing an immutable output tag; and
- failing early on cycles, missing targets, ambiguous short image names, unsafe paths, and malformed Dockerfiles or Containerfiles.
It intentionally models container build dependencies, not runtime service relationships. It
will understand FROM base but not that an API container talks to PostgreSQL at runtime. It also
does not rebuild merely because a remote tag such as alpine:3.22 changed without a Git change;
pin external bases by digest or use a dependency-update process when that distinction matters.
The quickest installation on Linux or macOS downloads the correct archive for the current machine,
verifies it against the release checksum, and installs platform into ~/.local/bin:
curl -fsSL https://raw.githubusercontent.com/davehewy/platform-images/main/scripts/install.sh | shInstall a specific version or choose another destination with environment variables:
curl -fsSL https://raw.githubusercontent.com/davehewy/platform-images/main/scripts/install.sh |
PLATFORM_IMAGES_VERSION=0.13.0 PLATFORM_IMAGES_INSTALL_DIR=/usr/local/bin shOn Windows, run this in PowerShell. It verifies the Windows archive, installs platform.exe below
%LOCALAPPDATA%\Programs, and adds its directory to the user PATH:
irm https://raw.githubusercontent.com/davehewy/platform-images/main/scripts/install.ps1 | iexSet $env:PLATFORM_IMAGES_VERSION or $env:PLATFORM_IMAGES_INSTALL_DIR first to pin a release or
change the destination.
Each GitHub release provides:
| System | Architecture | Release asset |
|---|---|---|
| GNU/Linux | AMD64 / x86_64 | platform-images-v0.13.0-linux-amd64.tar.gz |
| GNU/Linux | ARM64 / AArch64 | platform-images-v0.13.0-linux-arm64.tar.gz |
| macOS / Darwin | Intel AMD64 | platform-images-v0.13.0-darwin-amd64.tar.gz |
| macOS / Darwin | Apple Silicon ARM64 | platform-images-v0.13.0-darwin-arm64.tar.gz |
| Windows | AMD64 / x86_64 | platform-images-v0.13.0-windows-amd64.zip |
| Windows | ARM64 | platform-images-v0.13.0-windows-arm64.zip |
Every archive name contains its exact release version, so downloads from different releases remain
self-identifying when stored together. Each archive contains the native platform executable,
README, and MIT license. SHA256SUMS covers every archive, Python distribution, lockfile, and the
versioned SPDX JSON software bill of materials. GitHub publishes signed build provenance for every
downloadable payload and binds the SPDX SBOM to every archive, Python distribution, and lockfile.
The release tag is placed on the exact main commit that passed both CI gates; package and CLI
versions are derived from that immutable tag. Semantic-release never pushes an extra generated
version-bump commit to the protected branch and therefore needs neither a personal access token nor
a branch-protection bypass.
Verify a downloaded executable archive independently with:
gh attestation verify platform-images-v0.13.0-linux-amd64.tar.gz \
--repo davehewy/platform-images
gh attestation verify platform-images-v0.13.0-linux-amd64.tar.gz \
--repo davehewy/platform-images \
--predicate-type https://spdx.dev/Document/v2.3The Linux executables are built on Ubuntu 22.04 and target modern glibc-based 64-bit distributions. The macOS executables are built natively on Intel and Apple Silicon runners. Windows executables are built natively for x86_64 and ARM64; GitHub currently classifies its hosted Windows ARM64 runner as public preview.
The executable bundles Python and the Python package dependencies. Commands still expect the tools
they orchestrate to exist: Git for change inspection, a supported build backend and registry
transport, and access to the configured registry. Generic OCI
registries use their Distribution API plus username/password or ambient transport credentials;
AWS CLI is required only when registry.provider = "ecr".
If Python 3.12 is already part of the team's toolchain, uv can install an isolated, pinned release:
uv tool install --python 3.12 \
"git+https://github.com/davehewy/platform-images.git@v0.13.0"The universal platform_images-<version>-py3-none-any.whl and conventional Python source
platform_images-<version>.tar.gz are also attached to each GitHub release. The wheel is portable
across CPU architectures and operating systems with Python 3.12.
Clone the repository and install its locked development environment:
uv sync --locked --extra devThis keeps Ruff, pytest, pre-commit, and Commitizen available. An ordinary editable pip environment also works:
python3.12 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"Confirm which release is installed:
platform version
# platform-images 0.13.0In a repository that already contains image target directories but no configuration, initialize it from the existing layout:
platform initinit scans for existing Dockerfile and Containerfile targets, infers their parent directories
as discovery roots, and writes a Docker-based starter platform-images.toml. New configurations
use the generic OCI registry provider, so Nexus, GitLab Container Registry, GHCR, Harbor, and other
Distribution-compatible registries can resolve stable tags without pulling image layers. Select
--registry-provider ecr when AWS-native lookup and login are preferred. Discovery roots can
live at any depth and can be nested—for example, utils and utils/container-images can coexist.
Directories beneath those roots that do not contain either build filename are ordinary repository
directories and are ignored.
It also adds inferred short external bases such as alpine to the starter allowlist. Exact
qualified basenames form local edges automatically; when those references share one repository
path, init adopts it as the cascading output namespace rather than writing per-image entries.
Separator-only variants and strong unique near-matches are paired automatically. init writes the
smallest exact configuration needed to make those guesses reproducible, groups repeated references
to the same repository, and prints one review warning per inferred mapping. Genuine collisions stay
unmapped. It never moves an image, assumes a fixed root name, or overwrites an existing
configuration.
If auto-discovery is intentionally broader than the ownership areas you want, name each existing target parent directory explicitly. The directories can be anywhere beneath the repository:
platform init \
--discovery-root containers/shared \
--discovery-root services/payments/container-images \
--namespace platform/my-repositoryUse --builder podman, buildah, or nerdctl when Docker is not the team's default. init chooses
the matching registry transport unless --registry-transport is set explicitly. Run
platform init --help to see all initialization choices.
As that repository evolves, rerun the inference safely against the existing configuration:
platform reconcileIt adds only unambiguous external bases and repository identity exceptions, prints the exact diff,
preserves existing output policy, comments, newlines, and file permissions, and rolls back if an
inferred edge introduces a new validation error. Use platform reconcile --check in CI to detect
available updates without modifying the checkout. See configuration
reconciliation for the complete merge and safety rules.
If a Dockerfile obtains a registry prefix or complete parent reference from a global ARG without
a default, provide the deterministic checked-in value during initialization:
platform init \
--build-arg SOURCE_REGISTRY=nexus.example.com/gitlab-runnerThe generated [dockerfile.arguments] value is used for both dependency parsing and every real
build command, preventing the graph and the builder from interpreting different parent images.
Then, from the repository root:
platform images list
platform images validate
platform images graph
platform images build curl --dry-runIf installed through the development environment without activating it, prefix commands with
uv run:
uv run platform images list
uv run platform images validate
uv run platform images graph
uv run platform images build curl --dry-runRemove --dry-run when the configured backend is available. New platform init configurations
select Docker explicitly because it is the most common starting point. Podman remains the
backward-compatible default when older configurations omit the build setting; select another
backend with --builder, or use --engine when its matching registry transport should be selected
too.
The tool separates three names that are often accidentally conflated:
- Logical target name — the target directory name, such as
ubuntu-base-24-04. This stable name is used by the graph, commands, plans, manifests, and CI relationships. - Published repository — the registry-relative destination, such as
gitlab/ubuntu-base-24-04. The registry hostname is supplied separately throughPLATFORM_IMAGES_REGISTRY. - Tag or digest — the repository version, such as
latest,sha-<commit>, or@sha256:.... It does not define target identity.
Name each target directory for the image's stable role, using lowercase letters and numbers with
., _, or - separators. That directory name is the repository-wide logical ID, so it must be
unique across every discovery root. Keep registry hosts, repository paths, and tags out of the
directory name; changing the directory name is an identity change, not merely a layout change.
For new or repository-owned Dockerfiles, prefer the short logical name:
# utils/container-images/application/Dockerfile
FROM ubuntu-base-24-04This is the clearest local contract: the checked-in target named ubuntu-base-24-04 is a build
input. The planner binds it to the exact newly built image or unchanged stable digest, so the
consumer does not pull a mutable tag by accident.
Build these Dockerfiles through platform images build or the generated CI. A plain
docker build/podman build does not receive the controller's named contexts and may try to pull a
short logical name from a registry.
Qualified references require no per-image configuration when their final repository component exactly equals a unique logical target. Given these targets:
containers/
├── ubuntu24-04-base/Dockerfile
└── application/Dockerfile
the existing qualified source below automatically creates
application -> ubuntu24-04-base, regardless of registry hostname, repository path, tag, or
digest:
FROM nexus.example.com/gitlab-runner/ubuntu24-04-base:latestThe same target can be referenced from several private or public registry hosts without listing
those hosts in configuration. An unmatched qualified source remains external. The tool only uses
an exact final-component match during ordinary graph construction; it never makes a fuzzy graph
edge at build time. platform init may make a reviewed best guess, but records that decision as an
exact repository or alias mapping before validating the graph.
The same rule applies when the reference is assembled through a build argument:
ARG SOURCE_REGISTRY
FROM ${SOURCE_REGISTRY}/ubuntu24-04-base:latest[dockerfile.arguments]
SOURCE_REGISTRY = "nexus.example.com/gitlab-runner"The resolved source remains visible in images show, and every Docker, Podman, Buildah, or nerdctl
command receives --build-arg SOURCE_REGISTRY=.... Values in this table are committed build policy,
not secrets; registry passwords belong in CI secrets.
For an internal source containing an ARG, execution materializes a temporary Dockerfile in which
only that image operand points at the logical named context. Other uses of the same ARG remain
unchanged, and the checked-in Dockerfile is never edited. This makes prefix expressions such as
${SOURCE_REGISTRY}/base:latest bind to the exact planned parent consistently across Docker,
Podman, Buildah, and nerdctl instead of letting an engine pull the configured mutable spelling.
During platform init, a common observed path such as gitlab-runner/<logical-target> becomes the
single output namespace instead of generating one table per image:
[registry]
namespace = "gitlab-runner"
registry_environment_variable = "PLATFORM_IMAGES_REGISTRY"
stable_tag = "main"With PLATFORM_IMAGES_REGISTRY=nexus.example.com, ubuntu24-04-base then publishes as:
nexus.example.com/gitlab-runner/ubuntu24-04-base:sha-<full-commit-sha>
nexus.example.com/gitlab-runner/ubuntu24-04-base:main
Tags and digests are removed only while comparing identity. The exact expanded Dockerfile source is
then replaced with the planned commit-addressed image through a named build context, so the edge is
real in both platform images graph and the executed build. Inspect the evidence with:
$ platform images show application
application
dependencies: ubuntu24-04-base
local_references:
nexus.example.com/gitlab-runner/ubuntu24-04-base:latest -> ubuntu24-04-base (FROM, line 1)
platform init optimizes for adopting an existing repository with minimal operator work. It first
removes the registry host, port, tag, and digest, then compares the registry-relative repository
with all discovered logical targets. It automatically accepts:
- an exact basename;
- a unique separator-insensitive basename, such as remote
ubuntu-24-04-baseand localubuntu24-04-base; and - a strong unique similarity match when the next-best candidate is clearly worse.
The most strongly represented repository path becomes the global registry.namespace. That value
cascades to every discovered target. init writes an images.<target>.repository only when a
target's actual remote name differs from <registry.namespace>/<logical-target>, and writes an
alias only for an additional or legacy input spelling. Twenty-seven consumers of one remote base
therefore produce one mapping, not twenty-seven warnings.
For example, these repeated sources:
FROM nexus.example.com:8088/risk-repo/ubuntu-24-04-base:latestpaired with local directory ubuntu24-04-base generate only:
[registry]
namespace = "risk-repo"
[images."ubuntu24-04-base"]
repository = "risk-repo/ubuntu-24-04-base"The registry endpoint remains environment configuration:
export PLATFORM_IMAGES_REGISTRY=nexus.example.com:8088init also prints a compact audit record:
Review warning: init applied 1 inferred repository mapping:
- risk-repo/ubuntu-24-04-base -> ubuntu24-04-base
(separator-normalized, 27 references; canonical repository override)
The generated mapping is exact from then on: graphing and building do not repeatedly guess. If
separator normalization maps to multiple local targets, or two candidates are too close, init
does not choose. Validation groups all occurrences of that remote repository into one warning and
recommends one primary configuration change for the whole repository rather than presenting three
equal-weight choices for every Dockerfile.
For an already configured repository, an unresolved near-match looks like:
$ platform images validate
Validation passed (2 image targets).
Warnings: 1
containers/application/Dockerfile:1
[probable-local-reference] qualified image repository resembles local target
'ubuntu24-04-base' but is currently external (27 references across 27 build files):
nexus.example.com/gitlab-runner/ubuntu24-base
When that spelling is only an accepted dependency input, platform reconcile can apply the single
recommended alias:
[images."ubuntu24-04-base"]
aliases = [
"nexus.example.com/gitlab-runner/ubuntu24-base",
]Use repository when that different name is also where the locally built image must be published.
init selects this automatically when the source belongs to its inferred output namespace. The
value is registry-relative and tagless:
[images."ubuntu24-04-base"]
repository = "gitlab-runner/ubuntu24-base"If the near-match is genuinely external, suppress that one warning—and prevent automatic basename matching if the names are equal—with an explicit exception:
[identity]
external_repositories = [
"docker.io/vendor/ubuntu24-base",
]During a registry migration, list old repository identities as tagless aliases:
[images.ubuntu-base-24-04]
repository = "gitlab/ubuntu-base-24-04"
aliases = [
"old-nexus.example.com/legacy/ubuntu-base-24-04",
]Aliases are accepted as dependency inputs but never become output destinations. Repository and
alias identities must be globally unique; validation fails rather than guessing if two targets
claim the same identity. platform images show <target> displays the effective repository,
aliases, and every exact Dockerfile source that formed a local edge.
The published repository's final component does not have to equal the logical target. For example,
this deliberately maps local target directory foundation to a differently named remote image:
[images.foundation]
repository = "gitlab/ubuntu-base-24-04"FROM nexus.example.com/gitlab/ubuntu-base-24-04:latest then creates an edge to foundation.
An unrelated rename such as foundation to ubuntu-base-24-04 has no defensible name similarity,
so configure it explicitly. Clever inference is reserved for unique evidence; it does not turn an
arbitrary remote repository into a local target.
The repository contains two deliberately small images beneath its configured default discovery root:
images/
├── base/
│ └── Dockerfile
└── curl/
└── Dockerfile
images/base/Dockerfile starts from an allowed external image:
FROM alpine:3.22
RUN printf '%s\n' 'platform-local-base-v1' > /etc/platform-base-markerimages/curl/Dockerfile names the local target base directly:
FROM base
RUN apk add --no-cache curl
ENTRYPOINT ["curl"]
CMD ["--version"]The tool resolves base as the checked-in target—not as docker.io/library/base—and discovers:
Container image dependency graph
└── base
└── curl
The graphic uses the checked-in base and curl images. The commands below are copyable:
platform images list
platform images graph
platform images build curl --dry-run
# With GitLab registry and CI variables present:
platform images plan --ci --all --format gitlab
# Generate a ready-to-customize GitHub Actions workflow (Docker by default):
platform images generate-workflow github --output .github/workflows/container-images.yml
# Export the complete graph as native Docker Buildx Bake HCL:
platform images generate-bake --output docker-bake.hcl
docker buildx bake --file docker-bake.hcl --printPaths beginning with images/ in the worked examples are outputs from this repository's checked-in
configuration, not a required layout. Set discovery.roots to any repository-relative locations
that reflect your ownership model; inventory, change mapping, plans, and rendered pipelines use
those paths instead.
Use this during adoption, review, or incident diagnosis to confirm that discovery agrees with the team's mental model:
$ platform images list
base
curl
$ platform images validate
Validation passed (2 image targets).
$ platform images show curl
curl
build_file: images/curl/Dockerfile
repository: platform-images/curl
aliases: none
dependencies: base
local_references:
base -> base (FROM, line 1)
dependents: none
$ platform images graph
Container image dependency graph
└── base
└── curlUse --format json with show, validate, or graph when another tool needs to consume the
result. JSON output includes a schema version so it can be treated as an automation contract. Use
platform images graph --ascii when a terminal cannot display Unicode line-drawing characters.
For a diamond or other multi-parent DAG, every incoming edge remains visible, but a shared target's
children are expanded only on its first occurrence; later occurrences say [already shown] so a
large graph cannot explode into exponentially repeated terminal output.
A developer asks to build curl. Its parent is included automatically:
$ platform images build curl --dry-run
podman build --file images/base/Dockerfile --tag localhost/platform-images/base:dev images/base
podman build --build-context base=container-image://localhost/platform-images/base:dev --file images/curl/Dockerfile --tag localhost/platform-images/curl:dev images/curlWithout --dry-run, those commands execute in that order. The second build does not rewrite
FROM base; Podman binds the logical name through a named container-image:// build context.
The equivalent Docker Buildx plan uses Docker's docker-image:// named context and loads each
local result for its consumer:
$ platform images build curl --engine docker --dry-run
docker buildx build --load --file images/base/Dockerfile --tag localhost/platform-images/base:dev images/base
docker buildx build --load --build-context base=docker-image://localhost/platform-images/base:dev --file images/curl/Dockerfile --tag localhost/platform-images/curl:dev images/curlBuildah uses the same containers-storage named-context form as Podman. For a nerdctl local chain,
the controller exports each newly built parent as an exact OCI layout before binding it into the
consumer; this avoids treating localhost/... as a registry:
platform images build curl --builder buildah --dry-run
platform images build curl --builder nerdctl --dry-runAll four backends consume the same discovered graph and exact references; the backend changes execution, not selection. The compatibility matrix explains daemon, storage, and version requirements.
Docker teams can export that same selection as native Buildx Bake HCL:
platform images generate-bake --image curl --output docker-bake.hcl
docker buildx bake --file docker-bake.hcl --print
docker buildx bake --file docker-bake.hcl --load selectedThe generated curl target receives contexts = { "base" = "target:image-base" }, so BuildKit
sees the dependency directly and may still build unrelated targets in parallel. Qualified Nexus,
GitLab, GHCR, and other configured local spellings are bound to the same parent target. Configured
ARG-based image references are safely materialized as dockerfile-inline; ordinary build files
remain referenced by path. See Docker Buildx Bake export for affected CI
plans, immutable unchanged parents, --push, metadata, and the deliberate Docker-only boundary.
When the stable local parent is already present and only the leaf needs rebuilding, opt out of the upstream closure explicitly:
platform images build curl --no-depsUse --no-deps deliberately: it trusts the existing
localhost/platform-images/base:dev rather than recreating it.
Suppose a merge request edits only images/curl/Dockerfile:
BASE_SHA=$(git merge-base HEAD origin/main)
platform images changed --base "$BASE_SHA" --head HEAD
# curl
platform images affected --base "$BASE_SHA" --head HEAD
# curlbase is not rebuilt because no consumer needs a new base. The curl plan still needs an exact
parent, so CI resolves base:main through the configured ECR or generic OCI provider and injects
an immutable reference. An ECR example is:
123456789012.dkr.ecr.eu-west-2.amazonaws.com/platform-images/base@sha256:<digest>
This is the important partial-rebuild case: the leaf is rebuilt, its unchanged parent is reused, and the parent cannot move underneath the running build.
For offline plan inspection, provide the stable lookup instead of calling either registry API:
export PLATFORM_IMAGES_REGISTRY=123456789012.dkr.ecr.eu-west-2.amazonaws.com
export PLATFORM_IMAGES_STABLE_REFS='{
"base": "123456789012.dkr.ecr.eu-west-2.amazonaws.com/platform-images/base@sha256:<digest>"
}'
export CI_PIPELINE_ID=812
export CI_MERGE_REQUEST_IID=42
platform images plan --base "$BASE_SHA" --head HEAD --format jsonNow suppose the change is under images/base/:
$ platform images changed --base "$BASE_SHA" --head HEAD
base
$ platform images affected --base "$BASE_SHA" --head HEAD
base
curlcurl is included because it is a transitive dependent of base. In a larger graph, traversal
continues through every downstream consumer. Unrelated roots and their consumers remain absent.
A complete merge-request plan for the checked-in graph looks like this:
$ platform images plan --ci --all
Build plan (merge_request):
base
reasons: all-images
output: 123456789012.dkr.ecr.eu-west-2.amazonaws.com/platform-images/base:ci-812-0123456789abcdef0123456789abcdef01234567
curl
reasons: all-images, dependent-of:base
output: 123456789012.dkr.ecr.eu-west-2.amazonaws.com/platform-images/curl:ci-812-0123456789abcdef0123456789abcdef01234567
needs: base
input base: 123456789012.dkr.ecr.eu-west-2.amazonaws.com/platform-images/base:ci-812-0123456789abcdef0123456789abcdef01234567Both targets use the same pipeline-unique identity. curl consumes the output produced by the
base job in this plan, rather than the older stable base.
For the exact illustrative references above, CI_COMMIT_SHA was set explicitly:
export CI_COMMIT_SHA=0123456789abcdef0123456789abcdef01234567
platform images plan --ci --allThe parent pipeline calculates once, persists the JSON plan, and renders a child pipeline from that same artifact:
generate-image-pipeline:
variables:
GIT_DEPTH: "0"
script:
- platform images validate
- platform images plan --ci --format json > image-plan.json
- platform images render-plan image-plan.json --format gitlab > generated-images.yml
artifacts:
paths: [image-plan.json, generated-images.yml]For the base -> curl merge-request plan, the generated portion is equivalent to:
stages: [build, manifest, consume]
image_base:
extends: .image-build
script:
- platform images ci-build base --output-ref <registry>/platform-images/base:ci-812-<sha> --result-file image-results/base.json
artifacts:
paths: [image-results/base.json]
image_curl:
extends: .image-build
needs: [image_base]
script:
- platform images ci-build curl --output-ref <registry>/platform-images/curl:ci-812-<sha> --input-ref base=<registry>/platform-images/base:ci-812-<sha> --result-file image-results/curl.json
artifacts:
paths: [image-results/curl.json]
publish_image_manifest:
stage: manifest
script:
- platform images build-manifest image-results --mode merge_request --commit-sha <sha> --expected-target base --expected-target curl --output image-build-manifest.json
artifacts:
paths: [image-build-manifest.json]The needs edge is derived from the Dockerfile. Because every job pushes its unique output before
completion, image_curl can run on a different GitLab runner with no shared container-engine image
store. The later manifest stage waits for the complete build stage and receives every result
artifact without adding a needs entry for every image, so even the repository's 360-image stress
graph does not make the manifest job run into GitLab's per-job dependency limit.
The renderer also checks the complete generated child-pipeline job count before writing YAML. Its
default --gitlab-max-jobs 500 matches the current GitLab.com Free pipeline limit and includes one
job per affected image, the manifest job, and the default-branch promotion job when present. Set a
higher value only when the selected GitLab tier or self-managed
instance actually allows it:
platform images render-plan image-plan.json --format gitlab \
--gitlab-max-jobs 1500 > generated-images.ymlJobs added in the locally included project template share that same child-pipeline budget, so leave
room for scanners and deploy jobs. This is an early diagnostic, not a way to bypass a provider
limit: the tool will not split a dependency graph into disconnected GitLab pipelines and silently
weaken its artifact or needs guarantees.
The generated stage list also includes consume. Define project-specific scan, integration-test,
or review-deploy jobs in the locally included GitLab template, give them stage: consume, and use
needs: [{job: publish_image_manifest, artifacts: true}]. Default-branch main promotion waits for
those jobs rather than bypassing them.
Generate the complete workflow into the conventional location:
platform images generate-workflow github \
--default-branch main \
--engine docker \
--output .github/workflows/container-images.ymlFor base -> curl, the workflow has two static build layers. The planning job calculates the
affected set once and emits a dynamic matrix for each layer:
validate -> plan -> image_layer_0 [base] -> image_layer_1 [curl] -> manifest -> promote
Independent targets in the same layer run in parallel. A changed leaf with no rebuilt parent moves
into the first runtime layer and consumes its unchanged parent by stable digest. Empty layers use a
no-op sentinel because GitHub rejects an empty matrix. The authoritative JSON plan travels between
jobs as an artifact, and each matrix entry reloads and validates that plan before building its exact
target. Every matrix entry uploads its digest result; manifest verifies those results against the
plan and publishes image-build-manifest-<commit> for later jobs.
GitHub permits at most 256 jobs generated by one matrix.
When a dependency-safe layer is wider than that, the generator automatically creates parallel
image_layer_<n>_shard_<m> jobs and partitions the targets into deterministic chunks of at most
256. Every shard in a layer waits for every shard in the previous layer, so a 600-image independent
layer runs as 256 + 256 + 88 without flattening or serialising the graph. Narrow dependency chains
still use one job per layer; the generator calculates a fast path-cover bound rather than
multiplying every layer by the repository's total image count.
GitHub job dependencies are part of the checked-in workflow definition, so they cannot be invented after a run starts. The generator solves that constraint by creating enough static layers for the current graph and filling them dynamically from the change plan. If a graph edit becomes deeper than the checked-in workflow, planning fails with a regeneration instruction instead of dropping a dependency. Regenerate and commit the workflow whenever image relationships change.
The default workflow uses Docker Buildx on ubuntu-latest; registry authentication follows
platform-images.toml. A generic OCI configuration uses the PLATFORM_IMAGES_REGISTRY repository
variable plus the same-named username/password secrets configured under [registry]. An ECR
configuration uses PLATFORM_IMAGES_REGISTRY, AWS_ROLE_TO_ASSUME, and AWS_REGION variables
with short-lived AWS OIDC credentials. Pull requests from forks run graph validation but do not
receive registry credentials or execute image builds. For a pre-authenticated self-hosted ECR
runner with Podman:
platform images generate-workflow github \
--runner self-hosted \
--engine podman \
--aws-auth ambient \
--output .github/workflows/container-images.ymlSee GitHub Actions for the trust model, generated job structure, required variables, and regeneration contract.
Merge requests create isolated images and never update the stable alias:
<registry>/platform-images/base:ci-812-<full-commit-sha>
<registry>/platform-images/curl:ci-812-<full-commit-sha>
The default branch creates commit-addressed outputs:
<registry>/platform-images/base:sha-<full-commit-sha>
<registry>/platform-images/curl:sha-<full-commit-sha>
It also adds one final job:
promote_main:
stage: promote
script:
- platform images promote --source <registry>/platform-images/base:sha-<sha> --destination <registry>/platform-images/base:main
- platform images promote --source <registry>/platform-images/curl:sha-<sha> --destination <registry>/platform-images/curl:mainIf either build or manifest verification fails, promotion never starts. Registry updates across
several repositories are not transactional, however, so a network failure during promotion can
still update only some mutable aliases. Deployments that require a coherent set should consume the
digest-pinned manifest rather than several moving main tags.
A documentation change outside configured global inputs affects no target:
platform images affected --base "$BASE_SHA" --head HEAD
# no outputGitLab will not accept a child pipeline with no runnable jobs, so the renderer emits a successful
manifest job. It records the commit and an empty images object, which is an explicit, useful
answer for downstream automation:
stages: [build, manifest, consume]
publish_image_manifest:
stage: manifest
script:
- echo "No container images are affected."
- platform images build-manifest --mode merge_request --commit-sha <sha> --output image-build-manifest.json
artifacts:
paths: [image-build-manifest.json]The parent pipeline remains structurally identical whether zero, one, or hundreds of images are selected.
Some files affect every image even though they do not live beneath a configured discovery root.
Add those paths to changes.global_inputs:
[changes]
global_inputs = [
"shared/install-ca-certificates.sh",
"shared/company-root-ca.pem",
"build/container-policy/**",
]A change to any matching path selects every discovered target. Use this for real shared build inputs only; adding broad application or documentation paths defeats selective rebuilding.
Controller-critical paths are always global even if omitted from configuration:
.gitlab-ci.yml
.gitlab/**
.github/workflows/**
platform-images.toml
pyproject.toml
src/platform_images/**
uv.lock
These mandatory patterns cannot be configured away, because a controller or pipeline change must re-evaluate the entire image set safely.
Add a direct directory with exactly one Dockerfile or Containerfile; do not hand-write a
corresponding CI job:
images/debug/Dockerfile
FROM curl
RUN apk add --no-cache bind-tools jqValidation and graph inspection now produce:
$ platform images validate
Validation passed (3 image targets).
$ platform images graph
Container image dependency graph
└── base
└── curl
└── debugA change to base rebuilds base, curl, and debug; a change to curl rebuilds curl and
debug; a change to debug rebuilds only debug. The generated child pipeline grows by one job
and adds the correct needs edge automatically.
Dependencies can appear outside FROM too:
FROM alpine:3.22
COPY --from=base /etc/platform-base-marker /marker
RUN --mount=from=curl,target=/tools,ro cp /tools/usr/bin/curl /usr/local/bin/curlLocal references in COPY/ADD --from and RUN --mount=from participate in the same graph.
Numeric and named stages inside one build file remain stages rather than image targets. A
Containerfile uses the same instruction syntax and graph rules as a Dockerfile; if both names
exist in one target directory validation fails rather than guessing which is authoritative.
Not every image needs to belong to the same chain. Add an independent utility image:
images/healthcheck/Dockerfile
FROM busybox:1.37
CMD ["wget", "--help"]Because busybox is an allowed external image rather than a local target, the repository becomes a
forest with two roots:
Container image dependency graph
├── base
│ └── curl
└── healthcheck
The selection behavior is now:
| Changed directory | Rebuilt images | Why |
|---|---|---|
images/base/** |
base, curl |
Rebuild the changed target and its local consumer. |
images/curl/** |
curl |
The leaf changed; its parent remains an immutable stable input. |
images/healthcheck/** |
healthcheck |
It is an independent root with no local consumers. |
| configured shared input | base, curl, healthcheck |
The shared input is declared to affect every target. |
This is the central reason to calculate a graph instead of maintaining a single “images changed” switch: independent images stay cheap, while dependent images stay correct.
On the first default-branch pipeline, GitLab supplies an all-zero CI_COMMIT_BEFORE_SHA and GitHub
supplies an all-zero github.event.before. There is no valid previous commit to compare, so the
controller deliberately builds the complete graph.
For a manual bootstrap or recovery rebuild, request the same behavior explicitly:
platform images plan --ci --all --format json > image-plan.json
platform images render-plan image-plan.json --format gitlab > generated-images.ymlUse --ci --all after introducing the tool, creating a new registry namespace, or intentionally
invalidating every stable image. Normal pipelines should use --ci so Git determines the minimal
affected set.
If curl accidentally says FROM bsae, validation does not silently pull an unrelated public
image:
images/curl/Dockerfile:1
unqualified image reference is neither a local target nor an allowed external image: bsae
possible local target: base
If a local image directory is deleted while a surviving Dockerfile still references it, planning also fails. If images reference each other cyclically, validation reports the deterministic cycle. These fail-closed checks turn repository mistakes into reviewable errors rather than surprising registry pulls or deadlocked pipelines.
A monorepo does not need to move every container definition under one directory. Point discovery at the existing areas instead:
[discovery]
roots = [
"containers/shared",
"services/payments/container-images",
"platform/observability/builds",
]containers/shared/base/Containerfile
services/payments/container-images/api/Dockerfile # FROM base
services/payments/container-images/worker/Dockerfile # FROM base
platform/observability/builds/collector/Containerfile # independent
Discovery produces one repository-wide graph:
Container image dependency graph
├── base
│ ├── api
│ └── worker
└── collector
A change beneath services/payments/container-images/api/ selects only api; a change beneath
containers/shared/base/ selects base, api, and worker. The roots only locate targets—the
target names and inferred edges are global. Consequently, defining api beneath two roots is an
error with both paths reported.
Roots may be nested when a repository has ownership areas at different depths:
[discovery]
roots = ["utils", "utils/container-images"]With that configuration, utils/tool/Dockerfile defines tool, while
utils/container-images/api/Dockerfile defines api. The container-images grouping directory is
not mistaken for a target. Matching considers every configured root, so if utils/container-images
is itself a real outer target with a build file, changes in a more deeply nested target select both
the nested target and the containing image context. This keeps rebuild detection conservative when
Docker build contexts genuinely nest.
The recommended process is build once, test by digest, promote the same digest:
Git commit
-> affected DAG plan
-> commit-addressed build and registry push
-> image-build-manifest.json
-> tests / review deployment use @sha256
-> release automation promotes @sha256 to :v1.2.3
Do not rebuild an image after deciding it passed. A second build of the same source can differ because an external base, package index, timestamp, or build service changed. The registry digest is the identity of the bytes that were actually tested; the Git SHA identifies their source. The manifest joins those two identities.
Every generated build job writes a small result artifact. The final manifest job requires exactly
one result for every planned target and verifies the commit, project source, tag/digest pairing,
immutable reference, and dependency inputs. GitHub additionally reloads the authoritative plan and
checks every result against it; GitLab's result-producing commands are rendered directly from that
plan. GitHub publishes an artifact named image-build-manifest-<commit>; GitLab's
publish_image_manifest job publishes the same image-build-manifest.json file, including for a
no-change plan.
A two-image manifest looks like this (digests shortened here only for readability):
{
"schema_version": 1,
"mode": "default_branch",
"base_sha": "<previous-commit>",
"commit_sha": "0123456789abcdef0123456789abcdef01234567",
"source": "https://github.com/acme/containers",
"images": {
"base": {
"reference": "registry.example/acme/base:sha-0123456789abcdef0123456789abcdef01234567",
"digest": "sha256:<base-digest>",
"immutable_reference": "registry.example/acme/base@sha256:<base-digest>",
"input_references": {}
},
"curl": {
"reference": "registry.example/acme/curl:sha-0123456789abcdef0123456789abcdef01234567",
"digest": "sha256:<curl-digest>",
"immutable_reference": "registry.example/acme/curl@sha256:<curl-digest>",
"input_references": {
"base": "registry.example/acme/base:sha-0123456789abcdef0123456789abcdef01234567"
}
}
}
}The references have deliberately different jobs:
| Identifier | Meaning | Recommended use |
|---|---|---|
:ci-<pipeline>-<commit> |
Isolated merge-request output. | Review environments and integration tests; never release it. |
:sha-<commit> |
Human-searchable default-branch build identity. | Retry, audit, and locating a commit's output. Treat it as immutable. |
@sha256:<digest> |
Immutable registry content identity. | Tests, deployments, provenance records, and promotion sources. This is the safest handoff. |
:v1.2.3 |
Human semantic release name for an already-tested digest. | Release configuration and user-facing documentation. Make version tags immutable in the registry. |
:main |
Moving convenience channel. | Development consumers that deliberately want the latest successful default-branch result. Do not use it as release evidence. |
Download the manifest artifact, select the logical target, and fail if it is absent:
IMAGE_NAME=curl
IMAGE_REF="$(jq -er --arg name "$IMAGE_NAME" \
'.images[$name].immutable_reference' image-build-manifest.json)"
docker pull "$IMAGE_REF"
docker run --rm "$IMAGE_REF" --versionPodman, Buildah, and nerdctl accept the same digest-pinned registry reference. A deployment job can
write IMAGE_REF to a Helm value, Kustomize image override, Terraform input, or deployment API.
Record the full digest in the deployed revision so rollback and incident response do not depend on
where a mutable tag points later.
The manifest contains only the affected build set. That is normally what a test or deployment job
wants: if curl changed, use its new digest; if it did not, do not redeploy it. An empty images
object means the controller deliberately found no image work—it is not a missing artifact.
Let the versioning tool calculate a version only after the commit build and required tests pass. Then authenticate the chosen registry transport and promote from the manifest:
RELEASE_TAG=v1.2.3
platform images registry-login --registry-transport docker
platform images promote-manifest image-build-manifest.json \
--tag "$RELEASE_TAG" \
--expected-commit "$CI_COMMIT_SHA" \
--registry-transport dockerpromote-manifest pulls each @sha256 source, applies the semantic destination tag in the same
image repository, and pushes it. It refuses a merge-request manifest, a manifest for another
commit, an invalid container tag, a missing selected image, or a manifest whose digest fields are
internally inconsistent. Use repeated --image <name> options when a release should contain only
specific images; omit them to promote every image in the manifest.
Two release models are reasonable:
- Changed-image releases: an ordinary default-branch manifest contains only changed images and their affected consumers. Promote those entries. This is efficient and works well when each image has an independent release history.
- Coordinated repository releases: if
v1.2.3must exist for every image, make the release candidate pipeline useplatform images plan --ci --all(or GitHub's generatedrebuild_allinput), test that full manifest, and then promote all of it. Do not assume an ordinary partial manifest represents an unchanged image.
If semantic-release runs in a later workflow or pipeline, retrieve the manifest artifact for the
release commit—not merely the newest successful run—and pass that SHA through --expected-commit.
Retain manifest artifacts and commit-addressed registry outputs for at least the release and
rollback window. If the artifact cannot be found, fail the release rather than silently rebuilding
or using main.
Promotion across multiple image repositories is gated but not transactionally atomic: all builds
and manifest verification finish before it starts, but a registry or network failure can interrupt
the sequence. Consumers that must roll out a coherent multi-image set should treat the manifest as
the release bill of materials and deploy all @sha256 values from it. Semantic tags and main are
useful names; the manifest is the exact set.
Configuration lives in platform-images.toml. The fastest safe way to create it is:
platform initUse this when adopting the tool in an existing repository: it derives discovery roots from the
locations of existing build files, infers unqualified external base names, consolidates a common
qualified local-image path into one cascading output namespace, applies unique high-confidence
remote-to-local repository matches, and validates the resulting exact graph. Pass repeatable
--discovery-root options when you want to declare ownership areas explicitly, --namespace when
the registry hierarchy differs from the repository name, and --builder when Docker is not the
right default. The command refuses to replace platform-images.toml; configuration changes after
initialization can use platform reconcile for additive high-confidence updates while retaining
the existing namespace, output overrides, discovery roots, unrelated settings, and comments. It
prints a reviewable diff and inference audit; genuine ambiguities remain grouped warnings. Use
--check to enforce that drift in CI. Namespace changes, output redirection, discovery ownership
changes, and removal of old mappings remain ordinary reviewed source changes.
Bake export deliberately adds no second configuration table. generate-bake consumes the same
discovery roots, canonical repositories, aliases, Dockerfile ARG values, tag policy, and registry
inputs as every other planner and renderer. Use normal Buildx --set, --load, --push, secret,
cache, and platform options for execution-only concerns rather than duplicating them in
platform-images.toml.
This repository's complete configuration is:
[registry]
namespace = "platform-images"
registry_environment_variable = "PLATFORM_IMAGES_REGISTRY"
stable_tag = "main"
transport = "podman"
provider = "ecr"
authentication = "ecr"
scheme = "https"
[tags]
ci_prefix = "ci"
commit_prefix = "sha"
[build]
backend = "podman"
[discovery]
roots = ["images"]
[dockerfile]
allowed_short_external_images = ["alpine", "busybox"]
[changes]
global_inputs = [
"src/platform_images/**",
"pyproject.toml",
"uv.lock",
"platform-images.toml",
".gitlab-ci.yml",
".gitlab/**",
".github/workflows/**",
]| Setting | What it controls | When and why to change it |
|---|---|---|
registry.namespace |
Default output prefix between the registry host and logical target name. Local images use the same path beneath localhost/; an images.<target>.repository entry overrides it for that target. It does not restrict which source registries can form edges. |
Change it once to match the team's usual output hierarchy, such as platform/base-images. init adopts one consistently observed qualified path automatically, while explicit --namespace wins. |
registry.registry_environment_variable |
The environment-variable name from which the registry hostname is read. | Keep the default when one standard variable is acceptable. Change it to align with an established CI variable such as COMPANY_ECR_REGISTRY without hard-coding an account or region in the repository. |
registry.stable_tag |
The mutable alias resolved for unchanged parents and updated after a successful default-branch graph. | main is a clear default. Change it to stable, production, or another existing convention. This is a build baseline, not an environment deployment mechanism. |
registry.transport |
The CLI used for login, retry inspection, push, and promotion. Accepted values are docker, podman, buildah, and nerdctl. |
Usually match the builder. Podman and Buildah may be mixed because they share containers-storage; Docker and nerdctl require their matching transport. |
registry.provider |
Stable-tag resolver. oci uses the standard registry Distribution API; ecr uses AWS DescribeImages. Existing configurations default to ecr; new init configurations default to oci. |
Use oci for Nexus, GitLab Registry, GHCR, Harbor, Docker Registry, and similar services. Use ecr when AWS-native identity and regional checks are wanted. |
registry.authentication |
Login policy: credentials or ambient for OCI, and ecr for ECR. |
Use credentials on ordinary hosted runners. Use ambient only when a trusted runner already configured the transport and stable-manifest API access is anonymous or the runner exposes both configured credential variables. Use ecr with AWS workload identity. |
registry.username_environment_variable / password_environment_variable |
Names of the two secret environment variables consumed by OCI API lookup and registry-login. Defaults to PLATFORM_IMAGES_REGISTRY_USERNAME and PLATFORM_IMAGES_REGISTRY_PASSWORD. |
Rename them to established protected CI variable names. Values never belong in TOML; generated GitHub workflows read same-named repository secrets. |
registry.scheme |
https by default; controls generic OCI manifest and token requests. |
Keep HTTPS. Set http only for an explicitly trusted internal development registry; ECR always requires HTTPS. |
For ECR, the registry value is the host only:
export PLATFORM_IMAGES_REGISTRY=123456789012.dkr.ecr.eu-west-2.amazonaws.comThe account and region are parsed from that hostname and passed explicitly to AWS CLI operations.
For Nexus or another OCI registry, keep the hostname equally separate and provide protected credentials under the configured variable names:
[registry]
namespace = "gitlab-runner"
registry_environment_variable = "PLATFORM_IMAGES_REGISTRY"
stable_tag = "main"
transport = "docker"
provider = "oci"
authentication = "credentials"
username_environment_variable = "NEXUS_USERNAME"
password_environment_variable = "NEXUS_PASSWORD"
scheme = "https"export PLATFORM_IMAGES_REGISTRY=nexus.example.com
export NEXUS_USERNAME="$CI_NEXUS_USERNAME"
export NEXUS_PASSWORD="$CI_NEXUS_TOKEN"
platform images registry-loginStable parent lookup uses GET /v2/<repository>/manifests/<stable-tag>, handles Basic and Bearer
challenges, validates Docker-Content-Digest (or computes it from the manifest bytes), and never
pulls image layers merely to calculate a plan.
For GHCR in a generated GitHub workflow, set the configured variable names to GITHUB_ACTOR and
GITHUB_TOKEN. The renderer then uses the workflow's native actor/token and grants package-write
permission only to jobs that access the registry; no duplicate credential secrets are required.
The [images.<logical-target>] tables are optional. Without one, the published repository is
<registry.namespace>/<logical-target> and the short logical name is the recommended local
Dockerfile reference. A qualified reference also resolves automatically when its final repository
component exactly matches the logical target; registry hostnames and intermediate paths do not
need to be registered.
Repository configuration cascades from broad policy to narrow exceptions:
- An explicit
platform init --namespaceis the global output prefix and always wins. - Without that option,
initselects the dominant qualified local-reference path as the globalregistry.namespacewhen the evidence has one winner. - Every target then publishes to
<registry.namespace>/<logical-target>without an image table. images.<target>.repositoryoverrides only a canonical remote-name exception.images.<target>.aliasesadds only legacy or additional dependency spellings and never changes the output destination.
This keeps a 300-image repository on one global setting when its naming is uniform while still allowing a handful of real exceptions. Repeated consumers never require repeated configuration.
| Setting | What it controls | When and why to change it |
|---|---|---|
images.<target>.repository |
Exceptional registry-relative output repository and canonical qualified dependency identity. It must not contain a registry hostname, tag, or digest. | Use only when this target's pushed name differs from the global <registry.namespace>/<target> convention, including when the remote basename differs from the logical target. |
images.<target>.aliases |
Exceptional tagless full or registry-relative repositories that resolve to this logical target. Aliases are inputs only. | Use when a remote basename differs from the logical target or during a rename. Exact-basename qualified references need no alias. |
identity.external_repositories |
Tagless qualified or registry-relative repositories that must remain external. | Use only for a genuine name collision or to acknowledge a probable-local-reference warning. Ordinary private and public external images need no entry. |
The registry host remains an environment or CI concern. For example, configure
repository = "gitlab/ubuntu-base-24-04" and set
PLATFORM_IMAGES_REGISTRY=nexus.example.com; do not put nexus.example.com or :latest in the
repository setting. Validation rejects stale target entries, tagged aliases, and identities
claimed by more than one target. platform init consolidates one common qualified-reference path
into registry.namespace, selects a clearly dominant path during migrations, and writes only
remote-basename exceptions below [images]; explicit --namespace always wins.
| Setting | What it controls | When and why to change it |
|---|---|---|
tags.ci_prefix |
Prefix for merge-request outputs: <prefix>-<pipeline-id>-<commit-sha>. |
Change it when registry lifecycle rules or naming policy expect a prefix such as mr or review. Pipeline and commit identity remain part of the tag. |
tags.commit_prefix |
Prefix for default-branch immutable outputs: <prefix>-<full-commit-sha>. |
Change it to match retention or audit conventions, for example git. Do not make this a mutable channel name; retry and collision safety rely on commit-specific outputs. |
The stable tag is deliberately separate from immutable output tags. Builds publish unique outputs; promotion moves only the stable alias after success.
| Setting | What it controls | When and why to change it |
|---|---|---|
discovery.roots |
Non-empty array of repository-relative directories to search. A direct child is an image target only when it contains Dockerfile or Containerfile; every other directory is ignored. Defaults to ["images"]. |
Include each existing ownership area that contains image targets, whether central, service-local, deeply nested, or beneath another ownership area. Roots cannot be absolute, escape through .., or duplicate one another. |
discovery.root |
Backward-compatible singular form for one root. | Existing configurations may keep it. New configurations should use roots, even for one location, so adding another is a one-line change. Do not set both forms. |
For example, this discovers base, api, and worker without a registration list:
[discovery]
roots = ["containers/shared", "services/payments/images"]containers/shared/
└── base/Containerfile
services/payments/images/
├── api/Dockerfile
└── worker/Dockerfile
Target ownership, Git change mapping, validation paths, contexts, and generated plans all use the
matching configured root. Names remain repository-wide logical identifiers, so api can use
FROM base across roots without encoding either directory in the Dockerfile. Every logical name
must therefore be unique across all roots, case-insensitively.
| Setting | What it controls | When and why to change it |
|---|---|---|
build.backend |
CLI that turns a Dockerfile or Containerfile into an image. Accepted values are docker, podman, buildah, and nerdctl. |
Use Docker Buildx for the simplest GitHub-hosted setup, Podman or Buildah for daemonless containers-storage fleets, and nerdctl for containerd/BuildKit environments. Override it with --builder. |
registry.transport |
CLI that authenticates, inspects retry candidates, pushes when required, and promotes stable tags. | Match Docker with Docker and nerdctl with nerdctl. Podman and Buildah are interchangeable when they use the same containers-storage configuration. Override it with --registry-transport. |
The older [build] engine = "..." setting and --engine option remain compatible shorthands that
select a matching builder and transport. New configuration should use the two explicit axes. An
incompatible pair fails during configuration loading rather than discovering a local-store mismatch
halfway through a pipeline.
Docker Buildx and nerdctl/BuildKit push directly in CI; Docker reads BuildKit metadata and nerdctl
pulls the unique output back for identity and digest verification. Local nerdctl dependency chains
use temporary OCI layouts, while registry-backed CI contexts remain docker-image:// references.
Podman and Buildah build into shared containers-storage and push with --digestfile. Planning,
tags, dependency order, OCI identity labels, retry collision checks, and promotion gates are
identical. See the complete support and version matrix.
If a target named curl contains FROM base, the stored relationship is “curl depends on
base”. Human tree output places base above curl, and schedulers receive the corresponding
execution constraint “build base before curl”. Multi-parent relationships from FROM,
COPY/ADD --from, and RUN --mount=from remain one DAG rather than being flattened into a tree;
the text view repeats each incoming occurrence but expands a target's subtree once, while
graph --format json is the authoritative representation.
A repository containing a -> b -> a is invalid. platform images validate reports the complete,
deterministic cycle, and all graph-dependent commands stop before Git, container tooling, AWS, or CI
can schedule work. GitLab then maps direct selected edges to needs; GitHub maps the same selected
DAG to topological build layers.
dockerfile.allowed_short_external_images is the explicit allowlist for unqualified external
repository names:
[dockerfile]
allowed_short_external_images = ["alpine", "busybox", "debian"]Use it for intentional short external references such as FROM alpine:3.22. Do not add local image
names: discovered targets are local automatically. Keeping the list small catches typos and deleted
targets. scratch is always allowed. A registry-qualified external reference such as
ghcr.io/organisation/tooling@sha256:... does not need an allowlist entry.
dockerfile.arguments supplies deterministic global Dockerfile/Containerfile ARG values:
[dockerfile.arguments]
SOURCE_REGISTRY = "nexus.example.com/platform/base-images"
UBUNTU_RELEASE = "24.04"Use this when an image identity or reproducible build input has no suitable checked-in Dockerfile
default. Configured values override Dockerfile defaults, participate in FROM, COPY/ADD --from, and RUN --mount=from parsing, and are passed as sorted --build-arg options to every
supported backend. Validation prints this exact table as the remedy for an unresolved global ARG.
Do not place credentials here: Docker build arguments can be recorded in image history and build
logs.
changes.global_inputs adds repository paths whose modification must rebuild every target. Patterns
support exact paths, shell-style matching, and directory forms ending in /**:
[changes]
global_inputs = [
"shared/**",
"scripts/build-image.sh",
"security/container-policy/*.json",
]Use this for files actually read by multiple image builds or for policy that changes the meaning of
all builds. Do not use it for ordinary per-image inputs beneath discovery.roots; those are mapped
to their owning target automatically. Configured patterns are unioned with the mandatory controller
inputs listed earlier.
| Variable | Purpose | When to provide it |
|---|---|---|
PLATFORM_IMAGES_ROOT |
Run against a repository root other than the current directory. | Useful for wrappers, monorepo tooling, and local diagnostics invoked from another directory. |
The variable named by registry_environment_variable |
Registry hostname used for CI outputs, stable lookups, login, and promotion. | Required for CI and change-based plans; normally define it as a protected GitLab project/group variable. |
Variables named by username_environment_variable and password_environment_variable |
Generic OCI username and password/token. | Required when provider = "oci" and authentication = "credentials". Generated GitHub workflows read same-named repository secrets; GitLab projects should use protected and masked variables. |
PLATFORM_IMAGES_STABLE_REFS |
JSON mapping of target names to immutable image references. | Optional deterministic override for tests and offline plan previews. Normally omit it so ECR or OCI resolves the configured stable tag directly. |
CI_PIPELINE_ID |
Makes merge-request tags unique to a pipeline. | Supplied automatically by GitLab. The generated GitHub workflow maps github.run_id to it. |
CI_COMMIT_SHA, CI_PROJECT_URL |
Identify and label a CI build. | Supplied by GitLab; the generated GitHub workflow maps the checked-out head SHA and repository URL. CI builds fail rather than emit untraceable images when either is absent. |
CI_MERGE_REQUEST_DIFF_BASE_SHA |
Preferred merge-request comparison base. | Supplied by GitLab merge-request pipelines. |
CI_COMMIT_BEFORE_SHA, CI_DEFAULT_BRANCH, CI_COMMIT_BRANCH |
Select the comparison and distinguish default-branch promotion from review builds. | Supplied by GitLab or mapped from GitHub event context by the generated workflow. Full Git history is required. |
SOURCE_DATE_EPOCH or CI_COMMIT_TIMESTAMP |
Sets the OCI creation timestamp label. | Use SOURCE_DATE_EPOCH for reproducible build metadata; otherwise GitLab's commit timestamp is used when present. |
Never put registry credentials in platform-images.toml. platform images registry-login passes
OCI credentials to the selected transport over stdin, obtains a regional token from AWS CLI for
ECR, or performs an explicit no-op for authentication = "ambient". Prefer short-lived workload,
job, deploy, or robot-account tokens over personal long-lived passwords.
- Configure
discovery.rootsaround the repository's existing ownership areas and put each owned target in a direct child directory of one of them. Keep exactly oneDockerfileorContainerfileper target and keep ordinary context files alongside it. - Run
platform initand review its repository-inference audit. Prefer logical references such asFROM basefor new files; established qualified references can remain unchanged when the inferred mappings are correct. - Run
platform reconcilewhen later image or reference changes have safe inferred updates, orplatform reconcile --checkas a CI drift gate. - Configure broad policy once—the registry namespace, stable tag, approved short external images, and genuine shared inputs. Keep per-target repository overrides and aliases for actual naming exceptions only.
- Choose
build.backendandregistry.transport, then runplatform images validate,graph, andbuild <leaf> --dry-runlocally. Review the inferred graph with image owners. - Create the destination repositories and least-privilege pull/push credentials. ECR teams can use the concrete IAM policy in ECR setup; OCI providers use their native project, robot-account, deploy-token, or workload-identity controls.
- Either add the parent/child setup in GitLab CI, or generate and commit
.github/workflows/container-images.ymlusing GitHub Actions. Keep full Git history available to the planning job. Docker teams with custom automation can instead export the same persisted plan with Docker Buildx Bake. - Run one
--ci --alldefault-branch bootstrap to populate stable tags. - Let ordinary merge-request and default-branch pipelines use
--cito calculate the minimal rebuild set. - Make test and deployment jobs download
image-build-manifest.jsonand use each target'simmutable_reference, rather than reconstructing a tag or pullingmain. - If container images have semantic releases, retain the default-branch manifest, calculate the
version after tests, and use
promote-manifest --expected-committo tag those exact digests. - Use the JSON graph, plan, and manifest artifacts during review or incident diagnosis to explain why each image was selected, what it consumed, and which bytes the commit produced.
| Command | Typical use |
|---|---|
platform version [--format json] |
Print the installed release without requiring a configured repository. platform --version is the shorter equivalent. |
platform init [--discovery-root <path> ...] [--namespace <name>] [--builder <name>] [--registry-provider oci|ecr] [--build-arg NAME=VALUE ...] |
Safely create platform-images.toml by inferring target groups and a minimal cascading repository policy. Applies strong unique mappings with a review audit and refuses to overwrite existing configuration. |
platform reconcile [--check] |
Add safe external-base and repository-identity updates to an existing configuration, print the exact diff, and validate or roll back. --check detects drift without writing. |
platform images list |
Inventory discovered image targets. |
platform images show <name> |
Inspect one target's build file, output repository, aliases, dependencies, exact source-to-target bindings, and dependents. |
platform images validate |
Gate commits before planning or building. |
platform images graph [--format json] [--ascii] |
Review or export the complete dependency graph. Text output is always a visibly connected tree; --ascii replaces Unicode line drawing for limited terminals. |
platform images build <name> [--dry-run] [--no-deps] [--builder <name>] |
Build locally with deterministic dependency binding using the configured or selected backend. |
platform images changed --base <sha> --head <sha> |
Show directly changed targets and removed target directories. |
platform images affected --base <sha> --head <sha> |
Show the topologically ordered downstream rebuild set. |
platform images plan --image <name> |
Preview a local plan for one or more explicitly selected targets. Repeat --image to select several. |
platform images plan --all |
Preview a complete local build plan. |
platform images plan --ci |
Calculate a CI change plan and exact references from normalized GitLab/GitHub environment inputs. |
platform images plan --ci --all |
Force a complete CI bootstrap or recovery plan. |
platform images render-plan image-plan.json --format gitlab [--gitlab-max-jobs <n>] |
Validate and render the persisted plan into child-pipeline YAML, failing before GitLab when the exact generated job count exceeds the configured provider limit (default 500). |
platform images generate-bake [--all|--image <name>|--ci|--plan <file>] [--output docker-bake.hcl] |
Export deterministic Docker Buildx Bake HCL. Local parents use target: contexts, unchanged CI parents use digest-pinned docker-image:// contexts, and empty affected plans are valid no-ops. |
platform images generate-workflow github [--builder <name>] [--registry-transport <name>] [--output <path>] |
Generate a complete dependency-layered GitHub Actions workflow. |
platform images ci-build ... [--result-file <path>] [--builder <name>] [--registry-transport <name>] |
Execute one CI target with exact input/output references and optionally persist its digest result; normally called only by generated jobs. |
platform images build-plan-target <plan> <name> [--result-file <path>] |
Strictly reload a persisted plan, build its named target, and optionally persist its result; used by GitHub matrix jobs. |
platform images build-manifest <results...> --plan <plan> --output <path> |
Verify per-image results and publish the commit-to-image digest manifest; generated CI calls this automatically. |
platform images github-matrix <plan> --max-layers <n> [--max-shards <n>] |
Turn a persisted plan into dependency-safe, provider-bounded GitHub matrix outputs; used by generated workflows. |
platform images registry-login [--registry-transport <name>] |
Authenticate the selected transport using ECR, OCI credentials, or an explicit ambient policy without printing a token. |
platform images promote ... [--registry-transport <name>] |
Move a successfully built immutable output to a stable alias. |
platform images promote-plan <plan> [--registry-transport <name>] |
Promote a complete default-branch plan only after its generated build graph succeeds. |
platform images promote-manifest <manifest> --tag <version> --expected-commit <sha> |
Promote the exact digest-pinned outputs of a tested default-branch commit to a semantic version without rebuilding. Repeat --image to select entries. |
<name> for builders and transports is one of docker, podman, buildah, or nerdctl.
--engine <name> remains the concise compatibility form when both axes should match.
GitLab generates one child job per affected target, with direct graph edges rendered as needs.
GitHub Actions cannot add arbitrary job dependencies after a run starts, so the generated workflow
contains static topological layers and fills each layer with an affected-target matrix at runtime.
Layers wider than GitHub's 256-entry matrix limit are split into parallel shards automatically.
GitLab rendering counts its jobs against a configurable provider budget before emitting YAML. Both
consume the same authoritative image-plan.json and preserve parallelism between independent
targets.
Each generated job uses only its direct local dependencies in needs. ECR and generic OCI
providers resolve unchanged stable parents directly to immutable manifest digests before builds are
scheduled. It pushes before completing,
so jobs do not rely on a shared runner cache. Every supported registry transport pulls the exact
output tag and inspects identity
labels for target, commit, source, and dependency references. A matching image is reused by digest;
a mismatched immutable tag fails as a collision.
Every successful plan publishes a verified manifest, even when its affected set is empty. That is
the stable interface for later tests, deployments, and releases. Merge-request plans never promote
stable or semantic tags. Default-branch promotion runs only after manifest verification (and, in
GitLab, the project-defined consume stage). Removed targets are reported but are not deleted from
the registry; lifecycle and deletion policy remain explicit infrastructure responsibilities.
generate-bake is an additional Docker execution renderer over the same plan. It does not replace
the generated workflows' retry identity checks, verified digest manifest, or gated promotions.
Custom CI can persist image-plan.json, render HCL from it, and invoke Buildx once with --push;
teams that want the complete delivery contract should retain the GitHub or GitLab workflow.
See architecture, adding an image, GitHub Actions, GitLab CI, ECR setup, and contributing and releases.
The graph controller is fast, including on imperfect repositories. A deterministic 360-image stress repository with 737 local edges validates in about 160 ms, maps a foundation change to all 360 affected images in about 0.32 ms, renders its bounded 742-line terminal graph in about 1.1 ms, creates the complete image plan plus both GitHub and GitLab CI output in about 151 ms, and renders the full native Buildx Bake definition in about 13 ms on the development machine used for the reference run.
The default benchmark is deliberately awkward rather than perfectly tidy. It creates 360 mixed
Dockerfile/Containerfile targets across seven roots—including overlapping utils and
utils/container-images roots—with 25 build waves, fan-in, fan-out, diamonds, configured
repositories, Nexus/GitLab/GHCR spellings, ARG-based parents, aliases, digests, multistage
references, COPY/ADD/RUN --mount inputs, public and explicitly external images, heredocs, 28
non-image directories, and eight qualified references that deliberately remain warnings. The unit
suite also runs platform init over a separate 320-image qualified-reference chain, proves that
one inferred namespace cascades across all 320 targets, and confirms that only eight genuine remote
basename exceptions are written. A second 320-image case starts from an existing uniform
configuration, introduces eight remote-name exceptions, and proves that platform reconcile
writes exactly those eight mappings and is byte-for-byte idempotent on its next run. The tests
mutate the full corpus to prove deterministic rejection of cycles, ambiguous aliases, missing
managed images, and malformed syntax.
These are median results from 17 August 2026 using Python 3.12:
| Scenario | Images | Edges | Runs | Validate | Topological order | Plan leaf/upstream set | Root change impact | Terminal graph | Plan + render GitHub and GitLab | Render Bake HCL |
|---|---|---|---|---|---|---|---|---|---|---|
| Imperfect layered repo | 360 | 737 | 25 | 162.759 ms | 0.154 ms | 8.847 ms / 202 images | 0.313 ms / 360 images | 1.073 ms / 742 lines | 150.932 ms | 12.714 ms |
| Imperfect layered repo | 1,000 | 2,086 | 10 | 595.309 ms | 0.468 ms | 41.393 ms / 914 images | 0.922 ms / 1,000 images | 4.044 ms / 2,091 lines | 401.921 ms | 34.819 ms |
| Clean multi-parent chain | 100 | 292 | 25 | 23.416 ms | 0.042 ms | 4.341 ms / 100 images | 0.099 ms / 100 images | — | — | — |
| Clean multi-parent chain | 1,000 | 2,992 | 10 | 233.790 ms | 0.476 ms | 45.671 ms / 1,000 images | 0.956 ms / 1,000 images | — | — | — |
Run scripts/benchmark-graph.py to see how fast it is on your laptop or CI runner:
uv run --locked python scripts/benchmark-graph.py \
--scenario imperfect --images 360 --iterations 25Use --scenario chain for the simpler deep multi-parent graph, and change --images to test a
larger repository. Use --json to capture comparable results. --max-validate-ms,
--max-leaf-plan-ms, --max-graph-render-ms, --max-ci-render-ms, and
--max-bake-render-ms turn measurements into regression budgets. The figures above measure the
controller—filesystem discovery, Dockerfile parsing, graph construction, change propagation,
planning, terminal rendering, and YAML/JSON/HCL generation—not container builds or registry
transfers. They are evidence from one machine rather than hardware guarantees, so benchmark the
intended CI runner before choosing a local threshold.
The implementation avoids work that grows quadratically with the number of images:
- inverse dependency edges are constructed in one pass over the graph;
- deterministic topological ordering uses a priority queue;
- affected selection visits each reachable target and edge once;
- local dependency reasons propagate through one reverse topological pass, using compact bitsets when several explicit targets are selected; and
- repeated qualified external names reuse their near-match result, so hundreds of identical public base references do not each rescan every local target.
CI recreates the 360-image imperfect repository and applies deliberately generous median ceilings of 750 ms for discovery/validation, 75 ms each for leaf planning and bounded terminal rendering, 750 ms for all-image planning plus both pipeline renderers, and 250 ms for Bake rendering. Those ceilings catch accidental repeated scans, exponential tree expansion, and per-target closure work without turning normal runner variance into flaky builds. Dockerfile reads, parsing, and YAML/HCL serialization remain the largest controller costs; actual container builds and registry transfers will ordinarily dominate total pipeline time by orders of magnitude.
uv run --locked --extra dev pre-commit run --all-files
uv run --locked --extra dev pytest
uv run --locked python scripts/benchmark-graph.py --scenario imperfect --images 360 --iterations 25
uv run --locked --extra dev pytest -m integration
uv buildThe integration suite builds and verifies the base -> curl topology with Docker Buildx, Podman,
Buildah, and nerdctl/BuildKit, including a qualified local parent supplied through a configured
Dockerfile ARG; it skips only tooling unavailable locally. The 100-image enterprise fixture still
tests real Git change detection across four roots and several registry identities. The separate
360-image adversarial fixture goes wider and noisier, and checks both generated CI formats plus the
complete Buildx Bake HCL in full. A real Docker integration test parses and executes the generated
ARG-bound parent chain with docker buildx bake --load.
GitHub CI installs or verifies all four backends and requires every real build to pass. It also
repeats the locked quality suite and builds the wheel and source distribution. Conventional Commit
messages drive Python Semantic Release after main passes CI; releases add all-asset checksums, an
SPDX SBOM, signed provenance and SBOM attestations, and a verification job before installer tests.
Found incorrect graph selection, an unsupported Dockerfile or Containerfile relationship, or a generated pipeline problem? Open a structured bug report. Ideas for broader repository layouts or CI behaviour are welcome through the feature request form. Please search existing issues first and never include registry credentials or tokens in logs.
Development and release guidance lives in CONTRIBUTING.md. The project is maintained by David Heward. If it saves your platform team time, you can buy me a coffee.
Released under the MIT License. Copyright (c) 2026 David Heward.