Reproducible builds

A build is reproducible when the same source, built twice, produces the same bytes. Day builds the compiled code of every app reproducibly: rebuild a commit in a different directory, on a different day, and the machine code that comes out is identical. The containers those binaries ship in (.dmg, .apk, .ipa, .hap, .msix, .flatpak, .appimage) are reproducible on some platforms and not on others, and this page says which, and why.

Day’s CI checks this on every push to main. Each platform-toolkit job has a follow-up validate job that first installs and launches the shipped artifact on a clean runner, then rebuilds the same commit from a second checkout at a different path and compares the two.

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, a published binary is something you take on trust: you cannot tell a clean build from one where a compromised build machine, a substituted dependency, or a modified toolchain inserted something the source never contained.

Reproducibility turns that trust into something you can test. Anyone can rebuild and compare, so a tampered artifact has to survive independent verification rather than a single signature. 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 rather than inventing its own.

Reproducibility is not a substitute for signing. A signature says who built an artifact; reproducibility says the artifact matches its source. You want both.

What Day guarantees

Day’s CI grades a rebuild in two tiers, and they carry different weight.

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.

Signing sets a ceiling

Two Day artifacts cannot be byte-identical no matter what the build does, and it is worth knowing why before you go looking for a bug.

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. Verify the code, not the wrapper.

Per-platform caveats

Reproducibility needs cooperation from the Rust dependency graph and from the platform’s own build tools. Day controls the first and configures the second where the tool allows it. Where a tool offers no control, this section says so.

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”.

In practice 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, and TN3178 says plainly why not to: 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, LLVM’s deterministic builds post covers the ground Apple’s documentation does not.

ios-uikit

The unsigned .ipa is byte-reproducible, including the container. Getting there took three fixes, each hidden behind the last.

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. The showcase app carried 267 of them. 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 freshly minted key meant a dev-tier .apk could never be reproducible, because two builds signed identical bytes with different keys. It also means a build from one machine can now upgrade an install from another, which Android otherwise 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. It is unresolved rather than impossible. Note that 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.

SHA256withECDSA is where this stops. Two haps of byte-identical content differ only in the signing block, and that difference is the signature itself.

One caveat is worth knowing even if you never check reproducibility: DAY_OHOS_ARCH takes precedence over any connected device. Before that, day pack built for whatever emulator or handset happened to be attached, so a hap packed next to a running x86_64 emulator shipped x86_64 while the same commit packed elsewhere shipped arm64. A distribution build should not change shape because something was plugged in.

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. Those 24 bytes were the entire difference between two Windows builds.

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.

Two things are worth knowing about rustc itself. 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 is the standing list of reproducibility hazards and worth watching.

What ships alongside the artifact

day pack writes two files into build/day/dist next to the artifact itself:

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

The SBOM answers what went in. The .buildinfo answers what built it:

{
  "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.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 file, <source>_<version>_<arch>.buildinfo, is written in Debian’s deb822 .buildinfo format alongside the JSON one, so a Debian maintainer has everything the distribution’s own tooling expects.

Keep both files with the artifact when you publish it. Without the SBOM there is no commit to rebuild from, and without the .buildinfo there is no way to tell whether your machine matches the one that built it.

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.dmg"
python3 -c "import json;print(json.load(open('macos-appkit.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 does the whole check:

day rebuild "My App.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/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 what you want in CI: 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 says so 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. Nothing describes what went into it.

--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 own 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 is worth knowing 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.dmg" "app-b/build/day/dist/My App.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.

Day’s CI runs day rebuild --strict against every artifact it ships, on a clean runner, for all six packing platforms. The same command is available to you, and it applies the same two-tier comparison described above.