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:

FileWhat it records
<artifact>.sbom-cdx.json / <artifact>.sbom-spdx.jsonevery dependency that went into the build, plus the repository and commit it came from
<artifact>.buildinfo.jsonthe 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.