Skip to content

fix(packaging): unbreak sdists + harden release workflow - #52

Merged
antosubash merged 2 commits into
mainfrom
feature/hopeful-swartz-b70091
Apr 22, 2026
Merged

fix(packaging): unbreak sdists + harden release workflow#52
antosubash merged 2 commits into
mainfrom
feature/hopeful-swartz-b70091

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Summary

Two related release-path fixes on top of #51:

  1. Unblock the packaging pipeline. PR fix(packaging): resolve license-file path so sdists pass twine check #51 introduced LICENSE -> ../../LICENSE symlinks in every distributable so license-files = [\"LICENSE\"] would resolve without duplicating content. Hatchling preserves those symlinks verbatim in sdists, and safe tar extractors (uv's cache unpacker included) refuse entries whose symlink target escapes the archive root — which broke `uv sync --all-packages` in CI with errors like `symlink destination for ../../LICENSE is outside of the target directory`. Replaced all 14 symlinks with real file copies.
  2. Harden the release workflow so the next real release can't leave origin in a weird half-done state, and so the `testpypi` target actually exercises the publish path end-to-end.

Packaging fix

  • Each of the 14 package dirs now ships LICENSE as a regular file (git `type-change 120000 → 100644`).
  • ~15 KB of duplicated license text is the right trade for sdists that extract cleanly on every consumer.
  • Verified locally: uv build --all-packages → 14 wheels + 14 sdists, all 28 pass twine check, and every sdist extracts to a scratch directory without the symlink-escape error (the exact CI failure mode).

Release workflow changes

New job graph:

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

preflight

  • Validates the version string.
  • Fails fast if `v` already exists on origin (no more silent re-runs with a dirty tag).
  • Polls `gh run list` for the latest `pr.yml` conclusion on `github.sha` and refuses to release against a non-green main. This relies on `pr.yml` also running on `push: main` — added in this PR.

build

  • Bumps versions in the working tree only, builds `dist-py` + `dist-npm`, uploads them as artifacts.
  • No git side effects. A failed build leaves nothing for a cleanup.

publish-pypi

  • Per-package matrix (unchanged set of 14 packages).
  • shopt -s nullglob + array-length guard — hard-fails if the matrix key matches zero files instead of silently publishing nothing.

publish-npm

  • Publishes from the `.tgz` artifact built in `build` (no more `checkout@tag`, which is what previously forced tagging before publishing).
  • Auth: manually written `~/.npmrc` referencing `${NPM_TOKEN}`, env fed from `secrets.NPM_TOKEN`. Requires an npm Automation token in the `npm` environment.

finalize (new, only on `target == 'pypi'`)

  • Only runs after every publish succeeded. Re-applies the version bump, commits, tags, and pushes. No orphan tags, ever.

smoke / smoke-testpypi

  • Both wrap install/sync/npm-install in 12×15 s retry loops to survive PyPI/npm index propagation lag.
  • `smoke-testpypi` (new) 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). This closes the gap where `testpypi` never validated anything beyond the upload succeeding.

Other

  • Action versions aligned with `pr.yml` (`setup-uv@v8.0.0`, `setup-node@v6`, `checkout@v6`); Python pinned to 3.12 in every release job.
  • `pr.yml` gains a `push: branches: [main]` trigger so main-HEAD commits are graded (required for the preflight CI-green check).
  • Artifact retention set to 14 days.

Pre-flight for the next real release

  • Add secret `NPM_TOKEN` at the repo level (or scope it to the `npm` Environment with an approval gate). Use an npm Automation token — Automation tokens skip 2FA prompts and work non-interactively.
  • After this PR merges, `pr.yml` will run on `main` once; the release preflight can then verify that SHA is green.

Test plan

  • uv build --all-packages locally: 14/14 wheels + 14/14 sdists.
  • twine check dist/*: 28/28 PASSED.
  • Extract every sdist into a scratch directory: all 14 succeed (reproduces and fixes the CI failure from `uv sync`).
  • `actionlint` clean on both workflows (only pre-existing SC2034 in untouched `e2e-smoke` wait loop).
  • Dispatch `release.yml` with `target: testpypi` and version `0.0.2a1` to exercise the new `smoke-testpypi` path end-to-end.

…tract

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).
…estpypi 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.
@antosubash
antosubash merged commit b8cdcca into main Apr 22, 2026
10 checks passed
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