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
This commit is contained in:
chopratejas 2026-05-07 16:29:05 -07:00
parent 7aaa4ac48f
commit 183d51c8a8
2 changed files with 52 additions and 13 deletions

View file

@ -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 <root>/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

View file

@ -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