Skip to content

ADR-0023: Publishing to PyPI from the release workflow

Status: Accepted, amended 2026-10-16 and four times on 2026-10-17 (see the amendments), and by ADR-0026 Date: 2026-10-16 Deciders: Alex Nodeland

Adds the job ADR-0006 left for later, and decides #206. It answers #7's "when": at the first release, behind a switch the maintainer turns.

Context

release.yml builds each released package's wheel and sdist, attests them and attaches them to the GitHub release. None is on PyPI. The wheels require lattice-core-ai, which isn't there either, so a wheel from a release cannot be installed, and the libraries require one another in an order.

Two things were open. Provenance: actions/attest records the workflow's own commit as the build's source, so a release created on a later push than the one that merged its pull request has wheels built from the tag's commit and attested as built at the push (#206). Rollout: the first release has to be rehearsed, and published once, in an order, with the maintainer's approval.

The user decided: one release, with 0.1.0.dev0 floors in the first published wheels (raised in the next, #213); and, for #206, that the build refuses a release whose tagged commit is not the run's. The first of these was changed the same day: the first release is staged (Amendment).

Decision

  • A plan job decides everything first, by scripts/release_plan.py, which is tested (tests/test_release_plan.py): the packages to build, from which commits, whether the run writes, whether it publishes, and in what order. Every later job reads its outputs.
  • A release is built from the commit it was tagged at, or not at all (#206). If release-please's --sha for any released package, stackr included (its images are attested the same way), is not the run's commit, the plan fails before anything is built. Its message gives the command to run the workflow again on the tag, where the two are the same, and says what that command does: for a wheel, it attaches and attests the wheels and publishes to PyPI only if PYPI_PUBLISH is true, after the pypi environment's approval; for stackr, it builds the images again, pushes them and adds them to the release's notes, and PyPI is not in it. Since the 2026-10-17 amendment on environments, the approval is that of the package's own pypi-<distribution> environment. The cost is accepted: a release created on a later push exists with no wheels until someone runs it. A released package without a full commit, or a tag whose version is not its pyproject's, is refused too.
  • A release run never publishes to PyPI. Publishing is by dispatch on a tag, one package a dispatch (decided by the user). Merging the release pull request creates the tags and the GitHub releases, and the run it triggers builds, attests and attaches the wheels, pushes stackr's images, and lists in its summary the seven commands that publish, in dependency order: gh workflow run release.yml --ref <tag> -f tag=<tag> -f mode=publish (one for each package the release lists; the staged first release lists one, then six: see the Amendment). Since the 2026-10-17 amendment, each package has its own release pull request, and the run that merging one triggers lists that package's dispatch. Each dispatch publishes that package, in a job gated by the pypi environment, so each is one approval, one at a time. Since the 2026-10-17 amendment on environments, the job is gated by the package's own environment, pypi-<distribution>: still one approval a package. The order is lattice-core-ai and evalr; then artifactr-ai, reflexr and grantr; then relayr-ai; then portalr (STAGES, held by a test to the packages' own requirements).
  • A dispatch publishes only what PyPI can already satisfy. Before it publishes, the job verifies the attestation of its files again (the repository, the workflow, this run's commit and ref, and the file's own digest), and fails unless PyPI serves, at the version this repository has at the tag, every other package of this repository that the package requires, read from its pyproject whether or not it is in this release. Then it publishes with pypa/gh-action-pypi-publish (PyPI's own attestations, PEP 740) and waits until PyPI serves its files with the hashes built. PyPI is read through its simple index, which installers use, not its JSON API, which can lead the index or be cached as missing, with a few tries for a PyPI that errs.
  • A rehearsal writes nothing, anywhere. mode is rehearse unless it says publish. A rehearsal, on a branch (every wheel at its head) or on a tag (that release, again), builds and checks, and attaches nothing to a release, attests nothing, pushes no image, rewrites no release notes and publishes nothing. It keeps the files it built as artifacts of the run, and nothing else. This is structural: the build job's permissions are contents: read, and the jobs that can write (attach, publish-images, release-notes, pypi-publish) run only where the plan writes or publishes; tests hold both, and which jobs may ask for an identity token. mode=publish is refused anywhere but on a tag.
  • A file attached to a release is never replaced. A run on a tag builds the wheel again; scripts/attach-assets leaves a file already attached with the same bytes, and fails on one with other bytes, so a rebuild that is not byte for byte the same cannot replace what the release, and PyPI if it has the file, holds. Our wheels are believed reproducible (uv_build, Vite and the notices are deterministic) and that is not verified; if a rebuild differs, delete the asset on purpose. The comparison uses the asset's digest in gh release view --json assets, which GitHub fills for assets uploaded since mid-2025; where it is null or absent, the script downloads the asset and sums its bytes itself. Verified read-only with gh 2.102.0: assets[] there carries digest (sha256:<hex>) next to name, size and url, on a public release of another repository (cli/cli), since this repository has no release yet; the first release here is the first check on its own assets.
  • Nothing is published until the maintainer says so, twice. The repository variable PYPI_PUBLISH must be true, and the pypi environment's required reviewers approve each dispatch. Since the 2026-10-17 amendment on environments, the reviewers of each package's own environment approve its dispatch. Without the variable a dispatch still attaches and attests, and publishes nothing.
  • dev0 floors in the first wheels. Every library requires lattice-core-ai>=0.1.0.dev0, which a published 0.1.0 satisfies. Raising the floors to the published version is the next release's (#213). Superseded by the Amendment: no published wheel carries a dev0 floor.
  • A dispatch on a tag runs the workflow file as it was at that tag. A release tagged before this change cannot be run again by dispatch; the first release is the first with the dispatch.
  • Concurrency. A push has its own group and a dispatch has one per ref, so a dispatch cannot knock out a release run, and seven dispatches on seven tags do not queue behind each other. A run waiting for its environment's approval holds its group, so a second dispatch on the same tag waits for the first.

What the maintainer does, once

On PyPI, for each of lattice-core-ai, evalr, artifactr-ai, reflexr, grantr, relayr-ai and portalr (the names are free today), add a pending trusted publisher: owner alexnodeland, repository lattice, workflow release.yml, and environment pypi-<distribution>: pypi-lattice-core-ai, pypi-evalr, pypi-artifactr-ai, pypi-reflexr, pypi-grantr, pypi-relayr-ai and pypi-portalr (why). On GitHub:

  • create those seven environments, each with required reviewers (the maintainer), who approve its package's dispatches. Create all seven before PYPI_PUBLISH is set: a job that names an environment the repository doesn't have creates it, with no reviewers and no restriction;
  • restrict each one's deployment branches and tags to selected tags, *-v*, and not branches; and add a tag ruleset so that only maintainers can create *-v* tags, with the release-please GitHub App (alexnodeland-lattice) as a bypass actor: otherwise release-please cannot create the release tags. This matters because PyPI's trusted publisher cannot pin a ref. A user with write access can dispatch the workflow on a branch, or push a tag by hand, and GitHub then runs that ref's copy of release.yml, which can leave out the plan and the PYPI_PUBLISH variable; the package's environment's reviewers and its tag restriction are then all that stand between that run and PyPI;
  • set the repository variable PYPI_PUBLISH to true, when ready.

None of this is in the repository, and none was done when this was written. A pending publisher was added on 2026-10-17: see the amendment on environments.

To publish a release: after the release pull request merges and its run has finished, open that run's summary and run the commands it lists, one at a time, in the order given, approving each package's pypi-<distribution> environment when GitHub asks. Wait for each to finish before the next: the later ones check that PyPI serves what they require, and fail if it does not. To recover a partial release (one dispatch failed, or was rejected): run the same command again, which skips what PyPI already has (skip-existing) and fails on a file PyPI has with other bytes; then go on with the commands after it. A package whose wheels were never attached (the #206 refusal) gets its wheels from its own dispatch first.

To rehearse: run gh workflow run release.yml --ref main (rehearse is the default) and read the run: it builds every wheel at main's head and prints the order and what PyPI has. It writes nothing.

Options considered

Option For Against
Refuse a release whose commit isn't the run's (chosen) Provenance always names the right commit The release exists with no wheels until the workflow is run on the tag
Verify against the tag's commit afterwards No refusal Every consumer must; the failure comes after the release
Build in a workflow dispatched on the tag, always Always right A new trigger, and it must not publish from an arbitrary dispatch
Publish with an API token No environment on PyPI A long-lived secret; against the repository's no-external-secrets stance
Publish every wheel from the release run No dispatches to run A release run is also the push to main, and a failure part way leaves PyPI half published with no one deciding
A job per stage Parallel within a stage Four copies of the job, and a failure of one stage doesn't stop the next one's if without a gate on every earlier job
A reusable workflow for the stages No copies Trusted publishing names the calling workflow, and the reusable one's name is not what PyPI checks; not tried

Consequences

  • Easier: the first release can be rehearsed in full without publishing, and a wrong-commit release can't ship a wheel with the wrong provenance.
  • Harder: publishing is seven dispatches and seven approvals, one at a time, by hand, after every release; a release created on a later push needs a manual run on its tag first; and the maintainer's one-time setup is required before the first publish. The release run's summary lists the commands, so none is composed by hand.
  • Not tested: the publishing jobs have not run, and cannot be run without publishing. The plan and the PyPI checks are tested offline against fakes, the workflow's permissions and the publishing matrix are tested against the file, and it passes actionlint and zizmor. scripts/verify-attestation and scripts/attach-assets have been shell-checked and not run, since only a run that attests produces an attestation to verify; the rehearsal does not reach it, and the first release is its first run.
  • Revisit: #213, which the Amendment puts between the two halves of the first release; PEP 740 attestations are PyPI's; a testpypi rehearsal would need a second set of pending publishers, and isn't done.

Amendment (2026-10-16)

The user changed the rollout the same day: the first release is staged. lattice-core-ai 0.1.0 is released and published alone; then every package's floor is raised from lattice-core-ai>=0.1.0.dev0 to >=0.1.0 (#213); then the rest are released. A version on PyPI is kept for ever, so a wheel published with a dev0 floor would carry it for good, and an installer could pair it with a development build of lattice-core-ai. Staging means no published wheel ever carries one. Since the 2026-10-17 amendment on sibling floors, this holds of the floors on lattice-core-ai only; the floors on one another rose after the second release.

  • Two releases, not one. The first releases lattice-core-ai 0.1.0 (tag lattice-core-v0.1.0; the tag takes the package-name in release-please-config.json, not the distribution's name). The second releases the other six wheels and stackr, at the versions release pull request #19 gives them: evalr, reflexr, grantr, relayr-ai and portalr at 0.1.0, artifactr-ai at 0.2.0 (its manifest entry, 0.1.0, stands), and stackr at 0.1.0. stackr's 0.1 does not wait for the manual apply on real AWS that stackr RFC-0004 gates it on; that apply comes after.
  • The floors are raised between them, and cannot be raised earlier. uv resolves lattice-core-ai>=0.1.0 against the workspace's own lattice-core-ai, and 0.1.0.dev0 is older than 0.1.0, so the floors pull request fails to lock until the first release has set packages/lattice-core/pyproject.toml to 0.1.0 on main. It has to merge before the second release pull request does, so that the second release's wheels are built with it.
  • release-please has no switch for one component. It keeps one pull request for every package with unreleased changes, and on merging it creates a release for each component the merged pull request's body lists. It reads the latest released versions from .release-please-manifest.json, not from the tags, so a version left in the manifest without its release is skipped for good. And on every push to main it rewrites its open pull request whenever what it would write differs from the body there. So, without a change to the configuration, the first release is made by editing #19 by hand to lattice-core's changes alone, and merging it before anything else reaches main (step 3). A change to release-please-config.json could make this robust instead, such as "separate-pull-requests": true for one pull request a package, or leaving the other packages out of the configuration until the floors pull request puts them back; neither is made here, and either would be decided on its own. Superseded by the 2026-10-17 amendment: each package has its own release pull request, and step 3 edits nothing by hand.
  • The publishing itself is unchanged. The first release run lists one dispatch, the second six, in the order STAGES gives. A second-release dispatch checks that PyPI serves the lattice-core-ai this repository has at its tag, which the first release published. If lattice-core has releasable commits by the second release, it is in it too, as 0.1.1, and its dispatch is listed first. Since the 2026-10-17 amendment, the second release is seven release pull requests, each run listing one dispatch, and lattice-core's 0.1.1 is a release of its own.

The Decision above is otherwise unchanged; its bullet on dev0 floors is superseded by this.

The staged first release, step by step

  1. The maintainer, on PyPI and GitHub, once (What the maintainer does, once). Before step 3: the seven pypi-<distribution> environments, each with its required reviewers and its *-v* tag restriction, and the tag ruleset with the App (alexnodeland-lattice) as a bypass actor, since merging the release pull request creates its tag. Before step 4: lattice-core-ai's pending trusted publisher, and PYPI_PUBLISH set to true. Before step 7: the pending publishers of evalr, artifactr-ai, reflexr, grantr, relayr-ai and portalr (all seven can be added at once).
  2. Anyone with write access: rehearse. gh workflow run release.yml --ref main, and read the run: every wheel built at main's head, the order, and what PyPI has. It writes nothing.
  3. The maintainer: release lattice-core alone. Once the per-package release pull requests are open, close #19 unmerged (below). Check that lattice-core's release pull request, chore(main): release lattice-core 0.1.0 on the branch release-please--branches--main--components--lattice-core, changes lattice-core's CHANGELOG.md and pyproject.toml, its line in .release-please-manifest.json (to 0.1.0) and its version in uv.lock, and nothing else; then queue it, alone. Nothing is edited by hand, and the queue isn't held: other pull requests may merge meanwhile. If GitHub shows it in conflict with main, close it, as in step 6. Leave the other packages' release pull requests unqueued until step 6. Its release run tags lattice-core-v0.1.0, creates the release, attaches and attests the wheel and sdist, and lists one dispatch in its summary.
  4. The maintainer: publish lattice-core. gh workflow run release.yml --ref lattice-core-v0.1.0 -f tag=lattice-core-v0.1.0 -f mode=publish, approve the pypi-lattice-core-ai environment, and wait for it to finish: it publishes and waits until PyPI serves the files.
  5. Anyone: the floors pull request (#213). In artifactr, reflexr, grantr, relayr and portalr, lattice-core-ai>=0.1.0.dev0 becomes >=0.1.0 everywhere it appears, in dependencies and extras (grantr's is only in sql, relayr's in sql and otel; evalr requires none), and uv lock. Opened after step 3 merges, so that it locks; merged after step 4, so that the floor names a published version, and before the second release pull request.
  6. The maintainer: the second release, one release pull request at a time. Once #213 has merged, check that the open release pull requests are evalr's, reflexr's, grantr's, relayr's, portalr's and stackr's at 0.1.0 and artifactr's at 0.2.0. Queue one, and the next only when it has merged and its run has finished: two in one batch conflict on the manifest. A release pull request that GitHub shows in conflict with main is closed, not edited; the next push to main opens it again. Each run tags its package and lists its one dispatch; stackr's pushes its images and adds them to its release's notes.
  7. The maintainer: publish the six, one at a time, waiting for each, approving each in its package's environment, in STAGES's order, whatever order their release pull requests merged in: evalr; then artifactr-ai, reflexr and grantr; then relayr-ai; then portalr. With release-please's tags, these are gh workflow run release.yml --ref <tag> -f tag=<tag> -f mode=publish for evalr-v0.1.0, artifactr-v0.2.0, reflexr-v0.1.0, grantr-v0.1.0, relayr-v0.1.0 and portalr-v0.1.0. A failed or rejected dispatch is recovered as above, by running it again.

Amendment (2026-10-17): one release pull request a package

The user decided, the next day, that each package has its own release pull request: "separate-pull-requests": true in release-please-config.json. Steps 3 and 6 above are written for it; no step edits a release pull request by hand. What release-please 17.6.0 (the pinned action, v5.0.0) does with it, from its source and a dry run:

  • One pull request a package. Each package with releasable changes gets a pull request titled chore(main): release <package-name> <version>, such as chore(main): release lattice-core 0.1.0, on the branch release-please--branches--main--components--<package-name>. It changes that package's CHANGELOG.md and pyproject.toml, its line in the manifest and its version in uv.lock. Merging it releases that package alone, as <package-name>-v<version>: release-please matches the merged pull request to its package by the branch. The Title check accepts these titles (it checks the type and lists no scopes), and the merge queue asks only for the queue label. The configuration has no plugins or linked-versions, so a release changes no other package: raising the floors stays #213's.
  • #19 is left behind. release-please finds its open pull request by branch, and none of the new branches is #19's release-please--branches--main. So the first push to main with this configuration opens one release pull request for each of the eight packages, and neither updates nor closes #19. #19 still carries autorelease: pending, and merging it would still release all eight: the maintainer closes it, unmerged, once the eight are open.
  • The release run is unchanged. release-please's outputs are per release, whichever pull request made it, so the run that merging one package's pull request triggers has that package alone in paths_released, with its --tag_name and --sha; the plan reads them as it reads any release (tests/test_release_plan.py plans runs of one package), and the run's summary lists that package's dispatch.
  • A release pull request can fall behind main. release-please rewrites an open release pull request, from main's head, only when its description would change. The packages' lines in .release-please-manifest.json are next to each other, and git takes changes to adjacent lines as a conflict, so once one package's release merges, the release pull requests of the packages either side of it in the manifest (after lattice-core's: evalr's and stackr's) conflict with main until their descriptions change. A conflicting one is closed, not edited: release-please opens it again from main's head at the next push to main. "always-update": true would instead rewrite every open release pull request on every push, at a CI run for each, each time; it is not set.
  • The others wait, unqueued. From step 3 to step 6, the other packages' release pull requests stay open with no queue label. They change only versions and changelogs, so whenever they merge after #213, their releases are built from a main that has the raised floors, whether or not release-please has rewritten them since.
  • The second release is seven merges, one at a time (step 6), and seven runs, each listing one dispatch. Step 7's order is STAGES's whatever order they merged in, and each dispatch checks that PyPI serves what its package requires. If lattice-core has releasable commits by then, its next release pull request (0.1.1) is its own: the others' floor is 0.1.0, so it need not come first.

Amendment (2026-10-17): one environment a package

PyPI refused the second pending trusted publisher: with owner alexnodeland, repository lattice, workflow release.yml and environment pypi, it answered that the publisher "has already been registered for a different project name". A pending publisher is how a trusted publisher creates its project on the first upload, so PyPI lets one configuration be pending for one project name only. Owner, repository and workflow are the same for every package, so the environment is the one field that can tell them apart.

  • Each package publishes in its own environment, pypi-<distribution>: pypi-lattice-core-ai, pypi-evalr, pypi-artifactr-ai, pypi-reflexr, pypi-grantr, pypi-relayr-ai and pypi-portalr. The pypi-publish job's environment is pypi-${{ matrix.package.name }}, and the plan's name is the distribution's name, as release_plan.distribution gives it, so each leg runs in the environment its package's pending publisher names. A test holds the job to it.
  • The approval is unchanged. Each environment has the maintainer as its required reviewer and allows deployments from *-v* tags only, so each is the approval gate for its package, and a dispatch is still one approval. The tag ruleset is the repository's, and is unchanged.
  • A missing environment is not a gate. GitHub creates an environment a job names and the repository doesn't have, with no protection rules. So all seven are created, with their reviewers and tag restriction, before PYPI_PUBLISH is set (What the maintainer does, once); a package added later gets its environment, and its pending publisher, before its first dispatch.
  • Not chosen: one environment, publishers added in turn. The restriction applies only while a publisher is pending: once a project exists, its publisher is no longer pending, and the next package's could then be added (it seems; not tried). That would make adding each publisher wait on the publishing before it, and step 1's "all seven can be added at once" would no longer hold.
  • The publisher already pending on PyPI, with environment pypi, is removed and added again with its package's own environment. Until it is, a run in an environment named pypi, which GitHub creates unprotected if it is missing, could still create that project. A pypi environment on GitHub then gates nothing and can be deleted.

Amendment (2026-10-17): the sibling floors ship as dev0

The user decided that the second release ships with the libraries' floors on one another as they are, and that they are raised in the packages' next patch releases. The Amendment (2026-10-16) raised only the floors on lattice-core-ai (#213, step 5, in #318), so "no published wheel ever carries one" is true of those alone. The libraries' floors on one another are still >=0.1.0.dev0:

  • artifactr-ai and reflexr require evalr in their evals extra and evalr[langfuse] in their langfuse extra.
  • relayr-ai requires reflexr, reflexr[langfuse] in its langfuse extra, and evalr and evalr[langfuse] in its evals and langfuse extras.
  • portalr requires grantr[fastapi].

As with lattice-core, uv can't lock >=0.1.0 against a workspace member still at 0.1.0.dev0, so a floor can rise only once its sibling's own release pull request has merged. Raising them in between the second release's merges, sibling by sibling, was possible; the user chose not to interleave them.

  • The second release's wheels carry them, for good. A published version is kept for ever, so artifactr-ai 0.2.0, reflexr 0.1.0, relayr-ai 0.1.0 and portalr 0.1.0 keep these floors.
  • A dev0 floor lets pip take a pre-release for that requirement. pip allows pre-releases for a requirement whose specifier names one, such as >=0.1.0.dev0: if a development build of evalr newer than 0.1.0 were ever on PyPI, pip could pick it for artifactr-ai[evals], with no flag from the user. uv (0.12.12, the pinned version) prefers final releases by default, whatever the specifier says, and takes a pre-release only when no final release fits; its --prerelease allow opens every requirement to pre-releases, and with it an install was seen to take httpx 1.0.dev6. lattice publishes no development builds, so no such evalr, reflexr or grantr exists today.
  • They rise to >=0.1.0 in a follow-up, once the second release has put each sibling at 0.1.0 on main, and ship with the next releases of the packages that carry them: artifactr-ai 0.2.1, and reflexr, relayr-ai and portalr 0.1.1, if nothing in between bumps a minor version (bump-minor-pre-major makes a feat before then a 0.3.0 or 0.2.0). The follow-up is a fix(deps), so that it releases those packages on its own.

The sibling floors rose

2026-10-17. With every package released and on PyPI, the follow-up is #333, a fix(deps): each floor above is >=0.1.0, and relayr's on artifactr-ai, which was >=0.1.0, a version never on PyPI, is >=0.2.0. uv lock changes nothing, since a workspace member's requirement on another is locked with no specifier. The floors ship in artifactr-ai 0.2.1, and reflexr, relayr-ai and portalr 0.1.1; the four wheels of the second release keep their dev0 floors, as above. No dev0 floor is left in any package's pyproject.toml.

Amendment (2026-10-17): a package added unreleased

lattice RFC-0003 adds an eighth library, artifactr-files-ai (packages/artifactr-files), and it joins the release unreleased, as a new package does (ADR-0006): at 0.0.0 in .release-please-manifest.json, with its uv.lock extra file, and in STAGES's third stage, beside relayr-ai, since it requires artifactr-ai and lattice-core-ai. The workflow is unchanged.

  • Its release pull request stays unqueued. release-please opens chore(main): release artifactr-files 0.1.0 at the first push with a feat of it. It is queued only once its artifactr-ai floor names the artifactr release that has Workspace.lease_artifact, which it needs: until that release is on main, uv can't lock the floor, and a wheel published with the older floor would install against an artifactr without the lease.
  • What stands between it and PyPI is what stands between any package and PyPI: a release run never publishes; a dispatch publishes only with PYPI_PUBLISH set, and only if PyPI has a trusted publisher for artifactr-files-ai, which no one has added. Its pypi-artifactr-files-ai environment, with the maintainer as its reviewer and the *-v* tag restriction, and its pending publisher are added before its first dispatch, as the amendment on environments says of a package added later. Until then, a dispatch would create that environment unprotected, so the publisher, not the environment, is what refuses it.
  • The lists above (seven distributions, seven environments, the order ending in relayr-ai and portalr) are the first release's, and are left as they were written. The order is now: lattice-core-ai and evalr; then artifactr-ai, reflexr and grantr; then relayr-ai and artifactr-files-ai; then portalr.

The package was folded into artifactr

2026-10-17. Before its first release, artifactr-files-ai became artifactr's files extra, artifactr-ai[files] (ADR-0026). It leaves release-please-config.json, the manifest and STAGES, and the amendment above no longer applies to anything: its release pull request, #354, was never queued and is to be closed unmerged, since release-please no longer tends it, and no pypi-artifactr-files-ai environment or pending publisher was added, so there is nothing to remove from GitHub or PyPI. The order is again the first release's with artifactr-files left out: lattice-core-ai and evalr; then artifactr-ai, reflexr and grantr; then relayr-ai; then portalr.