Reproducibility
Reproducibility here is a property of a lock, not a promise of the tool. boot2deb does not guarantee that any clone rebuilds any image forever. That would over-promise, and during active development it is not even true.
What it guarantees is narrower and honest. It is the machinery to make a given build point reproducible to whatever strength you choose. It is also a documented way to rebuild the images the project publishes.
A build is a point across axes (device, kernel, suite, features, layout). The recipe
.toml records the constraints. The sibling .lock records the exact resolution:
every pinned commit, blob hash, and package manifest. build reads only the lock. That
separation is what lets one recipe serve two intents without choosing between them:
- Rolling — “give me a current working image.” Fresh clone,
updateto re-pin at today’s upstream,build. Best day-to-day UX. The resulting image’s provenance records exactly what went into it, so it is reproducible as of now. - Frozen — “reproduce exactly what shipped.” The lock is pinned and left alone. The image ships with a provenance manifest, and rebuilding it is a mechanical replay. Reproducible across time.
You opt into a strength per lock. Rolling and frozen are the same tool at two dial settings.
The three layers
An image rests on three independent inputs, each with its own durability and its own way to pin. Reproducibility is only as strong as the weakest one you froze.
1. Upstream sources (git commits, blobs)
Every compiled input is pinned to an exact commit in the lock. That covers the kernel, u-boot, and the MPP/RGA/ffmpeg trees. rkbin blobs are pinned by sha256.
A commit is only re-fetchable if its remote still advertises it, so pins fall into durability classes:
- A release tag is immutable and fetchable forever.
- A branch tip is ephemeral, since a force-push orphans it.
- A bare local commit is unfetchable by construction.
boot2deb keeps shipped recipes on durable tags, and makes a non-durable pin loud at
update time. It never substitutes a different commit for an orphaned one, because a
different SHA is different bytes.
boot2deb verify-sources <recipe> is the check. It is a read-only probe that reports
each pin as durable | ephemeral | ORPHANED | skipped, and exits non-zero on any orphan,
so CI can gate on it. It touches only the git remotes.
Custom kernels. A custom kernel is pinned the same way, as a source commit plus a patch series commit. Its one failure mode is rebasing or force-pushing the patch repo, which orphans the pinned commit.
Keep it in the durable class by tagging the patch repo at each release. The pinned commit then lives under an immutable ref, and stays fetchable across future rebases.
2. The Debian archive (rootfs)
The rootfs is the fast-moving layer. A testing suite like forky changes daily, and the
exact package versions a build installs rotate off the live mirror as it advances. Three
mechanisms pin it:
- The lock’s solved manifest fixes which bytes install: every package name, version, and sha256. This is always present.
- The published plan document (
<point>.plan, beside the image) additionally fixes how to install exactly those bytes, and records the archive state they were selected from.boot2deb reproducereplays it. This is written by every build. - A captured
snapshot.debian.orgtimestamp fixes availability of those bytes after they leave the live mirror. This is opt-in and dormant by default (mode = off), so day-to-day builds go straight to the live mirror.
The manifest and the plan are not two spellings of one thing. The manifest is a pin the next build is verified against, and a fresh solve that no longer reproduces it is an error. The plan is an instruction. Hand it back and the rootfs installs that set without solving at all. That is the difference between detecting drift and not being subject to it.
The plan also carries what neither the manifest nor the lock does:
- The mirror that answered, and the suite and components.
- The sha256 of the release body that was verified, and its
DateandValid-Until. - The fingerprint of the certificate that verified it, and the key under that certificate which made the signature.
The provenance manifest repeats those as [[archives]], one entry per repository. An
image’s own record therefore says what its packages were selected from, and not only
which they were.
The plan document states its own format version in its first line, and a boot2deb reads exactly one. A plan written by an older boot2deb is refused, naming both versions, rather than being read on a guess about what its fields meant:
$ boot2deb reproduce turing-rk1/forky --from published/
error: read the pinned plan published/turing-rk1-forky.plan: the document is in format
"ferroday-cage-plan 1", and this library reads "ferroday-cage-plan 2"
That is the correct failure and not a gap to work around. The version moved because a
field changed meaning. Signed-By named the signing subkey, and now names the
certificate’s primary key.
Reading an old document under the new rule would silently report a different key for an archive that never rotated one. That is exactly the event a recorded fingerprint exists to catch. Rebuild the image from its lock to get a plan in the current format.
Replaying a plan moves the trust anchor, which is why it is reproduce’s to do and
not something a build flag can turn on. A pinned install reads neither a release nor a
package index, so the package digests no longer chain to an archive signature. They chain
to the plan.
Each .deb is still verified against the digest the plan records, so a mirror serving
different bytes is caught. What is no longer checked is that the document describes a set
the archive ever offered. For reproducing an image you published, whose plan you have
alongside it, that is the right trade. For a routine build it is not, and a build that
sets no plan resolves exactly as before.
One consequence is worth stating plainly. A recipe that compiles its own packages
replays only if those compiles are byte-reproducible. The kernel .deb, and on a
media-accel recipe ffmpeg-rk/librockchip-mpp1/librga2, install from the build’s own
local pool and are pinned by digest like everything else.
A replay therefore either matches them, which proves the whole image reproduced with its compiles included, or fails naming the package that drifted. The failure is the honest outcome rather than a defect in the mechanism. A build that cannot reproduce its own compiler output was never reproducible, and this is where that becomes visible instead of silent. A board that installs Debian’s kernel and compiles nothing has no such dependency.
Snapshot has three modes:
off, the live mirror only.fallback, live first, with the snapshot backfilling anything that 404s.pin, the snapshot only, for a fully deterministic userland.
Capture a timestamp with --save-snapshot, and activate a mode with
--snapshot fallback|pin. A fallback/pin with no captured timestamp is refused
rather than silently downgraded.
The mirror list a mode resolves to is used for every root a build provisions, not
only for the image’s own userland. That means the target-arch sandbox the media-accel
packages compile inside, and the host-arch cross root the kernel, u-boot and modules
compile inside. It also means the packaging root whose dpkg-deb archives them.
Those roots hold the compilers and the archiver. Pinning the runtime without them would fix what ships and leave what produced it free to move.
Each root’s identity is folded into the artifact-cache keys of what it built, so a
snapshot-pinned build never restores a live-mirror build’s .debs. So is the
build-dependency set layered over it, because a compile probes for what is present.
This is why forky’s churn is not at odds with the model. The tool to freeze against it exists, and a frozen build turns it on.
3. The builder (boot2deb itself)
The same lock built by a different boot2deb can produce a different image, or fail to
read an old lock. During active development, breaking changes are expected, and the
project does not carry compatibility shims to read old locks forever. So the builder is
an input like any other, and the provenance manifest records it in [built_with].
That section carries two commits, because “what produced this image” has two answers that move independently:
| field | names | captured |
|---|---|---|
commit / dirty | the program — the boot2deb binary that ran | stamped into the binary when it is compiled |
config_commit / config_dirty | the data — the config tree it read layers, recipes and the lock from | read from --root when the build starts |
A config tree is dirty when a tracked file has changed. It is also dirty when an untracked file sits under a directory the build reads layers, overlays or recipes from. A new overlay file ships in the image before anyone commits it, so it counts. A scratch file elsewhere in the checkout does not.
One checkout can supply both, and in the layout boot2deb is developed in it does. They
still cannot answer for each other. An installed boot2deb run against a config tree
has a commit from wherever it was built. Its config_commit comes from the tree in
front of it.
A single binary building two different config trees is the ordinary case, not an exotic
one.
The section carries one more coordinate, ferroday_cage. It is a version rather than a
commit, because it names a released dependency instead of a checkout. That library is
what boot2deb sandboxes every build command with, and what it provisions every Debian
root through.
That library versions on its own schedule. Neither commit above bounds it, and a defect
in containment or provisioning belongs to it rather than to the binary that linked it.
A reader with the boot2deb checkout could reach the same number through its lock file.
The two cases that matter cannot. A builder with no commit has no checkout to consult,
and a dirty one has no commit that identifies what it held. ferroday_cage answers in
both. boot2deb diff reports it moving between two builds, and boot2deb doctor prints
the version the running binary links.
The binary’s commit is stamped at compile time rather than read at run time, and that is deliberate. It is stamped only from boot2deb’s own checkout. A source tree unpacked inside some other repository stamps no commit, rather than that repository’s. The binary is the builder, so its identity has to travel with it. An installed boot2deb has no source tree in reach. Reading whatever checkout happened to be nearby would record a different claim than the field makes.
The cost is that a binary can fall behind the checkout it was built from. Commit, forget
to cargo build, and the next image is stamped with the commit before yours, or with
one an amend left unreachable.
So a build refuses to start when the running binary provably is not this checkout’s source:
$ boot2deb build h96-max-m9/forky
error: this boot2deb was compiled from 7e6e2f02674c, but the checkout is at
90ab9c660bc1. An image built now records 7e6e2f02674c as its builder — a commit
that is not what is on disk, and that nobody else can resolve if it was amended
away. Run `cargo build` (seconds) to re-stamp it.
Two cases are certain enough to refuse. Either the stamp names a different commit than
HEAD, or it names HEAD but the sources under crates/ have been edited since.
Editing a device .toml or a .dts is not one of them. That is build input, recorded
by the lock and by config_commit, and it leaves the binary’s identity intact. A binary
compiled from an already-dirty tree is reported and not refused. It records
dirty = true, which is the honest answer rather than a false one.
--allow-stale-builder proceeds anyway, for when you mean it. The check itself is two
git reads, and it runs before any compile. The alternative is discovering the wrong
stamp in a provenance file written at the end of the build. boot2deb doctor reports
the same verdict standing still, and why-rebuild shows it as a builder row above the
compile nodes.
The builder also decides the environment a compile runs in. Every package build and the rootfs customize run in an unprivileged sandbox. What they produce depends on the variables they carry, the filesystem they see, and the identity they hold. It also depends on whether they can reach a network, and on which syscalls succeed. No source pin covers any of that, and all of it moves with the sandbox library boot2deb links.
The manifest therefore records them as data, rather than leaving them to be inferred from
a version. [sandbox] is the launch posture, and [sandbox_env] is the command’s
complete environment. [[sandbox_mounts]] is every mount the sandbox establishes, in
order, down to the /dev device nodes and symlinks. Two images built from one lock that
differ can be compared on the inputs that could explain it.
That record is the series every command starts from. A run’s own working and artifact binds are per-build paths, and its root is a per-build path. The subordinate identity map the rootfs customize adds is its own. None of them is recorded.
The rooting mode contributes its kind (plain or overlay) and nothing else, for the
same reason. A record carrying an overlay’s lower stack or a range map’s id extents would
be a property of the machine rather than of the builder.
That stamp is an as-built record, not a requirement. The stamped commit is a floor. It reproduces the image, as do later commits up to the next change that alters the output for this lock. A later one might carry fixes you want. A commit past that change will not reproduce it.
The floor is all that can ever be recorded, because the breaking change is in the future and unknowable at build time. Even a bugfix can be output-affecting. So the stamp says when the build worked, never when it will break. A reproduce flow reads it to advise, never to enforce. It says “built with X, you are on Y, newer, likely fine, here is how to get X”.
The build host
The three layers above are inputs you choose. The build host is not, since it is whatever machine you happened to run on. The rule for it is therefore different: a host setting either does not reach the image, or it is recorded. Nothing in between.
What is kept out:
-
Your umask. Git records two file modes and no directory modes at all, so a checked-out overlay tree’s modes are your umask rather than authored data. The staged tree is normalized back to git’s own model before it is laid into the rootfs. That is directories
0755, and files0644or0755by the executable bit.Without that, a
002umask (the Ubuntu/Pop!_OS default) ships a group-writable/etc,/usr, and/boot. A077umask ships an image whose/etcno non-root process can read. -
Your git configuration. Every
gitthe build runs, and the pure-Rust clone beside it, is isolated from/etc/gitconfig,~/.gitconfig, and/etc/gitattributes.The setting that decides this is
url.<base>.insteadOf. It rewrites a remote URL, so a host carrying one would fetch a pinned commit from a remote the lock does not name. That is the exact input the lock exists to fix.core.hooksPath,am.threeWay,apply.whitespace, and a systemgitattributesare the same class. Transport settings are the cost: express a proxy or credentials through the environment (http_proxy,https_proxy), which git still reads. -
Your distro’s
dpkg. No.debis archived by a tool from your host, the kernel’s included. The u-boot and kmod packages are staged, then archived by adpkg-debfrom a packaging root. The kernel’smake bindeb-pkgrunsdpkg-buildpackageanddh_builddebinside the cross root.Both are Debian userlands resolved from the same mirror list as the image itself. The archiver’s version and its
liblzmaare therefore sha256-pinned inputs the lock describes, rather than a property of the distribution that ran the build.The compressor for what boot2deb archives itself (
xz, level 6) is stated rather than inherited on top of that. The archive’s structure is therefore a property of boot2deb and not of the suite.There is no
fakerooton any path either. Every root maps the caller to uid 0, so a staged tree is already root-owned where it is archived, anddpkg-buildpackageneeds no gain-root command. -
Your compiler. Every compile runs in a provisioned root. The kernel, u-boot and the out-of-tree modules build in a host-arch cross root carrying
crossbuild-essential-<target>, and the media-accel.debs in a target-arch build sandbox. Neither yourgccnor yourmakeis on any build path, and the host cross toolchain is not either.Each stage additionally declares the build-dependencies it layers over that base, and the declaration is folded into the artifact key. A compile probes for what is present, and a package added to the layer is a different build.
The base is provisioned once and cached, and the layer over it is resolved against the archive as it stands when the build runs. The two could therefore describe different archive states, which is what would leave a layer package’s declared dependency unmet. Two things keep that from happening.
The base is checked against the archive every time it is reused. A cached base records the exact package set its bootstrap installed. Before reusing one, the build resolves that set against the archive as it stands now and compares. If the archive has moved past the tree, the tree is discarded and provisioned again, and the build says which packages moved.
the archive has moved past the arm64 rootfs at build/turing-rk1/forky/sandbox/build-arm64-forky-1d64cce0ea48: 1 package(s) resolve differently now, so it is being re-provisioned: libc6:arm64 2.42-17 -> 2.43-3The check is on the solved package set, not on the suite’s
Releasedate. A suite republishes itsReleaseseveral times a day, and almost never touches the handful of packages a base holds. Expiring on the date would therefore re-bootstrap for nothing. Under--snapshot pinthe archive does not move at all and the check never fires.A staged root is checked against its own dependencies. This is the backstop, for a skew the first check cannot see. Before any compile runs in it, a build root is checked against its own
DependsandPre-Depends. A build whose base and layer disagree stops there, naming the package, the constraint it declared, and the version actually installed:error: the ffmpeg build root does not satisfy its own dependencies — the cached base and the freshly resolved layer describe different archive states: libglib2.0-0t64 2.88.3-3 requires `libc6 (>= 2.43)` — installed 2.42-17 Drop the cached build roots so the next build provisions them against the current archive: `boot2deb clean RECIPE --build-roots`. -
Your
TMPDIR. The provisioned rootfs — the whole target userland, carrying xattrs and mapped ownership — is staged in the build’s work dir. On/tmpit would land on a RAM-backedtmpfson most desktops, making “does the build fit” a property of your mount table. -
Your shell environment. Every build command runs with
TZ=UTCandLC_ALL=C.UTF-8, and withKCPPFLAGSandMAKEFLAGScleared.KCFLAGSandKAFLAGScarry the build’s own path map (see Time and paths) and nothing of yours. A flag exported in your shell therefore cannot shape kernel bytes that a lock-keyed cache entry claims to reproduce. -
Your
openssl. The image’s first-boot/etc/shadowentry is hashed in-process. No host binary sits on the credential path.
What is recorded, because it genuinely does reach the image:
-
[toolchain]— the host/target arch and the cross prefix.jobsrecords the parallelism. It is recorded but deliberately not keyed, since a build whose output depends on its job count has a bug. Keying it would also fragment the artifact cache by machine size. The compilers are not here. They are named, sha256-pinned, in the root sections below. -
[toolchain.qemu]— theqemu-userinterpreter, on a host that cannot execute target binaries. It ran the target compiler for the sandbox-built packages and every maintainer script that configured the rootfs. It is absent where nothing is interpreted. An arm64 host building armhf cross-compiles and then runs the result natively, so it records none.This is the one compile input still probed on the host. It is registered with the host kernel’s binfmt handler, and no provisioned root can carry it.
It is taken from the kernel’s binfmt registration, not from a
PATHlookup, and the difference is not academic. The registered path is normally a wrapper under/usr/libexec/qemu-binfmt/rather than theqemu-<arch>-staticon yourPATH. Nothing requires the two to name the same file. A build with no interpreter onPATHat all still runs every target binary through the registered one.So
interpreteris the path the kernel recorded, andresolvedis that path with symlinks followed. The two are separate facts, because repointing the wrapper symlink swaps the interpreter with the registration unchanged.sha256is the content, which is also what the artifact cache keys on.A digest rather than a version line, because it moves when the binary is rebuilt at an unchanged version. It can also be taken from a binary that refuses to run, which the wrapper name does.
versionis read from the resolved path for a reader, and can be absent. -
[filesystem]— the on-disk contract the rootfs was formatted to. It is three records, described under The filesystem contract below. -
[verification]— which checks the finished rootfs filesystem passed. The built-in scan always runs, covering every metadata checksum, each group’s metadata placement, and every in-use inode’s block map, directory records and attributes. Any finding at all fails the build.The independent
e2fsck -fncross-check runs only where the host carriese2fsprogs. Its value is not extra depth (the scan is deeper) but independence, since the scan is one implementation checking its own output. That makes verification depth host-determined, so it is stated rather than left to a log line, and a release build can be gated on it. -
[image].image_bytes— the whole-disk size the build laid out, beside theimage_sizethe recipe authored. The two agree for a stated size, and the redundancy is the point. They differ in kind for a measured one, wherefit+20%states the rule and only this says what it came to. Without it a fitted image’s manifest could not answer how large its own image is. -
[[archives]]— the state each configured repository was in when the rootfs plan resolved, in the order the resolve saw them. That order is the index the.plandocument’s packages name.Per entry it holds the mirror that answered, plus the suite and components. It also holds the sha256 of the release body that was verified, its
DateandValid-Until, and two key fingerprints.signed_byis the certificate that verified the release, meaning its primary key. That is what a keyring entry is named by and whatblobs/keyrings/*.fingerprintspins, so the manifest is directly comparable against that list.signing_keyis the key that actually made the signature, usually a dedicated signing subkey of that certificate. The two are separate because a certificate rotating its subkey moves the second and leaves the first alone.[rootfs]says which package bytes shipped. This says what they were selected from. That is the question a solved manifest cannot answer, since the same suite resolves to different versions a week apart.An empty
signed_byis a fact rather than a gap. It says that repository was trusted unsigned, which is how the build’s own.debpool is configured, andsigning_keyis empty exactly when it is. That pool’s entry is markedlocaland carries no mirror, because its URL is a per-run path under a per-run directory. That is a property of the machine, kept out for the same reason the sandbox record carries no working or artifact path. -
[build_sandbox],[cross_sandbox]and[packaging_root]— the package sets of the three provisioned roots that produced the build’s.debs. Those are the target-arch base that compiled the media-accel packages, and the host-arch base that compiled the kernel, u-boot and out-of-tree modules. The third is the host-arch root whosedpkgarchived the staged ones.[rootfs]records what the image carries. These record what produced the parts of it boot2deb built: further Debian trees, resolved from the same mirrors, that no source pin covers.Each names a manifest published beside the image (
<recipe>.sandbox.pkgs,<recipe>.cross.pkgs,<recipe>.packaging.pkgs), sha256-pinned per package exactly as the rootfs manifest is.[cross_sandbox]in particular is where the compiler is named, by package and sha256 rather than by the version string it prints. That is why[toolchain]above carries nocc.Each is absent when the build produced nothing of its kind. There is no cross root for a board that installs Debian’s kernel and boots its own firmware. There is no packaging root for a build whose artifacts all came back from the artifact cache. They are records rather than contracts. Nothing pins them in the lock, and no later build is verified against them.
They describe the roots that stood up for this run. A build that restored some node’s outputs from the artifact cache did not compile that node here. The three blocks therefore account for the compiled part alone, which is what
[[restored_nodes]]below makes legible. -
[[restored_nodes]]— one row per build step whose outputs came back from the Tier-2 artifact cache instead of being compiled. It names the step, and whether every one of its outputs was restored (restored) or only some (partly restored):[[restored_nodes]] step = "userspace" outcome = "partly restored"Omitted entirely by a build that compiled everything it shipped. It answers a question no other section can: not what went into the image, but which parts of it this run actually built.
Without it the three root blocks above read as a claim about every
.debin the image. That holds only for a build that compiled them all. A mixed build restores some.debs that an earlier run produced, in a root that need not be the one named here. Pin a snapshot (--snapshot pin) when you need the stronger claim, or build with--no-artifact-cacheto make every.debthis run’s own. -
[[outputs]]— one row per artifact the build wrote: the step and role that wrote it, its file name, its size, and its sha256.[[outputs]] step = "kernel" role = "image_deb" file = "linux-image-7.2.8-1-arm64_7.2.8-1_arm64.deb" size = 71953728 sha256 = "…" per_image = falseThe rows come from the build’s own artifact events, so the record lists exactly what the build wrote. The provenance manifest and the bills of materials describe the outputs, and are not rows themselves. A run of some stages alone, such as
--stage image, writes a provenance manifest whose rows are that run’s outputs.per_image = truemarks an output that carries the first-boot password: the image, each compressed copy of it, and its file manifest. No rebuild can match those byte for byte.reproducejudges a rebuild against these rows. -
[sandbox],[sandbox_env]and[[sandbox_mounts]]— the posture, the environment and the complete mount series every sandboxed build command runs under, as the sandbox library resolves them. All three sit outside that library’s compatibility promise, so they are recorded rather than inferred from its version. Most of what they hold has no other accessor at all, down to the/devdevice nodes and symlinks.[sandbox]states how the sandbox is rooted (plainoroverlay), and the identity the command holds (single, where the calling user is root inside and nothing else is mapped). It states the network it can reach (isolated, a fresh namespace with loopback only, declared by boot2deb rather than taken from a library default). It also states where the three standard streams go, any resource limits in force, and whether the library’s hardening layer is compiled in.hardening = "unavailable"is written rather than omitted. An absent key cannot be told from one written before the key existed. A provenance record has to be readable without knowing which builder wrote it.[sandbox.streams]is there because a build’s output depends on it.isattyon the standard streams steers debconf’s frontend, a compiler’s color diagnostics, and every progress display. Two builds under two stream postures can therefore differ with nothing else to show for it.boot2deb declares
stdin = "null", which does more than state that a build is non-interactive. It puts the sandboxed command in a session of its own. Inherited, it would stay in yours. A maintainer script could then open/dev/ttyto read what is typed at your terminal. It could push characters into its input queue for your shell to run afterwards. Out of that session/dev/ttyfails.The output pair reads
inherit, which is a statement about the profile rather than about a compile. A capturing launch attaches its own pipes, so build output never went to your terminal either.
These identities also key the caches, so a .deb built with one toolchain is never
restored for a build using another. Neither is a rootfs whose packages were configured
under a different qemu-user.
The filesystem contract
[filesystem] answers a question no other pin does. Every other pin answers “which
sources went in”. This one answers “what shape were they written into”, and it is the
only such determinant that moves independently of the lock. The format options are
builder constants rather than resolved config values.
It is three records, because three things move for three different reasons.
policy_pin
The intent: the formatter’s own policy document, carried whole. That is every feature word twice over, as exact bits and as names, plus the block and inode sizes. It also carries the seven options outside the feature set entirely:
- The grow reservation and the inode ratio.
- The reserved share and the error behavior.
- The journal size, and the two directory-hash choices.
Every one of those moves bytes, and errors is the sharp case. It reaches neither a
feature word nor the geometry, so no other record here would notice it changing.
Nothing image-specific is in it, meaning no UUID, timestamp, label or block count. Two images built from these constants therefore carry byte-identical policy pins, and a difference always means the contract changed.
reference_geometry_pin
What that policy lays out, planned at one size chosen
once (4 GiB) and never moved. It closes the gap the policy pin cannot see: a change to
the formula behind an option whose name did not change. grow max reads the same
before and after a change to what Max reserves, and the blocks it reserves do not. It
is a function of the options and the reference size alone, so it says nothing about what
went into the image.
[filesystem.geometry]
What the format realized for this image: block and
inode counts, group layout, and max_grow_blocks, the ceiling the reserved descriptor
blocks buy. max_grow_blocks is how large a disk the image can still grow onto at first
boot. This record answers to the image’s size as well as to the policy. A larger
partition therefore moves every number in it with both pins unchanged.
Time and paths
A build reads no clock. Every time an output records comes from a SOURCE_DATE_EPOCH
that the lock fixes. Two builds of one lock therefore stamp the same times, whenever
they run.
Each compiled tree takes the committer date of the commit it compiles. That holds for
the kernel, u-boot, each out-of-tree module, and each userspace and ffmpeg tree. The
kernel needs two more settings, because parts of it read no SOURCE_DATE_EPOCH:
- The kernel’s build system dates the version string (
uname -v), the built-in initramfs and thekheadersarchive fromKBUILD_BUILD_TIMESTAMP. The build sets it to the same instant. - The kernel’s Debian packaging writes the wall clock into the changelog that every
package ships. A
dpkg-buildpackageinit hook re-dates it from the epoch before any package installs it.
The rootfs takes its epoch from the lock, as [rootfs] source_date_epoch. update
writes it: the time of the update that last moved a pin. An update that moves
nothing carries it forward, so the lock stays byte-identical. The rootfs epoch reaches
three places:
- It is the ceiling that every rootfs tar member’s modification time is clamped to.
- It is set in the customize step’s environment. There
mkinitramfsdates the initrd’s members, anduseradddates the account it creates. On a depthcharge board,mkimagedates the signed kernel partition that packs the initrd. - It dates the
Releasefile of the build’s own package pool.
A board that installs Debian’s kernel compiles nothing, and still gets an epoch this way. The value is folded into the rootfs cache key. It moves only when a pin does, so a rolling build loses no cache hit to it.
The export applies the ceiling in its encoder, which records each mtime as
min(mtime, epoch) as it writes. The encoder is the one place that can. Under the
subordinate id-map that gives the tree its real ownership, the provisioned files sit at
ids the host user cannot set times on.
The export also emits entries in sorted order, with directory children and extended attributes by name. A content-identical tree therefore encodes to a byte-identical archive.
Paths are the other thing a compile records. The compiler writes each source file’s
absolute path into the debug information, and the linker’s build ID is a hash that
covers it. So each kernel and out-of-tree module compile maps its tree to ., with
-fdebug-prefix-map in KCFLAGS and KAFLAGS. Two work directories then produce one
build ID.
The rootfs file manifest
Every image ships with a listing of the files its rootfs holds, <stem>.rootfs.uapi16,
beside it. The build reads it back from the formatted filesystem, so it describes the
bytes on the partition. /lost+found is in it, and so is the /etc/shadow the password
splice wrote. Each hard link appears as one shared inode.
One JSON object per file records:
- Its name, type, mode, owner and group, and its modification time in nanoseconds
- Its size, and a regular file’s sha256
- A symlink’s target, and a device node’s major and minor numbers
- An
inodeTokenthat every name of one hard-linked inode shares - Its extended attributes, under
xBoot2debXattrs
The attributes are there because file capabilities (security.capability) ship in the
image, and a comparison has to see them.
The format is the UAPI group’s File Manifest draft, UAPI.16. It is an open pull request
rather than a published specification, so boot2deb names the revision it writes: version
0.1, uapi-group/specifications#213 at 8c732b1. The file is an RFC 7464 JSON text
sequence. A root object comes first, and a closing trailer last. Between them every
other entry follows in pre-order, with siblings sorted by name bytes.
A press with additions formats a new rootfs, so it writes a new listing beside the image it presses. A plain press copies the build’s. A listing always describes the image next to it.
Three commands read it:
reproducecompares two images through their listings, with the content of/etc/shadowset aside.diffreports the files two images differ in, path by path.verify-imagewalks the image’s rootfs again, and checks the result against the published listing.
Two audiences
Because reproducibility is a property of a lock, the story splits by who owns the lock.
The project, publishing a release. The project owns every axis: the recipe and its lock, the snapshot timestamp, the patch-repo tag, and the builder commit. It offers a closed guarantee for a shipped image. Check out boot2deb at the stamped commit, build this lock, get that image. The consumer mostly flashes, and rebuilding is the frozen path. This is the release ritual below.
Someone who clones and authors their own recipe. Their subject is their build point, not the project’s. Their reproducibility is forward-looking — “make my current build re-buildable later” — rather than “rebuild what the project shipped.”
They own their lock: when to update, whether to --save-snapshot, and which builder
they are on. The project does not guarantee their build. It hands them the same
machinery and lets them set the strength.
The release ritual
To publish an image that stays reproducible across time, freeze all three layers and commit the result:
-
Freeze the userland. Capture a
snapshot.debian.orgtimestamp into the lock withboot2deb build <recipe> --save-snapshot, and set its mode topin. The rootfs is then deterministic even after the suite advances. Commit the snapshot-pinned lock, which is part of the release. -
Keep sources durable: tag the patch repo at its pinned commit, and confirm
boot2deb verify-sources <recipe>reports noORPHANEDpins. -
Build from that clean, committed checkout, so the image’s
[built_with]records real commits withdirty = falseandconfig_dirty = false. Runcargo buildfirst. The build refuses a binary that is behind the checkout, but nothing can make a dirty one identify itself. A release stampeddirty = truenames no commit anyone can return to.Build with
--save-outputs, and with both caches off (--no-artifact-cache --refresh-rootfs), so every byte it records is this run’s own. -
Publish the image together with its
.provenance.toml, its.planand its.rootfs.uapi16. The manifest names the builder that produced it and the archives it resolved against, and records every output. The plan is the document that replays them. The file manifest is what a rebuilt image is compared through. The committed lock, recoverable at that commit, carries the snapshot timestamp and every source pin. -
Ship a bill of materials with it, for the consumers who read one rather than a provenance manifest:
--sbom spdx --sbom cyclonedxon the build, orboot2deb sbomlater from the manifest in step 4. It is deterministic on the same terms as everything else here. Its identity is derived from the solved package set, so setSOURCE_DATE_EPOCHand two renderings of one image are byte-identical. -
Commit the outputs record once the image passes on hardware. Step 3 wrote it beside the lock as
recipes/<recipe>.outputs. It names the sha256 of every output that build wrote, under the build host’s architecture.verify-imagethen holds every later build of the lock to those bytes, and so does the gate script that runs it. A validated pin then names the bytes that were flashed, and not only the inputs they came from.
The record leaves out the disk images and their compressed copies. Which of them a build
writes follows its flags (--keep-raw, --compress, --layout), and they carry the
per-image password. It also leaves out the file manifest, which lists that password’s
file. The rootfs tar stands in for them. The account in it is still locked, and the
image is that tar through a deterministic formatter plus the password splice. The
bootloader payloads stand in for a split layout’s boot image.
A record is keyed by the build host’s architecture because a cross build and a native
build compile with different compilers. A record from an x86_64 host says nothing about
an arm64 host’s bytes, and verify-image says so rather than failing.
Only a lock that replays can carry a record. The outputs follow the solved package set, and a rolling mirror moves that daily. A snapshot pin (step 1) is what holds it still.
Reproducing a frozen image
boot2deb reproduce <recipe> --from <dir holding the published .plan>
That is the whole flow. It runs the ordinary pipeline, where the lock’s pinned commits and blobs reproduce the compiled inputs. It replaces one step: the rootfs installs the plan’s exact package set instead of solving for a new one.
Point --from at wherever the image, its provenance manifest and its .plan were
published. Omit it to use this build point’s own output directory, which is where a build
on this machine already wrote them.
The rebuild writes into a directory of its own, artifacts/reproduce/ under the work
dir unless --out-dir names another. The originals are then still there to compare
against. Both caches are off, since a restored output is the earlier build’s and proves
nothing about this one. --with-caches turns them back on.
When the build finishes, each output the original’s provenance manifest recorded gets a verdict:
| verdict | meaning |
|---|---|
identical | the rebuild wrote the same bytes |
differs | the rebuild wrote other bytes, named by digest or by the paths that moved |
missing | the rebuild wrote no such output |
not comparable | the two records cannot decide it, and the reason says why |
An output that carries the per-image password never matches byte for byte. The image, each compressed copy of it, and its file manifest are that kind. They are judged through the two images instead, in two parts:
- The disk. The partition tables, the
[filesystem]records and the disk sizes must agree. - The files. The two file manifests must agree on every
path. The one exception is the content of
/etc/shadow, which the password splice rewrites. A/etc/shadowthat is missing, or whose mode or owner moved, is still a difference.
A differs or a missing exits non-zero. --json prints the verdicts as one
reproduction record after the build’s own event stream.
reproduce reproduces builds, not pressings. An image press extended with
per-site additions is a derived copy (marked as such in its own
image.toml), and what reproduces is the artifact it was
pressed from.
The command reads the [built_with] stamp beside the plan and reports how the running
checkout compares. That is advice rather than a gate, because the stamp is a floor and
not a ceiling. A newer builder usually reproduces the image too, and might carry fixes.
A current clone is the normal first attempt, and git checkout <built_with.commit> is
the step to take only if it diverges.
The builder stamp lives in the build’s .provenance.toml, not on the image. The on-image
/etc/boot2deb/image.toml (see Image identity) records the image and
kernel identity, which a rescue tool reads without the provenance file.
Each layer contributes to that one command. The lock reproduces the sources, and the plan reproduces the package set. The lock’s snapshot pin keeps that set fetchable after the live mirror has moved on. Freeze all three and the replay is mechanical. Freeze fewer and it is reproducible to whatever strength you chose.
What is deliberately outside the claim
The per-image first-boot password is unique per build by design. /etc/shadow is
therefore never byte-reproducible, and neither is an output that carries it: the image,
its compressed copies, and its file manifest. reproduce compares those through the
file manifests, with the content of /etc/shadow set aside.
The package install writes three more files that differ between any two builds. The provisioning library, ferroday-cage, performs that install:
/var/log/dpkg.logand/var/log/alternatives.logrecord when each step ran./var/cache/ldconfig/aux-cacherecords each library’s inode and change time.
A fourth differs between builds on different days. The lastchg field of each system
account holds the day the account was created. Maintainer scripts create those
accounts without SOURCE_DATE_EPOCH, and /etc/shadow- carries the same field.
These are the library’s to fix, and boot2deb has reported them there. A file that two
tools write has two owners, so boot2deb does not rewrite them. Until the library
changes, the rootfs tar and the image differ in these files, and reproduce reports
them.