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( + "