Summary
A fully-pinned, reproducible = true build (all *_sha256 pins set, pylock/build_pylock hash-verified) produces different .AppImage bytes when built on two different machines, even though every resolved input is proven byte-identical. Reproducibility does hold machine-to-machine when the machine is held constant (two independent GitHub Actions runs of the same workflow produce an identical hash), so the gap is specifically "same machine class" vs. "different machine class" reproducibility.
Found while packaging ssh-mitm as an AppImage (the flagship real-world user of this feature) - see ssh-mitm/ssh-mitm, commits around 2026-09-02 (search history for "repro investigation" in that repo for the full trail of debug commits/CI runs this issue is based on).
What's proven
- CI-to-CI: two independent
ubuntu-latest GitHub Actions runs of the exact same commit/workflow produce a bit-identical .AppImage (same sha256, twice).
- Local-to-local: two independent builds on the same local machine (openSUSE Tumbleweed, btrfs) also produce a bit-identical
.AppImage.
- Local vs. CI: the two above do not match each other.
Environment (local vs. CI)
|
Local |
CI (ubuntu-latest) |
| OS |
openSUSE Tumbleweed |
Ubuntu 24.04 |
| Filesystem |
btrfs |
ext4 (typical) |
| CPU |
Intel i7-7700 (Kaby Lake) |
AMD (has sse4a) |
| CPU flags relevant to zstd |
sse2 sse4_1 sse4_2 bmi1 bmi2 avx2 |
sse2 sse4_1 sse4_2 sse4a bmi1 bmi2 avx2 (identical superset) |
| Cores |
8 |
4 |
appimagetool binary |
same file, sha256-pinned |
same file, sha256-pinned |
What's been ruled out (each with a real test, not a guess)
- Compile-time SIMD dispatch (facebook/zstd#4099/#4230) - the gate that bug touches is
#if defined(ZSTD_ARCH_X86_SSE2), resolved once at compile time. Since both machines run the exact same sha256-pinned appimagetool binary, this can't differ between runs. Confirmed irrelevant by reading the zstd source diff directly.
- Runtime CPU feature dispatch (BMI2/AVX2 runtime CPUID probing, referenced in zstd's own BMI2-dispatch work) - checked
/proc/cpuinfo on both machines directly in CI; bmi2/avx2 are present on both.
mksquashfs -processors N (thread count) - local has 8 cores, CI has 4. Forced -processors 1 on both (via --mksquashfs-opt); no change to either machine's hash.
- File/packing order - forced a fully deterministic,
LC_ALL=C-sorted -sort priority file (covering all ~11k AppDir entries, tested to actually take effect - it does change the hash) on both machines. Still produced two different (though each individually stable) hashes.
- Locale (
LC_COLLATE) - local shell runs de_DE.UTF-8, CI runs (presumably) C/C.UTF-8. Rebuilding locally with LC_ALL=C produced no change from the non-LC_ALL=C baseline - rules out locale-dependent sorting inside mksquashfs itself (it did explain an artifact in our own comparison tooling's sort calls, see next point, but not the actual mksquashfs output).
- AppDir content identity - extracted both a local- and a CI-built
.AppImage (--appimage-extract) and diffed the full trees (diff -rq): zero differences. Independently re-verified via a full-tree sha256sum manifest, correctly LC_ALL=C-sorted on both sides to avoid the locale-sort artifact above: zero differences, 9109/9109 files matching.
- Symlink targets -
find -type l was invisible to the content-hash manifest (find -type f only). Collected readlink output for all 1049 symlinks on both sides: zero differences.
- File permission bits - found and fixed a real discrepancy here (many files were
-rwxrwxr-x/-rw-rw-r-- locally vs. -rwxr-xr-x/-rw-r--r-- on CI, source not fully identified - umask reads 0022 on both, so it isn't the interactive shell's umask; possibly the python-build-standalone tarball's own stored permission bits combined with a different extraction-time umask context). Rewrote every local file's mode to exactly match CI's manifest (verified 0 diff after). Rebuilding after this fix still produced a different hash from CI. So this is a real bug (arguably appimage.ctl should normalize permission bits the same way it already normalizes mtimes via _normalize_mtimes, since two logically-equal AppDirs can currently have different mode bits depending on the build host's extraction-time umask/context) but it is not sufficient to explain the squashfs-level divergence.
- zstd version (
AppImage/appimagetool's own CI pins alpine:3.19, which ships zstd 1.5.5-r8 - predates the 1.5.7 release containing the #4230 fix) - built mksquashfs 4.6.1 from source, statically linked against a freshly-built zstd 1.5.7, and swapped it into the extracted appimagetool AppDir (usr/bin/mksquashfs, found via execvp("mksquashfs", ...) + AppRun's own PATH-prepending, confirmed in AppImage/appimagetool's source). Built this exact same binary on CI and downloaded it as an artifact; running the CI-built binary against my local AppDir reproduced my own local hash (not CI's) - proving the mksquashfs binary itself isn't the variable, only which AppDir/machine processes it. Building independently on both machines (different host GCC each time - 16.2.0 locally vs. Ubuntu's) still gave two different hashes, but that test is confounded by the compiler difference and isn't conclusive on its own.
squashfskit (squashfskit/squashfskit) - the exact fork NixOS/nixpkgs#40144 documents successfully using to fix this same class of bug (their words: "mksquashfs is nondeterministic ... even when passing -no-fragments and -processors 1"), and that reproducible-builds.org's own guidance points to. Built it (commit ae0d656, 2019) from source on both machines, statically linked against the same fresh zstd 1.5.7, swapped into appimagetool. Still produced two different hashes.
What's left unexplained
Content, permissions, symlinks, sort order, and thread count are all proven identical or forced identical between the two builds. The appimagetool binary is proven byte-identical (when using the stock pinned one) or built from the exact same pinned source both times (when using a custom mksquashfs). Every documented, community-recommended fix for this general class of squashfs non-determinism (squashfskit, fresh zstd, -processors 1, -sort) has been tried and none of them close the gap for this specific machine pair.
Remaining untested candidates, roughly in order of plausibility:
- Kernel-version-dependent behavior of some syscall mksquashfs relies on (e.g.
getdents64 ordering semantics, readahead/mmap behavior) that isn't controlled by any of the above.
- Filesystem-type-dependent behavior (btrfs locally vs. ext4 on CI) surfacing through something other than plain directory-entry order (already ruled out via
-sort).
- glibc-version-dependent codegen/behavior baked into the statically-linked
mksquashfs/zstd binary at build time (both the squashfskit and fresh-zstd tests built independently per-machine with each machine's own host GCC/glibc, which is a real confound neither test controlled for).
Practical takeaway for now
Reproducibility does hold for the actual release pipeline (GitHub Actions building on ubuntu-latest, twice, bit-identical) - that's what matters for "can someone else verify this exact release was built from this exact source," which is the realistic threat model most projects care about. What doesn't currently hold is "any two arbitrary machines produce the same bytes," which reproducible = true's docstring currently implies more broadly ("reproducible across machines and over time").
Suggested follow-ups
- Fix item 8 above regardless of the rest (normalize permission bits in
_normalize_mtimes or a sibling function) - it's a real, if insufficient, correctness gap.
- Soften the
reproducible/docs/reproducible-builds.md language to be explicit that the byte-identical guarantee is currently only demonstrated same-machine-class (e.g. "the same CI runner image"), not universally cross-machine, until this is actually root-caused.
- If anyone wants to pick this up: the most promising unexplored angle is a byte-level diff of the two squashfs images (not their extracted content, which is identical) to see exactly which structure differs - superblock fields, fragment table, block layout - which would narrow down whether this is a metadata field or genuinely different compressed bytes for identical block content.
Summary
A fully-pinned,
reproducible = truebuild (all*_sha256pins set,pylock/build_pylockhash-verified) produces different.AppImagebytes when built on two different machines, even though every resolved input is proven byte-identical. Reproducibility does hold machine-to-machine when the machine is held constant (two independent GitHub Actions runs of the same workflow produce an identical hash), so the gap is specifically "same machine class" vs. "different machine class" reproducibility.Found while packaging
ssh-mitmas an AppImage (the flagship real-world user of this feature) - see ssh-mitm/ssh-mitm, commits around 2026-09-02 (search history for "repro investigation" in that repo for the full trail of debug commits/CI runs this issue is based on).What's proven
ubuntu-latestGitHub Actions runs of the exact same commit/workflow produce a bit-identical.AppImage(same sha256, twice)..AppImage.Environment (local vs. CI)
ubuntu-latest)sse4a)sse2 sse4_1 sse4_2 bmi1 bmi2 avx2sse2 sse4_1 sse4_2 sse4a bmi1 bmi2 avx2(identical superset)appimagetoolbinaryWhat's been ruled out (each with a real test, not a guess)
#if defined(ZSTD_ARCH_X86_SSE2), resolved once at compile time. Since both machines run the exact same sha256-pinnedappimagetoolbinary, this can't differ between runs. Confirmed irrelevant by reading the zstd source diff directly./proc/cpuinfoon both machines directly in CI;bmi2/avx2are present on both.mksquashfs -processors N(thread count) - local has 8 cores, CI has 4. Forced-processors 1on both (via--mksquashfs-opt); no change to either machine's hash.LC_ALL=C-sorted-sortpriority file (covering all ~11k AppDir entries, tested to actually take effect - it does change the hash) on both machines. Still produced two different (though each individually stable) hashes.LC_COLLATE) - local shell runsde_DE.UTF-8, CI runs (presumably)C/C.UTF-8. Rebuilding locally withLC_ALL=Cproduced no change from the non-LC_ALL=Cbaseline - rules out locale-dependent sorting insidemksquashfsitself (it did explain an artifact in our own comparison tooling'ssortcalls, see next point, but not the actual mksquashfs output)..AppImage(--appimage-extract) and diffed the full trees (diff -rq): zero differences. Independently re-verified via a full-treesha256summanifest, correctlyLC_ALL=C-sorted on both sides to avoid the locale-sort artifact above: zero differences, 9109/9109 files matching.find -type lwas invisible to the content-hash manifest (find -type fonly). Collectedreadlinkoutput for all 1049 symlinks on both sides: zero differences.-rwxrwxr-x/-rw-rw-r--locally vs.-rwxr-xr-x/-rw-r--r--on CI, source not fully identified -umaskreads0022on both, so it isn't the interactive shell's umask; possibly the python-build-standalone tarball's own stored permission bits combined with a different extraction-time umask context). Rewrote every local file's mode to exactly match CI's manifest (verified 0 diff after). Rebuilding after this fix still produced a different hash from CI. So this is a real bug (arguablyappimage.ctlshould normalize permission bits the same way it already normalizes mtimes via_normalize_mtimes, since two logically-equal AppDirs can currently have different mode bits depending on the build host's extraction-time umask/context) but it is not sufficient to explain the squashfs-level divergence.AppImage/appimagetool's own CI pinsalpine:3.19, which ships zstd1.5.5-r8- predates the1.5.7release containing the #4230 fix) - builtmksquashfs 4.6.1from source, statically linked against a freshly-builtzstd 1.5.7, and swapped it into the extractedappimagetoolAppDir (usr/bin/mksquashfs, found viaexecvp("mksquashfs", ...)+AppRun's own PATH-prepending, confirmed inAppImage/appimagetool's source). Built this exact same binary on CI and downloaded it as an artifact; running the CI-built binary against my local AppDir reproduced my own local hash (not CI's) - proving the mksquashfs binary itself isn't the variable, only which AppDir/machine processes it. Building independently on both machines (different host GCC each time - 16.2.0 locally vs. Ubuntu's) still gave two different hashes, but that test is confounded by the compiler difference and isn't conclusive on its own.squashfskit(squashfskit/squashfskit) - the exact fork NixOS/nixpkgs#40144 documents successfully using to fix this same class of bug (their words: "mksquashfs is nondeterministic ... even when passing-no-fragmentsand-processors 1"), and that reproducible-builds.org's own guidance points to. Built it (commitae0d656, 2019) from source on both machines, statically linked against the same fresh zstd 1.5.7, swapped intoappimagetool. Still produced two different hashes.What's left unexplained
Content, permissions, symlinks, sort order, and thread count are all proven identical or forced identical between the two builds. The
appimagetoolbinary is proven byte-identical (when using the stock pinned one) or built from the exact same pinned source both times (when using a custom mksquashfs). Every documented, community-recommended fix for this general class of squashfs non-determinism (squashfskit, fresh zstd,-processors 1,-sort) has been tried and none of them close the gap for this specific machine pair.Remaining untested candidates, roughly in order of plausibility:
getdents64ordering semantics,readahead/mmapbehavior) that isn't controlled by any of the above.-sort).mksquashfs/zstd binary at build time (both the squashfskit and fresh-zstd tests built independently per-machine with each machine's own host GCC/glibc, which is a real confound neither test controlled for).Practical takeaway for now
Reproducibility does hold for the actual release pipeline (GitHub Actions building on
ubuntu-latest, twice, bit-identical) - that's what matters for "can someone else verify this exact release was built from this exact source," which is the realistic threat model most projects care about. What doesn't currently hold is "any two arbitrary machines produce the same bytes," whichreproducible = true's docstring currently implies more broadly ("reproducible across machines and over time").Suggested follow-ups
_normalize_mtimesor a sibling function) - it's a real, if insufficient, correctness gap.reproducible/docs/reproducible-builds.mdlanguage to be explicit that the byte-identical guarantee is currently only demonstrated same-machine-class (e.g. "the same CI runner image"), not universally cross-machine, until this is actually root-caused.