From 183d51c8a860a5ca8d00c091df89c398ffb0425e Mon Sep 17 00:00:00 2001 From: chopratejas Date: Thu, 7 May 2026 16:29:05 -0700 Subject: [PATCH] fix(ci): include NOTICE in sdist + assert License-File metadata matches tarball Every release since v0.20.16 has uploaded 12 wheels but no sdist. The underlying failure is a 400 from PyPI: 400 License-File NOTICE does not exist in distribution file headroom_ai-X.Y.Z.tar.gz at headroom_ai-X.Y.Z/NOTICE Two-part regression: 1. The hatch -> maturin migration in 2a91cbb (single-wheel maturin build backend, May 4) replaced `[tool.hatch.build.targets.sdist].include`, which listed both `LICENSE` and `NOTICE`, with maturin's own include directive that only carried `LICENSE` over. Maturin's PEP 639 license auto-discovery still emits `License-File: NOTICE` into the sdist's PKG-INFO (because NOTICE exists at the project root and matches the default glob), so the sdist tarball declares a license file it doesn't physically contain. PyPI's PEP 639 validator rejects with 400. Wheels were unaffected because maturin auto-injects both files into `*.dist-info/licenses/`. 2. CI showed "publish-pypi" green for ~22 releases despite this break because twine was bailing earlier with `400 File already exists` on the wheels (the version detector kept computing the same v0.21.5). PR #412 added `skip-existing: true` (May 6) to make wheel re-uploads idempotent. With wheels now silently skipping, twine proceeded to upload the sdist for the first time in three weeks - and the dormant License-File error surfaced as a hard 400. Fix: - Add `NOTICE` alongside `LICENSE` in `[tool.maturin].include` for the `sdist` format. Both files now ship in the tarball, matching what PEP 639 already declares in PKG-INFO. - Replace the existing "verify sdist contains LICENSE" check with a generic "every License-File entry in PKG-INFO resolves to a real tarball member" check. This catches the same bug class for any future addition (COPYING, AUTHORS, etc.) without another bespoke literal. Verified locally: $ maturin sdist --out dist Including license file `LICENSE` Including license file `NOTICE` Including files matching "LICENSE" Including files matching "NOTICE" Built source distribution to dist/headroom_ai-0.9.1.tar.gz $ tar -tzf dist/headroom_ai-0.9.1.tar.gz | grep -E '(LICENSE|NOTICE)$' headroom_ai-0.9.1/LICENSE headroom_ai-0.9.1/NOTICE $ twine check dist/headroom_ai-0.9.1.tar.gz Checking dist/headroom_ai-0.9.1.tar.gz: PASSED --- .github/workflows/release.yml | 52 ++++++++++++++++++++++++++++------- pyproject.toml | 13 +++++++-- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 680fde7e6..3b43b828d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -295,10 +295,23 @@ jobs: command: sdist args: --out dist - - name: Verify sdist includes top-level LICENSE + - name: Verify sdist license-file metadata matches tarball contents if: matrix.target == 'x86_64-unknown-linux-gnu' run: | python - <<'PY' + # PyPI rejects sdists whose `License-File:` metadata entries + # (PEP 639) reference files that aren't physically present + # in the tarball: `400 License-File X does not exist in + # distribution file ... at /X`. This check catches + # that divergence before upload by parsing PKG-INFO's + # License-File lines and asserting each one resolves to a + # real tarball member. Issue trail: sdist publish broke at + # v0.20.16 when the hatch -> maturin migration in 2a91cbb + # dropped NOTICE from the sdist `include` list while PEP 639 + # still emitted it as a License-File. Was masked for ~22 + # releases by an earlier twine "File already exists" failure + # on duplicate wheels; surfaced once PR #412 added + # skip-existing. from pathlib import Path import tarfile @@ -307,18 +320,37 @@ jobs: raise SystemExit(f"expected exactly one sdist in dist/, found {len(sdists)}") with tarfile.open(sdists[0], "r:gz") as archive: - names = archive.getnames() + names = set(archive.getnames()) + roots = {name.split("/", 1)[0] for name in names if "/" in name} + if len(roots) != 1: + raise SystemExit(f"expected one sdist root directory, found {sorted(roots)}") + root = roots.pop() - roots = {name.split("/", 1)[0] for name in names if "/" in name} - if len(roots) != 1: - raise SystemExit(f"expected one sdist root directory, found {sorted(roots)}") + pkg_info_path = f"{root}/PKG-INFO" + member = archive.getmember(pkg_info_path) + fh = archive.extractfile(member) + if fh is None: + raise SystemExit(f"could not read {pkg_info_path} from {sdists[0].name}") + pkg_info = fh.read().decode("utf-8") - root = roots.pop() - license_path = f"{root}/LICENSE" - if license_path not in names: - raise SystemExit(f"{sdists[0].name} is missing {license_path}") + declared = [] + for line in pkg_info.splitlines(): + if not line.strip(): + break # headers ended (blank line separator); rest is README body + if line.startswith("License-File:"): + declared.append(line.split(":", 1)[1].strip()) - print(f"sdist LICENSE OK: {license_path}") + if not declared: + raise SystemExit(f"{sdists[0].name} PKG-INFO declares no License-File entries - expected at least LICENSE") + + missing = [name for name in declared if f"{root}/{name}" not in names] + if missing: + raise SystemExit( + f"{sdists[0].name} declares License-File entries that are missing from the tarball: {missing}. " + f"Add them to `[tool.maturin].include` with `format = \"sdist\"`." + ) + + print(f"sdist License-File metadata OK ({len(declared)} files, all present): {declared}") PY # Audit each Linux wheel's dynamic symbol references against its diff --git a/pyproject.toml b/pyproject.toml index 90828428a..e4f0d2e8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -263,9 +263,16 @@ default = true # Where the Python package lives. With `python-source = "."` and the # package directory `headroom/` at repo root, maturin includes every file # under `headroom/` in the wheel — that picks up the dashboard HTML -# templates and bundled YAML configs. `LICENSE` is listed explicitly because -# maturin sdists do not get the package-directory treatment wheels do. -include = [{ path = "LICENSE", format = "sdist" }] +# templates and bundled YAML configs. `LICENSE` and `NOTICE` are listed +# explicitly because maturin sdists do not get the package-directory +# treatment wheels do, and PEP 639 auto-discovery emits both files into +# `License-File:` metadata — PyPI rejects sdists whose declared license +# files are missing from the tarball with `400 License-File X does not +# exist in distribution file`. +include = [ + { path = "LICENSE", format = "sdist" }, + { path = "NOTICE", format = "sdist" }, +] python-source = "." module-name = "headroom._core" # The cdylib source lives under `crates/headroom-py`. Maturin invokes