mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
7aaa4ac48f
commit
183d51c8a8
2 changed files with 52 additions and 13 deletions
52
.github/workflows/release.yml
vendored
52
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue