Skip to content

ci: check the sdist can actually be consumed - #291

Open
d-v-b wants to merge 3 commits into
claude/zarr-sdist-allowlist-414698from
claude/zarr-sdist-ci-checks
Open

ci: check the sdist can actually be consumed#291
d-v-b wants to merge 3 commits into
claude/zarr-sdist-allowlist-414698from
claude/zarr-sdist-ci-checks

Conversation

@d-v-b

@d-v-b d-v-b commented Aug 13, 2026

Copy link
Copy Markdown
Owner

🤖 AI text below 🤖

Split out of the sdist allowlist PR (#289 / zarr-developers#4261) so the packaging change and the CI change can be reviewed separately.

Stacked on claude/zarr-sdist-allowlist-414698, not maincheck_sdist_contents.py asserts packages/ is absent from the sdist, which is only true once the allowlist lands. Based on main this PR would be red by construction. Merge the allowlist first; the diff here is CI-only either way.

Why

The allowlist is the safe default — a new subpackage under packages/ stays out of the zarr sdist unless someone opts it in — but it inverts the failure mode. A blocklist ships too much silently; an allowlist can ship too little, by adding a file consumers need without a matching entry.

Test distribution artifacts previously ran ls dist and asserted nothing. Two checks replace it, split by cost.

test_dist_pypi — seconds, runs on PRs

ci/check_sdist_contents.py asserts:

  • every testpaths entry from pyproject.toml ships
  • packages/ does not
  • the paths packagers read but no test opens: LICENSE.txt, PKG-INFO, py.typed, and the hatch-vcs _version.py

The testpaths assertion is the one that earns its keep — it's derived from config maintained for other reasons, not restated, so it can't rot into a second allowlist to forget. Pointed at the pre-allowlist sdist it reports both defects that were actually live:

testpaths entries missing from the sdist
  docs/user-guide
  `pytest` on an unpacked sdist will fail to collect these.
Paths that must not ship in the zarr sdist
  packages/zarr-http-server/... (370 total)

test_dist_conda — ~10 min, not on PRs

Builds the sdist with the real conda-forge recipe, fetched from the feedstock at run time and repointed at our freshly built tarball by ci/conda_recipe_for_sdist.py. conda-forge is the consumer most exposed to a missing file: it builds from the sdist rather than the wheel, and runs our suite from a directory holding only what its recipe copies out of the tarball.

Fetching beats reproducing. A vendored copy of that recipe would go stale the moment the feedstock changed, and silently — the same class of bug this job exists to catch. Only the fields naming which tarball to build are rewritten; requirements, tests, pip_check and license_file execute as conda-forge wrote them.

Worth knowing: the recipe's requirements are already regenerated from our pyproject.toml by grayskull on each version bump (bot: inspection: update-grayskull in the feedstock's conda-forge.yml) — the 3.3.0 bump moved typing_extensions >=4.13 → >=4.14 with no human involved. The tests: block is hand-maintained there, which is why running it beats reproducing it.

Why it's skipped on pull_request

It runs the whole suite from an unpacked sdist. Measured on CI: 591.95s of a 613s job — conda's build and env solve are ~22 seconds of that. The cost is inherent to testing an sdist, so there's no cheaper variant of the same coverage.

On push to main it still catches a bad merge within ~10 minutes, attributed to one commit, and it gates upload_pypi, so no release can publish an sdist conda cannot consume. workflow_dispatch runs it against a branch on demand. Keeping it off PRs also stops a recipe change in the feedstock from reddening unrelated PRs.

Release gating is unchanged in effect: on a release event neither job is skipped, so upload_pypi still requires both.

One trap worth flagging

The generated recipe uses a url: source, not path:. path: sources honour .gitignore, and src/zarr/_version.py is gitignored (.gitignore:69). It's written into the sdist by the hatch-vcs hook precisely because an unpacked sdist has no git history to derive a version from — so a path: source silently drops it and the build fails. Noted in a comment in the script.

Verification

Ran on Linux CI before the trigger was narrowed, with the recipe fetched live:

✔ python imports test passed!
✔ pip check passed!
6884 passed, 475 skipped, 4 xfailed in 591.95s
✔ all tests passed!

No changes/ fragment: CI-only, nothing user-visible for the release notes.

