From 373e738e2db6d754e27fb0b6b427b4d635da95c7 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 3 Aug 2026 00:03:21 -0700 Subject: [PATCH] Fix PyPI upload failures from colliding macOS wheel artifacts macos-latest (macOS 26) and macos-15 are both arm64, so with CIBW_ARCHS_MACOS=auto64 both jobs produced identically named *-macosx_11_0_arm64.whl files with different contents. The publish job extracted both artifacts into dist/ via merge-multiple, letting two concurrent writers target the same path. The resulting wheels had local file headers disagreeing with the central directory, which PyPI rejects with "Invalid distribution file. ZIP archive not accepted: Mis-matched data size". - Drop the duplicate macos-15 matrix entry. - Download each artifact into its own subdirectory and fail loudly if two build jobs emit the same distribution name. - Replace the zipfile.testzip() check, which only reads the central directory, with the structural checks PyPI actually applies: local header vs central directory name/CRC/sizes, no data descriptors, and no trailing data after the end-of-central-directory record. --- .github/workflows/wheels.yml | 177 +++++++++++++++++++++++++++++------ 1 file changed, 147 insertions(+), 30 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index c18fbfb..16169f5 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -19,8 +19,10 @@ jobs: include: - os: ubuntu-latest - os: windows-latest + # macos-latest (macOS 26) and macos-15 are both arm64, so with + # CIBW_ARCHS_MACOS=auto64 they build identically named wheels. + # Keep a single arm64 runner; add macos-15-intel for x86_64 wheels. - os: macos-latest - - os: macos-15 steps: - name: Checkout repository @@ -99,50 +101,165 @@ jobs: with: python-version: "3.13" - - name: Download wheel artifacts + # Each artifact lands in its own subdirectory. Never merge into one + # directory here: two artifacts holding the same file name would be + # written concurrently to the same path and corrupt each other. + - name: Download build artifacts uses: actions/download-artifact@v8 with: - path: dist - pattern: wheels-* - merge-multiple: true + path: artifacts - - name: Download sdist artifact - uses: actions/download-artifact@v8 - with: - path: dist - name: sdist + - name: Collect distributions + run: | + python - <<'PY' + import collections + import pathlib + import shutil + import sys + + src = pathlib.Path("artifacts") + dst = pathlib.Path("dist") + dst.mkdir(exist_ok=True) + + found = sorted( + p for p in src.rglob("*") + if p.is_file() and (p.suffix == ".whl" or p.name.endswith(".tar.gz")) + ) + if not found: + sys.exit("No distributions found under artifacts/") + + by_name = collections.defaultdict(list) + for p in found: + by_name[p.name].append(p) + + clashes = {n: v for n, v in by_name.items() if len(v) > 1} + if clashes: + print("Same file name produced by more than one build job:") + for name, paths in sorted(clashes.items()): + print(f" {name}") + for p in paths: + print(f" - {p} ({p.stat().st_size} bytes)") + sys.exit( + "Build matrix produces duplicate distribution names; " + "fix the matrix so every job emits distinct wheels." + ) + + for p in found: + shutil.copy2(p, dst / p.name) + print(f"{p.name} {p.stat().st_size} bytes") + PY - name: Validate distribution archives run: | python -m pip install --upgrade pip twine python - <<'PY' import glob - import zipfile + import struct import sys + import zipfile - wheels = sorted(glob.glob("dist/**/*.whl", recursive=True)) + # Mirrors the checks PyPI applies on upload (see docs.pypi.org/archives). + # zipfile.testzip() alone reads through the central directory only, so it + # misses local-header/central-directory disagreement and trailing data. + LFH = 0x04034B50 + ZIP64 = 0xFFFFFFFF + + + def check(path): + try: + return inspect(path) + except Exception as exc: # unreadable archive is itself the finding + return [repr(exc)] + + + def inspect(path): + problems = [] + with open(path, "rb") as fh: + raw = fh.read() + + with zipfile.ZipFile(path) as zf: + bad = zf.testzip() + if bad is not None: + problems.append(f"CRC mismatch in entry {bad!r}") + + infos = zf.infolist() + names = [i.filename for i in infos] + dupes = sorted({n for n in names if names.count(n) > 1}) + if dupes: + problems.append(f"duplicate entries: {dupes}") + + for info in infos: + off = info.header_offset + head = raw[off:off + 30] + if len(head) < 30: + problems.append(f"{info.filename}: truncated local header") + continue + sig, _, flags, _, _, _, crc, csize, usize, nlen, elen = struct.unpack( + "