Heads builds inside a versioned Docker image that provides a consistent build
environment. ROM reproducibility still depends on matching source and build
inputs. Docker images are built with Nix since
PR #1661. flake.nix and
flake.lock define the local image's Nix inputs; the published image selected
by docker_repro.sh is pinned separately through docker/DOCKER_REPRO_DIGEST
(or its environment override) and cross-checked against the canonical CI image
pin. A local image build does not by itself establish bit-identical ROM output.
See also: General reproducible-build notes, Reproducible build practices, QEMU testing, CircleCI pipeline notes.
The short path to build Heads is to do what CircleCI does:
- Install Docker CE for your OS
- Run
./docker_repro.sh make BOARD=XYZ
# Pinned-image build (recommended for all users)
./docker_repro.sh make BOARD=EOL_x230-hotp-maximized
# Build and run a QEMU board
./docker_repro.sh make BOARD=qemu-coreboot-fbwhiptail-tpm2 run./docker_repro.sh is the canonical wrapper for the configured pinned image.
docker_local_dev.sh is intended for developers who need to modify the local image built
from flake.nix/flake.lock and is not recommended for general testing.
The supported and tested workflow uses the provided Docker wrappers
(./docker_repro.sh, ./docker_local_dev.sh, or ./docker_latest.sh). Host-side
installation of QEMU, swtpm, or other QEMU-related tooling is unnecessary for the
standard workflow and is not part of the tested configuration. Only advanced or edge-case
workflows may require installing those tools on the host (see qemu.md).
The Docker images produced by our Nix build include QEMU (qemu-system-x86_64),
swtpm / libtpms, canokey-qemu (a virtual OpenPGP smartcard), and other userspace
tooling required to build and test QEMU boards. You only need Docker on the host. For KVM
acceleration expose /dev/kvm (load kvm_intel / kvm_amd); the wrapper scripts mount
it automatically when present.
If you plan to manage disk images or use qemu-img snapshots on the host (outside
containers), install the qemu-utils package locally (which provides qemu-img).
You can also use Nix to enter a development shell or build Heads directly without Docker:
# Enter a development shell with all dependencies
nix develop
# Or run a single command in the environment
nix develop --command make BOARD=EOL_x230-hotp-maximizedNote: nix develop provides QEMU, swtpm, and other required dependencies in the shell
environment, so separate host installs are not needed for this workflow. The Docker
workflow is still recommended for its consistent container image identity.
Three wrappers cover different use cases:
| Script | Use case | Reproducibility | When to use |
|---|---|---|---|
./docker_repro.sh |
Canonical build environment | Pinned to an image manifest digest | Canonical default path: cross-checks the pinned image against the current CI digest; ROM equality still depends on matching source/build inputs |
./docker_local_dev.sh |
Developer customization | Local build may differ if flake changes | Developers only: Rebuilds from local flake.nix/flake.lock when dirty; use HEADS_CHECK_REPRODUCIBILITY=1 to verify against published version |
./docker_latest.sh |
Convenience | Defaults to the configured digest; may be unpinned if none is available | Testing/convenience: Uses the selected image; by default falls back to DOCKER_REPRO_DIGEST when available. Runs unpinned only when no digest is configured, in which case confirmation is required unless HEADS_ALLOW_UNPINNED_LATEST=1 or DOCKER_LATEST_DIGEST is set. |
Recommendation by role:
- End users & QA: Use
./docker_repro.shwhen the pinned image identity is desired; verify ROM reproducibility separately when required - Developers: Use
./docker_local_dev.shwhen iterating on the build system or Nix flake, but verify reproducibility withHEADS_CHECK_REPRODUCIBILITY=1before committing - Maintainers: Use
./docker_repro.shfor official releases; see Maintenance workflow
Examples:
# Canonical builds
./docker_repro.sh make BOARD=EOL_x230-hotp-maximized
./docker_repro.sh make BOARD=qemu-coreboot-fbwhiptail-tpm2 run
# Developer workflow (verify before committing)
./docker_local_dev.sh make BOARD=UNTESTED_nitropad-ns50
HEADS_CHECK_REPRODUCIBILITY=1 ./docker_local_dev.sh make BOARD=UNTESTED_nitropad-ns50If you are already inside the container interactively, run make BOARD=board_name as usual.
The current worktree is bind-mounted at the same path inside the container.
X11 sockets, /dev/kvm when available, and USB devices are passed separately.
This does not make arbitrary host paths visible: repository, build, key, disk,
and install-image paths passed to the build must resolve inside the worktree.
Use repository-contained paths such as ./qemu_img/.
# Build ROM, then export public key to emulated USB storage at QEMU runtime
./docker_repro.sh make BOARD=qemu-coreboot-fbwhiptail-tpm2
# Inject a GPG public key into the ROM image
./docker_repro.sh make BOARD=qemu-coreboot-fbwhiptail-tpm2 \
PUBKEY_ASC=./qemu_img/public-key.asc inject_gpg
# QEMU run with repository-contained disk and install-image paths
./docker_repro.sh make BOARD=qemu-coreboot-fbwhiptail-tpm2 \
USB_TOKEN=Nitrokey3NFC \
PUBKEY_ASC=./qemu_img/public-key.asc \
ROOT_DISK_IMG=./qemu_img/root.qcow2 \
INSTALL_IMG=./install-images/installer.iso \
runIf you do not specify USB_TOKEN, the container uses the included canokey-qemu virtual
token by default. Set USB_TOKEN (or use hostbus/hostport/vendorid,productid) to
forward a hardware token instead. See qemu.md for details.
Each wrapper shows its own focused help (only variables it actually uses). For the complete
environment reference run docker/common.sh directly:
# Wrapper-specific help
./docker_repro.sh --help
./docker_latest.sh --help
./docker_local_dev.sh --help
# Full environment variable reference (shared helper)
./docker/common.shThe shared helper documents all supported environment variables (opt-ins and opt-outs) and defaults. Wrapper help is intentionally narrower so it only lists variables relevant to that wrapper.
HEADS_MAINTAINER_DOCKER_IMAGE — override the canonical maintainer's Docker image
repository (default: tlaurion/heads-dev-env). Use this for local testing or if you
maintain a fork. Example: export HEADS_MAINTAINER_DOCKER_IMAGE="myuser/heads-dev-env".
This affects reproducibility checks and default image references across all Docker wrapper
scripts.
HEADS_CHECK_REPRODUCIBILITY_REMOTE — specify which remote image to compare against
when verifying reproducibility (default: ${HEADS_MAINTAINER_DOCKER_IMAGE}:latest). Use
this to test against a specific tagged version instead of :latest.
# Compare against a specific version
export HEADS_CHECK_REPRODUCIBILITY_REMOTE="tlaurion/heads-dev-env:v0.2.7"
HEADS_CHECK_REPRODUCIBILITY=1 ./docker_local_dev.shHEADS_DISABLE_USB=1 — disable automatic USB passthrough and the automatic USB
cleanup (default: 0).
HEADS_X11_XAUTH=1 — force mounting your ${HOME}/.Xauthority into the container
for X11 authentication. When set the helper will bypass programmatic Xauthority generation
and mount your ${HOME}/.Xauthority (if present); if the file is missing the helper will
warn and will not attempt automatic cookie creation (GUI may fail).
HEADS_FORCE_DOCKER_REBUILD=1 — force rebuild from flake.nix/flake.lock regardless of git status. Also attempts to delete the cached nix store result/link before rebuilding. Takes precedence over HEADS_SKIP_DOCKER_REBUILD=1.
HEADS_SKIP_DOCKER_REBUILD=1 — skip automatically rebuilding the local image when
flake.nix/flake.lock are dirty.
HEADS_CHECK_REPRODUCIBILITY=1 — compares the locally built image ID with
the remote maintainer image's config digest through
compare_image_reproducibility(). This checks Docker image identity only; it
does not compare Heads ROMs or authenticate the publisher. It requires network
access and compares against ${HEADS_MAINTAINER_DOCKER_IMAGE}:latest by default.
Use HEADS_CHECK_REPRODUCIBILITY_REMOTE for a different tag (for example
v0.2.7). See Verifying reproducibility below.
HEADS_AUTO_INSTALL_NIX=1 — automatically attempt to download the Nix single-user
installer when nix is missing (interactive prompt suppressed).
For supply-chain safety the helper will download the installer to a temporary file and
print its SHA256; it will NOT execute the installer automatically unless the downloaded
installer matches a pinned hash. The helper will also attempt to detect the installer
version heuristically (when possible) and suggest the canonical releases URL (for example
https://releases.nixos.org/nix/nix-2.33.2/install.sha256) so you can fetch the
published sha and compare. To verify:
-
Preferred — pin a release version: set
HEADS_NIX_INSTALLER_VERSIONto a release (for examplenix-2.33.2). The helper will fetchhttps://releases.nixos.org/nix/${HEADS_NIX_INSTALLER_VERSION}/installandinstall.sha256and show both checksums for you to compare. To auto-run in trusted automation, setHEADS_NIX_INSTALLER_SHA256to the expected sha256 as well. -
Or compute-and-pin locally: run
./docker/fetch_nix_installer.sh --version nix-2.33.2(or--url) to download the installer and print its sha256, then setHEADS_NIX_INSTALLER_SHA256to that value for automation.Otherwise verify the downloaded installer manually and run it yourself:
sh /path/to/installer --no-daemon.
HEADS_AUTO_ENABLE_FLAKES=1 — automatically enable flakes by writing
experimental-features = nix-command flakes to $HOME/.config/nix/nix.conf
(interactive prompt suppressed).
HEADS_MIN_DISK_GB — minimum free disk space in GB required on /nix (or / if
/nix missing) for building (default: 50).
HEADS_SKIP_DISK_CHECK=1 — skip the preflight disk-space check.
HEADS_ALLOW_UNPINNED_LATEST=1 — when set, bypass the interactive warning that using
:latest in ./docker_latest.sh is a supply-chain risk (otherwise :latest requires
confirmation unless DOCKER_LATEST_DIGEST is set or the wrapper can fall back to
DOCKER_REPRO_DIGEST for the maintainer image).
DOCKER_LATEST_DIGEST — pin the convenience wrapper to a specific immutable digest.
DOCKER_REPRO_DIGEST — pin the image used by ./docker_repro.sh to an immutable
digest: tlaurion/heads-dev-env@<digest>. A non-empty environment value takes
precedence; otherwise docker_repro.sh falls back to the non-comment digest in
docker/DOCKER_REPRO_DIGEST. The pin fixes image identity only; it does not
guarantee ROM reproducibility. On the canonical default
tlaurion/heads-dev-env path, the wrapper also extracts the digest from
.circleci/config.yml and aborts on a mismatch. Fork or other non-canonical
repository overrides can skip that CI digest cross-check.
When USB passthrough is active the wrappers will detect processes that may be holding a
USB token (for example scdaemon or pcscd). The wrapper will warn and, on interactive
shells, give a 3-second abort window before attempting to kill those processes to free
the token. Set HEADS_DISABLE_USB=1 to opt out of this automatic cleanup.
For fully unattended builds (script/non-interactive shell), wrap the command in
script to provide the pseudo-TTY that docker_repro.sh's -ti requires. Pass
-f (flush) as well: without it, script buffers its output and a non-tty/agent
context that reads the stream incrementally can see the build stall or lose
output. HEADS_DISABLE_USB=1 skips the USB token passthrough (and its 3-second
abort window) so nothing prompts:
script -qefc "HEADS_DISABLE_USB=1 ./docker_repro.sh make BOARD=EOL_t480-hotp-maximized"
script -qefc "HEADS_DISABLE_USB=1 ./docker_repro.sh make BOARD=EOL_x220-maximized"script (util-linux) allocates a pseudo-terminal (PTY) so docker run -ti has
a TTY in a non-interactive/agent context; the command's output is still shown on
screen (stdout). The options used above:
-c "<cmd>"runs that command.-qsuppressesscript's own start/done banners.-epropagates the command's exit status, so a failed build is detectable.-fflushes output as it is written (no buffering), so incremental readers don't see it stall.
script also records the session to a file — by default ./typescript, which
this repo gitignores (.gitignore:34 typescript*). Leave it default to keep
the transcript; pass /dev/null as the trailing argument to skip recording.
HEADS_DISABLE_USB=1 skips USB-token passthrough and its 3-second abort window.
Both HEADS_DISABLE_USB=1 and script are unnecessary when running from
an interactive terminal.
HEADS_DISABLE_USB=1 ./docker_repro.sh make BOARD=qemu-coreboot-fbwhiptail-tpm2 runFor details about selecting or forwarding a physical USB token to QEMU (handled by the
USB_TOKEN make variable), see qemu.md.
Note: you may need to prefix commands with sudo depending on your Docker setup.
# List local images
docker images
# Inspect a specific image (IDs, digests, repo tags)
docker image inspect <image>
# Remove a specific image
docker rmi <image>
# Remove all local images (destructive)
docker rmi -f $(docker images -aq)
# Remove unused images/containers/networks/build cache (destructive)
docker system prune -a --volumesIf you manage qcow2 disk images on the host, qemu-img can create, list, restore, and
delete snapshots. These examples assume a qcow2 disk image:
# Create a snapshot
qemu-img snapshot -c clean root.qcow2
# List snapshots
qemu-img snapshot -l root.qcow2
# Restore (apply) a snapshot
qemu-img snapshot -a clean root.qcow2
# Delete a snapshot
qemu-img snapshot -d clean root.qcow2
# Optional: create an overlay backed by a base image
qemu-img create -f qcow2 -b base.qcow2 overlay.qcow2If you prefer to run these inside the container, prefix with ./docker_repro.sh:
./docker_repro.sh qemu-img snapshot -l root.qcow2The canonical build wrapper is ./docker_repro.sh. It prefers a non-empty
DOCKER_REPRO_DIGEST environment value, falls back to the pinned value in
docker/DOCKER_REPRO_DIGEST, and resolves the selected reference to an immutable
manifest digest. The canonical default tlaurion/heads-dev-env path
cross-checks that digest against .circleci/config.yml before running the
command; fork/non-canonical overrides can skip that check. The pin fixes the
container image identity only; it does not compare or guarantee equality with CI
build outputs when the repository commit, working-tree state, or other build
inputs differ.
./docker_repro.sh make BOARD=EOL_x230-hotp-maximized
./docker_repro.sh make BOARD=qemu-coreboot-fbwhiptail-tpm2 runThis will:
- Select
DOCKER_REPRO_DIGESTfrom the environment, falling back todocker/DOCKER_REPRO_DIGEST - Validate and resolve the selected reference to a manifest digest; on the canonical default path, cross-check
.circleci/config.yml(fork/non-canonical overrides can skip this) - Pull that exact image if it is not present locally
- Execute the requested build or QEMU command in the resolved image
docker_repro.sh does not download or compare CI hashes.txt, final ROM
hashes, or other build outputs. Use the separate verification procedure in
reproducible-builds.md
when a same-commit ROM comparison is wanted.
About the published image:
- Repository:
tlaurion/heads-dev-envon Docker Hub is the maintainer's canonical image (configurable viaHEADS_MAINTAINER_DOCKER_IMAGE) - Versioning: Tagged with version numbers (e.g.,
v0.2.7) for stability;:latestis mutable and not recommended - Pinning:
DOCKER_REPRO_DIGESTfrom the environment takes precedence;docker/DOCKER_REPRO_DIGESTis the fallback manifest reference (tlaurion/heads-dev-env@sha256:...) - Local image comparison: Rebuilding the flake without local changes is intended to reproduce the image, but a digest comparison is a verification step, not a premise that guarantees success
- Fork/Override: To use a different image repository, set
HEADS_MAINTAINER_DOCKER_IMAGE="youruser/your-image"before running any Docker wrapper script
DOCKER_REPRO_DIGEST (the environment variable or the repository file docker/DOCKER_REPRO_DIGEST)
is consumed by ./docker_repro.sh via resolve_docker_image(); pinning fixes
image identity and avoids accidental selection of a mutable :latest tag. It
does not prove ROM reproducibility or authenticate the publisher.
./docker_local_dev.sh is a developer helper that ensures a local Nix-based Docker image
(linuxboot/heads:dev-env) is available for interactive development. It performs preflight
checks and interactive prompts to make the process easier:
- Ensures
nixis installed and flakes are enabled; if missing it will prompt to install Nix and enable flakes. SetHEADS_AUTO_INSTALL_NIX=1and/orHEADS_AUTO_ENABLE_FLAKES=1to suppress prompts and proceed automatically. - Requires either
curlorwgetto fetch the Nix installer; if neither is present the script will print how to install one and abort. - Checks disk space on
/nix(or/if/nixis absent); default minimum is 50 GB (HEADS_MIN_DISK_GB=50) — override or skip the check withHEADS_SKIP_DISK_CHECK=1. - If
flake.nixorflake.lockare dirty (uncommitted changes), the helper will rebuild the local Docker image. To intentionally trigger a rebuild, make and keep changes toflake.nix(for example update an input or a harmless comment) or updateflake.lock, then run./docker_local_dev.sh; the helper detects the dirty flake files and will rebuild automatically. To avoid an automatic rebuild, commit or stash your changes or setHEADS_SKIP_DOCKER_REBUILD=1to disable the check.
Notes on automation:
- The
./docker_local_dev.shhelper will attempt to ensure Nix and flakes are available when you run it interactively. SetHEADS_AUTO_INSTALL_NIX=1/HEADS_AUTO_ENABLE_FLAKES=1to suppress prompts. - Building the Docker image and populating
/nixcan require significant disk space — at least 50 GB free on/nix(or/if/nixis not present). Adjust viaHEADS_MIN_DISK_GBor skip the check withHEADS_SKIP_DISK_CHECK=1. - The Nix installer requires a downloader; either
curlorwgetmust be available on the host. The helper will guide you to install one if neither is present. - For reproducible builds prefer
./docker_repro.sh;./docker_local_dev.shis intended for development and will rebuild the local image whenflake.nix/flake.lockare dirty (unlessHEADS_SKIP_DOCKER_REBUILD=1).
If you don't already have Nix, install it:
[ -d /nix ] || sh <(curl -L https://nixos.org/nix/install) --no-daemon
. /home/user/.nix-profile/etc/profile.d/nix.shEnable flake support in nix:
mkdir -p ~/.config/nix
echo 'experimental-features = nix-command flakes' >>~/.config/nix/nix.conf# Manual
nix build --print-build-logs --verbose --out-link docker/result .#dockerImage && docker load -i docker/result
# Via helper (rebuilds automatically when flake files are dirty)
./docker_local_dev.shYour local Docker image linuxboot/heads:dev-env is ready to use. Image
identity can be compared with the helper, but ROM reproducibility still
depends on matching repository and build inputs.
On some hardened OSes, you may encounter problems with ptrace:
> proot error: ptrace(TRACEME): Operation not permitted
The most likely reason is that your kernel.yama.ptrace_scope variable is too high and doesn't allow docker+nix to run properly. You'll need to temporarily set it to 1 while you build:
sudo sysctl kernel.yama.ptrace_scope # show current value (probably 2 or 3)
sudo sysctl -w kernel.yama.ptrace_scope=1 # lower for the build
# ... build ...
sudo sysctl -w kernel.yama.ptrace_scope=<original_value> # restore afterFor development iterations that don't require Docker reproducibility:
nix develop --command make BOARD=novacustom-nv4x_adl
nix develop --command make BOARD=novacustom-nv4x_adl kexec # single packagenix develop drops into the same Nix environment that builds the Docker
image. Packages are cached in build/$ARCH/; subsequent builds are
fast. No sudo, no Docker.
See modules.md for the sentinel chain and how to force a rebuild after changing patches.
The host and container share build/. Binaries produced by one environment
can therefore be unusable in the other if their runtime/toolchain assumptions
differ. This is a generic cleanup case, not a claim about one particular Nix
loader path or a reproduced incident.
The conservative recovery is to remove the affected kernel build directory so the container regenerates it:
rm -rf build/x86/linux-<ver>/<kconfig-name>If only a specific helper is known to be stale, remove that helper and rerun; otherwise keep host and container build trees separate to avoid mixed artifacts.
# Verify local image matches maintainer's latest
HEADS_CHECK_REPRODUCIBILITY=1 ./docker_local_dev.sh
# Verify against a specific version
HEADS_CHECK_REPRODUCIBILITY=1 \
HEADS_CHECK_REPRODUCIBILITY_REMOTE="tlaurion/heads-dev-env:v0.2.7" \
./docker_local_dev.sh- Setup Nix persistent layer under QubesOS (Thanks @rapenne-s!)
- Install Docker under QubesOS
compare_image_reproducibility() compares the local Docker image ID with the
remote image's config digest. This is an image-build identity check only: it
does not compare Heads ROMs or authenticate the publisher.
The Heads project maintains tlaurion/heads-dev-env on Docker Hub (the
repository is configurable through HEADS_MAINTAINER_DOCKER_IMAGE). The
repository's pinned repo@digest reference is a manifest digest: it
identifies the exact manifest, including its config digest and layer
references, selected from the registry. A matching manifest digest is the
strongest registry-level equality check. The wrapper helper instead performs a
local image-ID versus remote config-digest comparison.
| Scenario | Command |
|---|---|
| Check against latest maintainer image | HEADS_CHECK_REPRODUCIBILITY=1 ./docker_local_dev.sh |
| Check against specific version tag | HEADS_CHECK_REPRODUCIBILITY=1 HEADS_CHECK_REPRODUCIBILITY_REMOTE="tlaurion/heads-dev-env:v0.2.7" ./docker_local_dev.sh |
| Check fork maintainer's image | HEADS_MAINTAINER_DOCKER_IMAGE="youruser/heads-dev-env" HEADS_CHECK_REPRODUCIBILITY=1 ./docker_local_dev.sh |
| Standalone check at any time | ./docker/check_reproducibility.sh linuxboot/heads:dev-env tlaurion/heads-dev-env:v0.2.7 |
You have either:
- Built a local Docker image with
./docker_local_dev.sh(produceslinuxboot/heads:dev-env), or - Built from
nix build --out-link docker/result .#dockerImage(results indocker/resultsymlink loadable viadocker load -i docker/result)
Enable reproducibility verification automatically during your build with
HEADS_CHECK_REPRODUCIBILITY=1:
# Verify against the default (maintainer's :latest image)
HEADS_CHECK_REPRODUCIBILITY=1 ./docker_local_dev.sh
# Example output when digests MATCH (reproducible build):
# === Reproducibility Check ===
# Local image (linuxboot/heads:dev-env): sha256:8ae7744cc8b4ff0e959aa6dfeeb40dbd40d20ac6fa1f7071dd21ec0c2d0f9f41
# Remote image (tlaurion/heads-dev-env:latest): sha256:8ae7744cc8b4ff0e959aa6dfeeb40dbd40d20ac6fa1f7071dd21ec0c2d0f9f41
# (via registry+jq)
# ✓ MATCH: Config digests identical
# Config digest: sha256:8ae7744cc8b4ff0e959aa6dfeeb40dbd40d20ac6fa1f7071dd21ec0c2d0f9f41
# Note: this check compares image config IDs, not manifest identity
# Docker Hub: https://hub.docker.com/layers/tlaurion/heads-dev-env/latest/images/sha256-5f890f3d...
# === End Reproducibility Check ===Docker images expose two distinct identities:
- Config digest / image ID is the SHA-256 of the image config JSON. It
names a config that references layer digests and runtime metadata such as
environment and entrypoint.
docker inspect --format='{{.Id}}'prints this value. Equal image IDs are useful evidence that two images have the same config and layer references, but they do not identify the exact registry manifest selected by a tag. - Manifest digest is the SHA-256 of the manifest that lists the config and
layer blobs. A
repo@sha256:...pin uses this digest. Different manifests can reference the same config digest, for example with different manifest media types or annotations.
Accordingly, a config-ID match is narrower than a manifest-digest match. A
config-ID mismatch proves the images differ; a config-ID match alone does not
prove that the published manifest has the same media type or annotations as a
locally compared one. Use docker buildx imagetools inspect,
docker manifest inspect, or the helper's repo@digest output when registry
manifest identity is the property being checked.
To test against a specific version tag instead of :latest:
HEADS_CHECK_REPRODUCIBILITY=1 \
HEADS_CHECK_REPRODUCIBILITY_REMOTE="tlaurion/heads-dev-env:v0.2.7" \
./docker_local_dev.sh
# Example output when digests DIFFER (expected for different versions):
# === Reproducibility Check ===
# Local image (linuxboot/heads:dev-env): sha256:5f890f3d...
# Remote image (tlaurion/heads-dev-env:v0.2.7): sha256:75af4c81...
# (via registry+jq)
# ✗ MISMATCH: Config digests differ
# === End Reproducibility Check ===
#
# If remote config digest cannot be fetched, falls back to pulling the image:
# === Reproducibility Check ===
# Local image (linuxboot/heads:dev-env): sha256:5f890f3d...
# Could not fetch remote image config digest via registry; falling back to 'docker pull' to compare image IDs (progress will be shown).
# Tip: Install jq and curl for faster registry-based checks (no pull needed).
# Pulling remote image (progress will be shown)...
# Remote image (pulled tlaurion/heads-dev-env:v0.2.7): sha256:75af4c81...
# ✗ MISMATCH: Image IDs differ after pull.
# Local: sha256:5f890f3d...
# Remote: sha256:75af4c81...
# === End Reproducibility Check ===compare_image_reproducibility() compares the local image ID with the
remote image's config digest. Use a manifest inspection when the
requirement is to verify the exact repo@digest
reference or distinguish images that share a config but use different manifest
metadata.
The standalone helper delegates to the same centralized image comparison used
by the wrapper. Its output and exit status are governed by
docker/common.sh; it compares image identity, not Heads ROMs or build
manifests.
./docker/check_reproducibility.sh \
linuxboot/heads:dev-env tlaurion/heads-dev-env:v0.2.7# Get the digest of your local image (after docker load)
docker inspect --format='{{.Id}}' linuxboot/heads:dev-env
# Compare with the published image (will pull if needed)
docker pull tlaurion/heads-dev-env:v0.2.7
docker inspect --format='{{.Id}}' tlaurion/heads-dev-env:v0.2.7✓ Image ID/config digest match — the local Docker image ID equals the remote
image config digest reported by compare_image_reproducibility(). This
compares build-environment identity only; it is not a ROM comparison, does not
prove manifest-digest equality, and does not authenticate the publisher. The
comparison can succeed when:
flake.nixandflake.lockare not modified (repository is clean relative to these files)- The same Nix version and dependencies are used
- Build runs on the same Nix store state
✗ Digests differ — expected when:
- You have uncommitted changes in
flake.nixorflake.lock - Different Nix version or Nix dependencies resolved differently on your system
- Using a different
nixpkgsversion than the locked one inflake.lock
A manifest-digest pin fixes the registry object requested by the wrapper, but a digest by itself does not establish who published that image or prove that the local build environment was uncompromised. A matching local image ID and remote config digest establish only that image-config identity comparison; a matching manifest digest establishes exact registry-manifest identity. Supply-chain review and signature/provenance checks are separate concerns.
Recommendation: pin the canonical repo@sha256:... reference for critical
builds rather than a mutable tag, and record both the manifest digest and the
source commit used for the build.
We do not maintain a docker/DOCKER_LATEST_DIGEST file in the repository because
latest is a user-level convenience and should be explicitly chosen. When
DOCKER_LATEST_DIGEST is unset, ./docker_latest.sh may fall back to DOCKER_REPRO_DIGEST
only when the base image matches the maintainer repo; otherwise it will prompt before
using an unpinned :latest unless HEADS_ALLOW_UNPINNED_LATEST=1 is set.
# 1) Obtain the digest for a published image
# Tip: inspect tags on Docker Hub: https://hub.docker.com/layers/tlaurion/heads-dev-env/
#
./docker/get_digest.sh tlaurion/heads-dev-env:v0.2.7
# Output (example): tlaurion/heads-dev-env@sha256:50a9110c...
# Auto-pull and return digest in one go:
./docker/get_digest.sh -y tlaurion/heads-dev-env:v0.2.7
# 2) Export and use the digest
export DOCKER_LATEST_DIGEST=$(./docker/get_digest.sh tlaurion/heads-dev-env:latest | tail -n1)
DOCKER_LATEST_DIGEST=$DOCKER_LATEST_DIGEST ./docker_latest.sh make BOARD=qemu-coreboot-fbwhiptail-tpm2When a digest is discovered, helpers print a concise summary to help auditing:
Image: tlaurion/heads-dev-env@sha256:50a9...
Digest: sha256:50a9...
Resolved from: local|registry API|env|file
Tip: export DOCKER_LATEST_DIGEST=sha256:50a9...
To change what ./docker_latest.sh uses as the "latest" image:
- Temporary override:
./docker/pin-and-run.sh <repo:tag> -- ./docker_latest.sh <command> - Local convenience env:
export DOCKER_LATEST_DIGEST=$(./docker/get_digest.sh tlaurion/heads-dev-env:vX.Y.Z | tail -n1) - Canonical fallback: edit
docker/DOCKER_REPRO_DIGESTwith the desired digest and commit
# pin-and-run helper examples
./docker/pin-and-run.sh tlaurion/heads-dev-env:v0.2.7 -- ./docker_latest.sh make BOARD=qemu-coreboot-fbwhiptail-tpm2
./docker/pin-and-run.sh -y tlaurion/heads-dev-env:v0.2.7 -- ./docker_latest.sh make BOARD=qemu-coreboot-fbwhiptail-tpm2
# Omit the wrapper — helper defaults to './docker_latest.sh'
./docker/pin-and-run.sh tlaurion/heads-dev-env:v0.2.7 -- make BOARD=qemu-coreboot-fbwhiptail-tpm2Use docker_latest.sh for the historical v0.2.7 image. Do not select that
historical tag through docker_repro.sh, whose digest source is the current
reproducible-image configuration.
Alternative manual commands without the helper:
docker pull tlaurion/heads-dev-env:latest
# prints full repo@digest (if available)
docker inspect --format='{{index .RepoDigests 0}}' tlaurion/heads-dev-env:latest
# to get only the digest portion:
docker inspect --format='{{index .RepoDigests 0}}' tlaurion/heads-dev-env:latest | cut -d'@' -f2Notes: some registries or Docker versions may require docker manifest inspect or
skopeo inspect to obtain an authoritative digest; the helper script tries
docker inspect first, then docker manifest inspect when available.
Acceptable digest formats for DOCKER_REPRO_DIGEST / DOCKER_LATEST_DIGEST:
sha256:<64-hex>, sha256-<64-hex>, or bare <64-hex> — all normalized to sha256:<hex>.
To update the Docker image to a new version (e.g., vx.y.z):
docker_version="vx.y.z"
docker_hub_repo="tlaurion/heads-dev-env"
# Update pinned packages to latest if needed, modify flake.nix as required
nix flake update
# Commit flake changes
git add flake.nix flake.lock
git commit --signoff -m "Bump nix develop based docker image to $docker_version"
# Verify reproducibility: ensure the local build matches (no further changes to flake files)
nix develop --ignore-environment --command true
# Build the new Docker image
nix build --out-link docker/result .#dockerImage
docker load -i docker/result
# Verify you can extract the digest (flake.nix/flake.lock must be committed)
docker inspect --format='{{.Id}}' linuxboot/heads:dev-env
# Tag the image with the new version
docker tag linuxboot/heads:dev-env "$docker_hub_repo:$docker_version"
# Push the new version to Docker Hub (requires push access)
docker push "$docker_hub_repo:$docker_version"
# Capture the digest of the pushed image (use --yes to auto-pull)
new_digest=$(./docker/get_digest.sh -y "$docker_hub_repo:$docker_version" | tail -n1)
prev_digest=$(grep '^[^#]' docker/DOCKER_REPRO_DIGEST | head -n1)
# Update the digest in the repository file
sed -i "s|$prev_digest|$new_digest|" docker/DOCKER_REPRO_DIGEST
# Update the version comment in the repository file
sed -i "s|# Version: .*|# Version: $docker_version|" docker/DOCKER_REPRO_DIGEST
# Update .circleci/config.yml (remove old comment, insert fresh one above the image line)
sed -i \
-e "/^[[:space:]]*# Docker image: /d" \
-e "/^[[:space:]]*- image: ${docker_hub_repo//\//\\/}@/ s|^\([[:space:]]*\)\(- image: ${docker_hub_repo//\//\\/}@\)|\\1# Docker image: $docker_hub_repo:$docker_version\n\\1\\2|" \
.circleci/config.yml
# Commit the digest and config changes
git add docker/DOCKER_REPRO_DIGEST .circleci/config.yml
git commit --signoff -m "Pin docker image to digest for $docker_version"
# Push the branch and create a PR for testing with CircleCI
git push origin docker/squash-docker-changes
# After PR is merged and tested, optionally tag as latest (use with caution)
# docker tag "$docker_hub_repo:$docker_version" "$docker_hub_repo:latest"
# docker push "$docker_hub_repo:latest"- Image comparison: Build the local image with committed, clean
flake.nix/flake.lock, then compare its image ID/config digest with the intended published image. One localnix buildis not evidence of deterministic or bit-identical output. - Digest verification: After pushing, use
./docker/check_reproducibility.shto verify local and remote digests match. - Supply chain: Pin digest in
docker/DOCKER_REPRO_DIGESTand.circleci/config.ymlto ensure all builds reference an immutable, auditable image. - Documentation: Update the version comment in
docker/DOCKER_REPRO_DIGESTso users know which image version is pinned. - User migration: When releasing a new version, communicate the new digest and version in release notes.
Notes:
:latestis a mutable registry tag; its contents are not established by this repository documentationdocker_repro.shuses the image digest declared by.circleci/config.yml; reproducing a CI ROM additionally requires the same repository/build inputs and a separate artifact comparison
export HEADS_MAINTAINER_DOCKER_IMAGE="youruser/heads-dev-env"
# All scripts will now reference your repository
./docker_local_dev.sh make BOARD=EOL_x230-maximized
HEADS_CHECK_REPRODUCIBILITY=1 ./docker_local_dev.sh
# Reproducibility check compares against youruser/heads-dev-env:latest
# resolve_docker_image uses youruser/heads-dev-env as the base imageThe repository file docker/DOCKER_REPRO_DIGEST pins the image identity used
by ./docker_repro.sh. Update it after publishing a new image to keep the
configured reference synchronized; pinning alone does not establish ROM
reproducibility or publisher authenticity.