d-v-b added 3 commits August 13, 2026 16:31
…ubpackages (zarr-developers#4261)

* fix(build): use an sdist allowlist so the zarr sdist stops shipping subpackages

The root sdist config was a blocklist naming /.github, /bench and /docs, so
every release shipped whatever else happened to sit in the repository root.
That included the whole packages/ tree — the zarr-indexing, zarr-metadata and
zarr-http-server sources, which are released as their own distributions — plus
ci/, design/, towncrier fragments and other repo furniture. 2.9M of the 1.4M
sdist was other people's packages.

Replace it with an explicit allowlist, matching what packages/zarr-indexing and
packages/zarr-metadata already do.

Including /docs also fixes a second problem: tests/test_docs.py walks docs/ and
testpaths collects docs/user-guide, so with docs/ excluded the shipped test
suite died at collection with 'Not a file or directory'. tests/test_docs.py now
runs green from an unpacked sdist (61 passed, 2 skipped), and full collection
finds 7581 tests with no errors.

Assisted-by: ClaudeCode:claude-opus-5

* docs(build): trim the sdist allowlist comment

Drop the narration of the blocklist this replaced -- that history lives in
git -- and keep only the durable rationale and the reason each entry is on
the list.

Assisted-by: ClaudeCode:claude-opus-5
The sdist contents are defined by an allowlist in pyproject.toml. That is the
safe default -- a new subpackage under packages/ stays out of the zarr sdist
unless someone opts it in -- but it inverts the failure mode. A blocklist ships
too much silently; an allowlist can ship too little, by adding a file consumers
need without a matching allowlist entry.

The 'Test distribution artifacts' job previously ran 'ls dist' and asserted
nothing. Two checks now replace it, split by what they cost, both inspecting
the built tarball rather than the working tree since a git checkout has files
the sdist does not.

test_dist_pypi runs ci/check_sdist_contents.py: every 'testpaths' entry from
pyproject.toml ships, packages/ does not, and the handful of paths packagers
read but no test opens (LICENSE.txt, PKG-INFO, py.typed, the hatch-vcs version
file) are present. The testpaths check is derived from config maintained for
other reasons rather than restated, so it cannot drift into being a second
allowlist to forget. Seconds and hermetic, so it runs on pull requests too.
Against the pre-allowlist sdist it reports both defects that were live:
docs/user-guide missing, and 370 packages/ paths.

test_dist_conda builds the sdist with the real conda-forge recipe, fetched from
the feedstock at run time and repointed at the tarball we just built by
ci/conda_recipe_for_sdist.py. conda-forge is the consumer most exposed to a
missing file: it builds from the sdist rather than the wheel and runs our suite
from a directory holding only what its recipe copies out of the tarball.
Fetching beats reproducing -- a copy of that recipe would go stale silently,
which is the same class of bug this job exists to catch. Note the recipe's
requirements are already regenerated from our pyproject.toml by grayskull on
each version bump (bot: inspection: update-grayskull in the feedstock's
conda-forge.yml); the tests block is hand-maintained there.

That job is skipped on pull_request. It runs the whole suite from an unpacked
sdist, measured at 591.95s of a 613s run, so conda's build and solve are about
22 seconds of it and there is no cheaper variant of the same coverage. On push
to main it still catches a bad merge within minutes and attributes it to one
commit, and it gates upload_pypi so no release can publish an sdist conda
cannot consume. workflow_dispatch runs it against a branch on demand. Keeping
it off pull requests also stops a recipe change in the feedstock from reddening
unrelated PRs.

A url source rather than a path one, deliberately: path sources honour
.gitignore, and src/zarr/_version.py is gitignored. It is written into the
sdist by the hatch-vcs hook precisely because an unpacked sdist has no git
history, so a path source would drop it and break the build.

Verified on CI before the trigger was narrowed: imports test passed, pip check
passed, 6884 passed / 475 skipped / 4 xfailed.

Assisted-by: ClaudeCode:claude-opus-5
check_sdist_contents.py derived `testpaths` from the working tree's
pyproject.toml while comparing it against the tarball's contents, so the two
halves of the comparison could come from different trees -- and the job comment
claims the opposite, that these checks inspect the artifact rather than the
checkout. Read pyproject.toml out of the tarball instead, which makes the check
a property of the artifact alone: the same answer against a release sdist
downloaded from PyPI as against a fresh `hatch build`.

Also pin pyyaml, matching how hatch is installed in test.yml, and correct the
workflow_dispatch note: dispatch only offers branches of the repository it runs
in, so for a fork PR that means dispatching in the fork.

Assisted-by: ClaudeCode:claude-opus-5
@d-v-b
d-v-b force-pushed the claude/zarr-sdist-ci-checks branch from 22cf142 to 749ab76 Compare August 13, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant