Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions .github/workflows/releases.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,26 @@ jobs:
needs: [build_artifacts]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: releases
path: dist

- name: test
run: |
ls
ls dist
- name: Install uv
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0

- name: Install just
uses: extractions/setup-crate@7577c1bdf2d95e6d65d532788f35ed79d4b1dda2 # v2.0.1
with:
repo: casey/just
version: 1.58.0

- name: Check sdist contents
run: just check-sdist

upload_pypi:
name: Upload to PyPI
Expand Down
4 changes: 4 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ lock *args:
build *args:
hatch build "$@"

# Check the built sdist ships what it needs (default: the one in dist/)
check-sdist sdist="dist":
uv run --no-project python ci/check_sdist_contents.py "$@"

# Create a changelog fragment (interactive without arguments)
changelog *args:
{{ docs_run }} towncrier create "$@"
Expand Down
1 change: 1 addition & 0 deletions changes/4263.misc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The release workflow now checks that the built sdist contains every `testpaths` entry, `examples/`, the generated `_version.py` and `py.typed`, and nothing from `packages/`. Run it locally with `just check-sdist` after `just build`.
92 changes: 92 additions & 0 deletions ci/check_sdist_contents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""
Check a built sdist ships the paths it needs and none it must not.

Usage:
python check_sdist_contents.py SDIST

SDIST is a path to a `.tar.gz`, or a directory containing exactly one.

`[tool.hatch.build.targets.sdist]` in pyproject.toml is an allowlist, so the
mistake to catch is a needed path that was never listed. Every `testpaths`
entry must ship, read from the pyproject.toml inside the tarball so the check
depends on the artifact alone. The paths below are needed but not covered by
`testpaths`.
"""

import sys
import tarfile
import tomllib
from pathlib import Path

# `_version.py` is gitignored and written into the sdist by the hatch-vcs hook.
REQUIRED_FILES = ["src/zarr/_version.py", "src/zarr/py.typed"]

# `tests/test_examples.py` globs `examples/` and skips silently if it is empty.
REQUIRED_DIRS = ["examples"]

# Each subpackage under `packages/` is released as its own distribution.
FORBIDDEN_PREFIXES = ["packages/"]


def read_sdist(sdist: Path) -> tuple[set[str], dict[str, object]]:
"""Return the sdist's paths, relative to its top-level directory, and its pyproject.toml."""
with tarfile.open(sdist) as tar:
names = tar.getnames()
roots = {name.split("/", 1)[0] for name in names}
if len(roots) != 1:
raise SystemExit(
f"Expected one top-level directory in {sdist.name}, got {sorted(roots)}"
)
root = roots.pop()
pyproject = tar.extractfile(f"{root}/pyproject.toml")
if pyproject is None:
raise SystemExit(f"pyproject.toml is missing from {sdist.name}")
config = tomllib.loads(pyproject.read().decode())
members = {name.split("/", 1)[1] for name in names if "/" in name}
return members, config


def check(sdist: Path) -> int:
print(f"Checking {sdist.name}")
members, config = read_sdist(sdist)
testpaths = config["tool"]["pytest"]["ini_options"]["testpaths"]

missing_files = [p for p in REQUIRED_FILES if p not in members]
missing_dirs = [
p for p in [*testpaths, *REQUIRED_DIRS] if not any(m.startswith(f"{p}/") for m in members)
]
forbidden = sorted(m for m in members if m.startswith(tuple(FORBIDDEN_PREFIXES)))

if not (missing_files or missing_dirs or forbidden):
print(f"OK ({len(members)} paths)")
return 0

for heading, paths in [
("Missing files", missing_files),
("Missing directories", missing_dirs),
("Paths that must not ship", forbidden),
]:
if paths:
print(f"\n{heading}:")
print("\n".join(paths[:20]))
if len(paths) > 20:
print(f"... and {len(paths) - 20} more")
print("\nFix the allowlist in `[tool.hatch.build.targets.sdist]` in pyproject.toml.")
return 1


if __name__ == "__main__":
if len(sys.argv) != 2:
print(__doc__)
sys.exit(2)
target = Path(sys.argv[1])
if not target.exists():
print(f"{target} does not exist; build the sdist first with `just build`")
sys.exit(2)
if target.is_dir():
tarballs = sorted(target.glob("*.tar.gz"))
if len(tarballs) != 1:
print(f"Expected exactly one .tar.gz in {target}, found {len(tarballs)}")
sys.exit(2)
target = tarballs[0]
sys.exit(check(target))
Loading