Skip to content

fix(packaging): resolve license-file path so sdists pass twine check - #51

Merged
antosubash merged 1 commit into
mainfrom
feature/distracted-blackwell-b795eb
Apr 22, 2026
Merged

fix(packaging): resolve license-file path so sdists pass twine check#51
antosubash merged 1 commit into
mainfrom
feature/distracted-blackwell-b795eb

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Summary

The 0.0.1 release workflow failed at twine check for every sdist with:

InvalidDistribution: Invalid distribution metadata: '../../LICENSE' is
invalid for 'license-file', parent directory indicators are not allowed

PEP 639 disallows parent-directory indicators in license-files. The wheels happened to pass (the license file is renamed inside *.dist-info/licenses/), but the sdist writes the literal path into PKG-INFO as License-File: ../../LICENSE, which newer twine rejects.

Changes

  • Replace license-files = ["../../LICENSE"] with license-files = ["LICENSE"] across all 14 distributable packages (4 framework/*, 10 modules/*).
  • Add a LICENSE symlink in each package directory pointing at the repo-root LICENSE, so hatchling resolves the file at build time without duplicating its contents. Git stores the symlinks (mode 120000), and the sdist/wheel materialize the file as a regular copy.

Verification

uv build --all-packages --out-dir /tmp/sm-dist-py
uvx twine check /tmp/sm-dist-py/*

All 14 wheels and 14 sdists now report PASSED.

Test plan

  • uv build --all-packages succeeds for every package
  • twine check passes on every wheel and sdist
  • CI release workflow re-run reaches the publish step on testpypi

Reviewer notes

  • Symlinks work on macOS/Linux (including ubuntu-latest in CI). Windows dev-machines need core.symlinks=true in git config to materialize them — same caveat already applies to other aspects of this repo.
  • No change to pyproject.toml build config beyond the single license-files line; hatchling's default license-file handling picks up the symlinked file.

PEP 639 forbids parent-directory indicators in license-files. Twine
rejected every sdist with `InvalidDistribution: '../../LICENSE' is
invalid for 'license-file'`, blocking the release workflow.

Replace the `../../LICENSE` path with a plain `LICENSE` entry in each
package's pyproject.toml, and add a `LICENSE` symlink in each package
directory pointing at the repo-root LICENSE so the file resolves at
build time without duplicating its contents.

Verified: all 14 wheels and 14 sdists built via `uv build --all-packages`
now pass `twine check`.
@antosubash
antosubash merged commit efcc3aa into main Apr 22, 2026
10 checks passed
antosubash added a commit that referenced this pull request Apr 22, 2026
* fix(packaging): replace LICENSE symlinks with real files so sdists extract

PR #51 added a `LICENSE -> ../../LICENSE` symlink to every distributable
package so `license-files = ["LICENSE"]` would resolve at build time without
duplicating content. `uv build` + `twine check` passed locally, but hatchling
preserves symlinks verbatim when building sdists, and safe tar extractors
(including uv's cache unpacker) refuse to unpack entries whose symlink
target escapes the archive root — breaking `uv sync --all-packages` in CI:

  × Failed to build `simple-module-auth @ modules/auth`
  ├─▶ failed to unpack `...simple_module_auth-0.0.1/LICENSE`
  ╰─▶ symlink destination for ../../LICENSE is outside of the target directory

Replace each symlink with a regular file copy of the repo-root LICENSE.
~15 KB of duplication for portable sdists — the right trade.

Verified: all 14 sdists + 14 wheels build, pass `twine check`, and extract
cleanly to a scratch directory (which reproduces the original CI failure).

* ci(release): harden workflow — tag after publish, preflight checks, testpypi smoke

The release workflow had a handful of failure modes that would surface the
first time something actually went wrong:

* Tag was pushed before `uv build` ran, so a failed build (e.g. the sdist
  symlink bug in #51) left an orphan `v<version>` tag to clean up.
* `git commit || exit 0` silently skipped the tag-push step when there were
  no file changes (e.g. re-dispatching the same version), letting the
  workflow proceed to publish without a matching git tag.
* `mv dist-py/${name}-* to-publish/ 2>/dev/null || true` swallowed empty
  globs, so a typo in the matrix would silently publish nothing.
* `testpypi` target never smoke-tested the publish path — defeating the
  point of a dry-run target.
* `uv tool install` in smoke ran immediately after publish-pypi, with no
  retry for PyPI index propagation delay.
* No gate on the commit being released: any dispatch against main would
  proceed even if CI was red.

Restructured the job graph to:

  preflight  →  build  →  publish-{pypi,npm}  →  finalize  →  smoke[-testpypi]

* `preflight` validates the version string, fetches tags and fails fast if
  `v<version>` already exists, and polls `gh run list` for the latest pr.yml
  conclusion on the target SHA (pr.yml now also runs on `push: main` so
  main-HEAD commits are graded in their own right).
* `build` bumps versions in the working tree only and uploads artifacts —
  no git side effects.
* `publish-pypi` uses `shopt -s nullglob` + array-length guard, hard-failing
  if the matrix key matches zero files.
* `publish-npm` now publishes from the uploaded `.tgz` artifact instead of
  re-checking-out the tag, and reads auth from an `NPM_TOKEN` env var fed by
  `secrets.NPM_TOKEN` via a manually written `.npmrc`.
* `finalize` runs only on `inputs.target == 'pypi'` and only after every
  publish succeeded; it re-applies the bump, commits, tags, and pushes.
  A failed build or publish therefore never leaves origin in a bumped state.
* `smoke` and new `smoke-testpypi` both wrap install/sync/npm-install in
  12×15 s retry loops for registry propagation. `smoke-testpypi` points
  `UV_INDEX_URL` at TestPyPI with a PyPI extra-index for transitive deps
  and runs Python-only (npm isn't published on the testpypi target).

Also aligned action versions with pr.yml (`setup-uv@v8.0.0`, `setup-node@v6`,
`checkout@v6`) and pinned Python to 3.12 in every release job.
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