Reproducible builds
A reproducible build produces the same bytes from the same source and build inputs. Day checks compiled code and distributable packages separately, because signing and archive metadata can change even when the code does not. This guide explains the guarantees, platform exceptions, and tools for comparing a release with a rebuild.
Why it matters
If you can rebuild an artifact and get the same bytes someone else got, you can check that a binary corresponds to the source it claims to come from. Without that, you cannot tell a clean build from one where a compromised machine or a substituted dependency inserted code the source never contained.
Anyone can rebuild and compare, so a tampered artifact fails the comparison even if its signature is
valid. That is the argument the Reproducible Builds project makes
in full, and its documentation is the reference for the
general techniques: SOURCE_DATE_EPOCH, path normalization, archive metadata, and the rest. Day
follows those conventions.
Reproducibility is not a substitute for signing. A signature says who built an artifact; reproducibility says the artifact matches its source.
What Day guarantees
Day’s CI grades a rebuild in two tiers.
Payload: the compiled code, extracted from whatever container ships it. A mismatch here fails the build. It means the same sources produced different machine code, or a build path leaked into the binary.
Container: the shipped file itself. A mismatch is reported but does not fail, because what remains after Day normalizes archive timestamps is either a linker build ID or a signature, and neither is something a build controls.
day pack normalizes what it can before handing a tree to an archiver: every file and directory
gets a fixed modification time, taken from SOURCE_DATE_EPOCH when you set it and otherwise
2020-01-01T00:00:00Z. The default is not the Unix epoch because ZIP’s timestamp field cannot encode
anything before 1980, and an out-of-range value gets clamped back into per-run variance.
Day also sets codegen-units = 1 and lto = "fat" in the release profile. Both matter for
reproducibility; the LTO choice is explained under Linux below.
Signatures differ per build
Two Day artifacts cannot be byte-identical no matter what the build does.
A signed .hap uses SHA256withECDSA. ECDSA picks a random value per signature, so signing the
same bytes twice produces two different signatures. A released .dmg is stapled: xcrun stapler staple writes an Apple-issued notarization ticket into the file, and Apple issues a new one per
submission.
For both, the payload tier is the guarantee; compare the compiled code inside the container.
Per-platform caveats
Reproducibility needs cooperation from the Rust dependency graph and from the platform’s build tools. Day controls the first and configures the second where the tool allows it. This section also lists the places where a tool offers no control.
macos-appkit
The compiled binary is reproducible. The .dmg is not.
Every Mach-O carries an LC_UUID. Apple documents it in TN3178: Checking for and resolving build
UUID problems, which states that the linker derives it from a hash of the built code
specifically to promote reproducible builds, and that Apple’s tools “strive to support reproducible
builds within the constraints imposed by the Mach-O file format”.
The UUID still changes when the same commit is built in a different directory, which moves 16 bytes of the executable plus the signature covering them. TN3178 notes that a signature covers the build UUID. Day’s check normalizes the UUID before comparing, so the payload tier passes and the container tier reports the difference. Treat a differing UUID as expected rather than a bug.
The linker’s -no_uuid option would remove it and make the binary reproducible, but TN3178
explains the cost: an image without a build UUID cannot be matched to its .dSYM, so Apple’s crash
reporter cannot symbolicate it. Day keeps the UUID. There is also no way to set one after the fact;
per TN3178, no Apple command does that.
hdiutil is the harder problem. Two DMGs built from identical, timestamp-normalized input differ by
628 bytes uncompressed under APFS (UUIDs plus a Fletcher-64 checksum on every block) or 151 bytes
under HFS+, spread across GPT GUIDs and their CRC32s, the volume header’s dates and UUID, and
per-file creation dates in the catalog. hdiutil stamps copy time as each file’s creation date, so
normalizing the source tree does not reach them. UDZO compression then turns any of that into a
whole-file difference, because a change in compressed length shifts every chunk after it. Day does
not attempt to rewrite DMG internals.
Day sets ZERO_AR_DATE=1 on every Apple toolchain invocation, which stops libtool and ld64
writing file modification times into static archives and debug maps. TN3178 is Apple’s reference for
build UUIDs specifically; for the wider set of linker and archiver flags, see LLVM’s deterministic
builds post.
ios-uikit
The unsigned .ipa is byte-reproducible, including the container. Three settings make that so.
Xcode’s release defaults leave a debug map in the linked binary: one N_OSO entry per object file,
each holding that file’s absolute path under SYMROOT. Day passes DEPLOYMENT_POSTPROCESSING=YES STRIP_INSTALLED_PRODUCT=YES STRIP_STYLE=debugging, which strips the map. Xcode runs dsymutil
before strip, so the .dSYM still appears and symbolication still works; STRIP_STYLE=debugging
keeps the symbol table so in-process backtraces resolve.
Xcode 14 added ObjC selector stubs, where the compiler emits _objc_msgSend$<selector> references
and the linker synthesizes an __objc_stubs section. That leaves two __got slots for
_objc_msgSend with byte-identical contents, and which consumer gets which slot is not stable. Day
disables the optimization with -fno-objc-msgsend-selector-stubs, which leaves one slot. For a
Swift-heavy app this also makes the binary slightly smaller.
ditto -c -k copies each file’s modification time into the ZIP and has no flag to suppress it, so
Day stamps the staging tree before archiving.
LC_UUID still varies, as on macOS. See TN3178 and the macos-appkit section above.
linux-gtk and linux-qt
The compiled binary is reproducible. Neither the .flatpak bundle nor the .appimage is compared.
ThinLTO makes an internal symbol external so it can be inlined across modules, and renames it with a
.llvm.<hash> suffix to avoid collisions. That suffix was not stable across build directories: two
builds of the same commit on one machine differed in exactly two symbol names, which cascaded into
the GNU build ID and the string table size. Day’s release profile uses lto = "fat", which merges
everything into one module so no cross-module promotion happens and the suffix is never emitted.
Cargo’s trim-paths, the more targeted fix, is still
unstable as of Cargo 1.97.
A .flatpak is an OSTree bundle that ordinary archivers cannot open, and an .appimage is an ELF
with a squashfs image appended, so Day’s check compares the ELF binary staged before packaging
rather than either container. Both Linux formats are built from the same staged tree, so one
recorded digest set covers both downloads.
flatpak-builder honors SOURCE_DATE_EPOCH from version 1.3.1, and Day exports it; OSTree’s own
support for it is an open issue. See the
flatpak-builder command
reference. Day exports
the same epoch to linuxdeploy, but the AppImage runtime it prepends is a downloaded binary that
tracks its own upstream release, so an AppImage is not expected to be byte-identical across a
runtime bump.
android-mdc
The compiled .so files are reproducible. The .apk and .aab are close but not yet identical.
Gradle stamps each ZIP entry with the file’s modification time and walks the tree in filesystem
order. Day’s app template sets isPreserveFileTimestamps = false and isReproducibleFileOrder = true on every archive task, which is the documented fix. See Gradle’s reproducible archives
guidance and
reproducible-builds.org on the JVM.
Day ships a fixed dev keystore rather than generating one per project, which is what Android’s own
debug.keystore does. A per-project key would sign identical bytes with different keys, so a
dev-tier .apk could never be reproducible. The fixed key also lets a build from one machine
upgrade an install from another, which Android refuses when a signature changes.
What remains is inside the APK Signing Block. Zip entries and the central directory come out
byte-identical, and apksigner produces identical output given the same key and input, so this is
not an inherent property of APK signing the way ECDSA is for HarmonyOS.The v2 and v3 signatures cover every byte of the file, so an APK has to be
identical before signing for any of this to hold: the constraint F-Droid documents in its
reproducible builds guide.
harmony-arkui
The compiled .so is reproducible. A signed .hap cannot be.
hvigor assembles and emits the .hap itself, so there is no staging tree for Day to stamp. Day
patches the archive’s timestamps in place instead, and does it before signing. A .hap signature
covers the local file headers, so rewriting them afterwards would invalidate it.
Two haps of byte-identical content differ only in the ECDSA signature block.
DAY_OHOS_ARCH selects the architecture and takes precedence over any connected device, so a hap
packed next to an x86_64 emulator does not silently ship x86_64.
windows-xaml
The staged payload is reproducible.
The Microsoft linker writes the wall clock into the PE header’s TimeDateStamp and into the debug
directory. Day passes /Brepro, which substitutes a hash of the input.
For the NSIS installer, Day’s generated script sets SetDateSave off, which stops NSIS storing and
restoring each file’s modification date. The /SOLID lzma compressor it already used is
deterministic.
The .msix and the -setup.exe are built from one staged payload directory, and Day stamps that
tree once before either container is built.
Rust dependencies
None of the above helps if a crate in your dependency graph is itself nondeterministic. A build.rs
is ordinary Rust code and can embed a timestamp, an absolute path, a hostname, or a random value
into generated source. If your app stops reproducing after adding a dependency, that is the first
place to look.
rustc itself has two relevant properties. codegen-units = 1 is the documented baseline for
deterministic output, and Day sets it. Dependency source paths under ~/.cargo/registry appear in
panic locations inside the binary, so two machines with different home directories produce different
binaries even when everything else matches. Day’s CI compares builds on the same runner image,
where that path is constant. rust-lang/rust#129080 tracks the standing list of reproducibility
hazards.
What ships alongside the artifact
day pack writes one set of sidecars into build/day/dist per artifact, each named after the
artifact’s full file name, extension included, so a release directory sorts them next to what
they describe:
| File | What it records |
|---|---|
<artifact>.sbom-cdx.json / <artifact>.sbom-spdx.json | every dependency that went into the build, plus the repository and commit it came from |
<artifact>.buildinfo.json | the target and profile, the host OS and architecture, the exact version of every tool that participated, and the SHA-256 of each artifact produced |
<artifact> here is the packaged file itself; the dmg’s buildinfo is
day-showcase-macos-appkit.dmg.buildinfo.json.
The SBOM lists the inputs; the .buildinfo records the tools:
{
"schema": "1.0",
"target": "macos-appkit",
"profile": "release",
"host": { "os": "macos", "arch": "aarch64" },
"tools": [
{ "key": "rust", "name": "rustc", "version": "rustc 1.97.0 (2d8144b78 2026-07-07)",
"install": "rustup toolchain install <version> && rustup override set <version>" },
{ "key": "xcode", "name": "Xcode", "version": "Xcode 26.6",
"install": "https://developer.apple.com/download/all/?q=Xcode — install, then sudo xcode-select -s ..." }
],
"artifacts": [
{ "name": "day-showcase-macos-appkit.dmg",
"sha256": "62e8dd94235e3e36202cd0d4a0be2084f24ba4be037d9ae9c93e7baa5d929e9e" }
]
}
Each tool carries an install hint, so a machine that cannot reproduce the build can be told what
to change. The .buildinfo is always a sidecar and never embedded: tool versions differ per machine,
so baking them into the artifact would make the artifact itself unreproducible.
On linux-gtk and linux-qt a second buildinfo is written alongside the JSON one, in Debian’s
deb822 .buildinfo format. It ships
as <artifact>.buildinfo.deb822, under Day’s sidecar naming rather than Debian’s
<source>_<version>_<arch>.buildinfo convention, so the file name says which download it
describes, and a Debian maintainer still has the fields the distribution’s own tooling expects.
Publish the sidecars with the artifact: the SBOM names the commit to rebuild from, and the
.buildinfo names the tools to match.
Checking an artifact you did not build
The recorded SHA-256 makes the cheapest check a hash comparison. If you downloaded a release and its
.buildinfo, you can confirm the file is the one the publisher meant to ship without building
anything:
shasum -a 256 day-showcase-macos-appkit.dmg
python3 -c "import json;print(json.load(open('day-showcase-macos-appkit.dmg.buildinfo.json'))['artifacts'][0]['sha256'])"
That establishes the artifact is intact. It does not establish that it was built from the source it
claims: the publisher computed both the file and the hash. To check that, rebuild it and compare
the result against the artifact you were given, which is what day rebuild does.
Verifying with day rebuild
Point day rebuild at any artifact that has its SBOM and .buildinfo beside it: one you built, or
one you downloaded from someone else. It reads that information back and runs the check:
day rebuild my-app-macos-appkit.dmg
It finds the SBOM shipped with the artifact, reads the repository and commit that produced it, compares your installed tool versions against the ones recorded at build time, clones that commit into a temporary directory, packs it again, and compares the two artifacts.
Environment 6 tool(s) match the artifact
Cloning https://github.com/you/my-app @ 342a2be1606d
Rebuilding macos-appkit (release) in /tmp/day-rebuild-my-app-macos-appkit/src/apps/my-app
Payload identical
Container differs
99517b21e367c5b0… vs 62e8dd94235e3e36…
The two verdicts are the ones described above. Payload covers the compiled code pulled out of
whatever container it ships in, and a mismatch there exits non-zero. Container covers the shipped
file byte for byte, and it differs on formats that embed a signature or a build UUID, which is why
it only reports. Pass --strict to also fail when the payload could not be compared at all, which
is the right setting for CI, because a container the machine cannot open means the code went
unverified.
The environment check runs before the clone, so a machine that cannot reproduce the artifact reports that immediately rather than after a long build. It never installs anything; it reports what is missing and what to run:
Error the build environment does not match the artifact:
rust: this machine has rustc 1.97.0 (2d8144b78 2026-07-07)
the artifact was built with rustc 1.90.0 (1159e78c4 2025-09-14)
rustup toolchain install 1.90.0
Install the versions above, or re-run with --force-tool=<name> for each tool you want to
ignore (--force-tool=all ignores every mismatch).
--force-tool takes a tool name, repeats, and accepts all. It exists for experiments. A forced
rebuild that differs tells you nothing, because the difference may be the tool you forced.
A rebuild needs the commit to exist in the repository, so an artifact packed from a working tree with uncommitted changes is refused.
--from-dir <dir> rebuilds from a project directory you name instead of cloning the commit the
SBOM records, for a source tree that is not in git; Day’s CI uses it to verify an artifact
packed from a freshly scaffolded project. The directory is copied to a scratch path first, minus
.git and any build products, so a build path baked into the binary still surfaces as a payload
mismatch. The .buildinfo beside the artifact still gates tool versions.
Verifying a build yourself
day rebuild is the short path. Doing it by hand helps when you want to compare
something it does not handle, or to see the difference rather than a verdict. Build the app twice
in two different directories and compare.
git clone <your-app> app-a && git clone <your-app> app-b
( cd app-a && day pack -p macos-appkit )
( cd app-b && day pack -p macos-appkit )
Two separate directories matter. Building twice in the same place will not catch a build path that leaked into the binary, which is one of the most common causes of a reproducibility failure.
Compare the results with diffoscope, which recurses into archives and decodes binary formats instead of reporting that two files differ:
diffoscope app-a/build/day/dist/my-app-macos-appkit.dmg app-b/build/day/dist/my-app-macos-appkit.dmg
Install it with brew install diffoscope, apt install diffoscope, or pip install diffoscope.
On Windows most of its external comparators are unavailable and it falls back to a binary diff.
Set SOURCE_DATE_EPOCH to the same value for both builds if you want the archive timestamps to
match a specific date rather than Day’s default:
SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) day pack -p android-mdc
On Apple platforms you can check the build UUID directly, which is the difference diffoscope will
report most often. TN3178 documents dwarfdump:
dwarfdump --uuid "app-a/build/day/pack/macos-appkit/My App.app/Contents/MacOS/myapp"
dwarfdump --uuid "app-b/build/day/pack/macos-appkit/My App.app/Contents/MacOS/myapp"
Each architecture in a universal binary has its own UUID. otool -l <binary> | grep -A1 LC_UUID
shows the same load command if you prefer.
Read the output against the sections above before filing a bug. A differing LC_UUID on Apple
platforms, a differing signature block, or a differing .dmg is expected. A differing .so, .exe,
or Mach-O executable, once the UUID is accounted for, is not.