From 4c3dd97c55269981b6b633ca463d9bb32eb8756b Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Wed, 22 Jul 2026 21:28:26 +0500 Subject: [PATCH] fix(bundler): order bundle members by canonical POSIX arcname (reproducible builds) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _collect_files returned sorted(collected), i.e. pathlib.Path order, which is platform-dependent: on Windows PurePath compares case-folded with backslash separators, whereas the zip member NAMES are the canonical POSIX arcnames (build_bundle: file_path.relative_to(bundle_dir).as_posix()). So the same bundle built on Windows vs Linux/macOS produced archives whose members were laid out in different order — not byte-for-byte identical across build hosts, contradicting the packager's reproducible-build guarantee (fixed timestamps + canonical modes). Order by the same canonical POSIX-arcname key used to name members, so member order is host-independent. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/bundler/services/packager.py | 8 +++++++- tests/unit/test_bundler_packager.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/bundler/services/packager.py b/src/specify_cli/bundler/services/packager.py index 481a0a2bb8..6a0778e3ab 100644 --- a/src/specify_cli/bundler/services/packager.py +++ b/src/specify_cli/bundler/services/packager.py @@ -142,4 +142,10 @@ def _collect_files( # Skip symlinked files to avoid escaping the bundle directory. continue collected.append(path) - return sorted(collected) + # Order by the canonical POSIX arcname (the same key build_bundle uses to + # NAME each member), not by pathlib.Path comparison. Path ordering is + # platform-dependent (Windows folds case and uses backslash separators), + # which would lay out zip members differently across build hosts and break + # the byte-for-byte reproducible-build guarantee even though the member + # names are identical. + return sorted(collected, key=lambda p: p.relative_to(bundle_dir).as_posix()) diff --git a/tests/unit/test_bundler_packager.py b/tests/unit/test_bundler_packager.py index 5835047f7d..53a3c37462 100644 --- a/tests/unit/test_bundler_packager.py +++ b/tests/unit/test_bundler_packager.py @@ -73,6 +73,23 @@ def test_build_is_deterministic(tmp_path: Path): assert first.artifact_path.read_bytes() == second.artifact_path.read_bytes() +def test_member_order_is_platform_independent(tmp_path: Path): + # Members must be laid out in canonical POSIX-arcname order (the same key + # build_bundle uses to NAME them), not pathlib.Path order — which folds case + # on Windows and would otherwise reorder members across build hosts, breaking + # the byte-for-byte reproducibility guarantee. Mixed-case names make the + # difference observable: Path order on Windows groups differently than the + # canonical string sort. + bundle = _make_bundle( + tmp_path / "b", + extra_files={"Zeta.txt": "z", "apple.txt": "a", "Foo.txt": "f", "bar.txt": "b"}, + ) + result = build_bundle(bundle, output_dir=tmp_path / "out") + with zipfile.ZipFile(result.artifact_path) as archive: + names = archive.namelist() + assert names == sorted(names) + + def test_output_dir_inside_bundle_excludes_prior_artifacts(tmp_path: Path): bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"}) out_dir = bundle / "dist"