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"