ci: publish to PyPI via Trusted Publishing on GitHub Releases - #49
Merged
Conversation
alessiodevoto
approved these changes
Jul 29, 2026
| description = "Benchmark agent (BenchAgent) and Harbor runner for the NOOA framework — reproduces the tech report's SWE-bench and Terminal-Bench results" | ||
| license = {text = "Apache-2.0"} | ||
| readme = "README.md" | ||
| requires-python = ">=3.12" |
Collaborator
There was a problem hiding this comment.
requires-python = ">=3.12,<3.14" since this depends on nooa-cli that has <3.14
| target: | ||
| description: "Index to upload to" | ||
| type: choice | ||
| options: [testpypi, pypi] |
Collaborator
There was a problem hiding this comment.
Manual dispatch should be only for TestPyPI dry runs, but this also allows real PyPI, maybe to be on the safe side and avoid wrong releases we can do:
options: [testpypi]
and:
publish-pypi:
if: github.event_name == 'release'Adds .github/workflows/publish.yml, which builds all four workspace
packages and uploads them to PyPI when a GitHub Release is published.
Auth is OIDC Trusted Publishing, so there are no API tokens or repo
secrets to manage.
The workflow gates the upload behind two checks, since a PyPI version
number can never be reused:
- the built version must equal the release tag and must not be a
`.devN` version, which would mean the tag was not reachable from the
checked-out commit (e.g. a shallow clone);
- the wheels must import cleanly in a fresh venv.
Publishing fans out into one job per package, each in its own
`pypi-<package>` environment. PyPI keys a *pending* trusted publisher on
(owner, repo, workflow, environment), so four packages sharing a single
environment collide on registration — PyPI cannot tell which project to
create on first upload. Distinct environments also allow per-package
approval gates.
Builds now pass --no-sources, as the uv packaging guide recommends for
release builds: it disables tool.uv.sources so the build is exercised
the way a non-uv consumer sees it. The three sub-packages resolve the
core via `nooa = { workspace = true }`, which this keeps honest. The CI
build job mirrors the flag so it stays a canary for release breakage.
Packaging metadata is filled in for the PyPI project pages: project
URLs on all four, classifiers and keywords on the core, and READMEs for
nooa-memory and nooa-bench, which would otherwise publish blank pages.
nooa-memory's description said "not yet published", which is the
one-liner PyPI renders under the package name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`pip install nooa[cli]` previously succeeded with exit code 0, emitted a single "does not have an extra named `cli`" warning, and installed no CLI at all — leaving no `nooa` command and a user who believes the install worked. The sibling packages are separate distributions, so the only working spelling was `pip install nooa-cli`. A published version's extras cannot be changed after upload, so adding these in a later release would still strand anyone on 0.0.7. Doing it before the first publish. The extras are deliberately circular (nooa[cli] -> nooa-cli -> nooa). Verified with both resolvers against locally built wheels: `nooa[cli]` installs a working `nooa` command, and `pip check` reports no broken requirements. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This org enforces a GitHub Actions allowlist, and a disallowed action fails the *entire workflow* at startup rather than just its own job — that is what left CI dead for eight days (see PR #50). publish.yml referenced two non-GitHub actions, `astral-sh/setup-uv` and `pypa/gh-action-pypi-publish`, either of which could have produced a `startup_failure`. A publish workflow that cannot start is one that silently never ships, and this one would only be exercised at the moment of an actual release. Every `uses:` is now an `actions/*` action. uv is installed from a pinned, versioned install script and performs the upload itself. `--trusted-publishing always` rather than `automatic`, so a broken publisher config fails loudly instead of falling back to hunting for a token. `--check-url` makes a re-run idempotent, skipping already-uploaded files — which matters because `fail-fast: false` means a partial publish is a state we can land in and need to resume from. The tradeoff is losing PEP 740 attestations: uv uploads them but does not generate them, and the action that does may not be allowlisted. Provenance is worth nothing if the workflow cannot start. Documented in RELEASING.md as something to revisit. Verified locally: the pinned install script yields exactly uv 0.11.4, and `uv publish --trusted-publishing always` outside an OIDC environment exits 2 with "No OIDC token discovered" rather than hanging or falling back. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three fixes from review of #49. **Script injection in attach-to-release** (found by Codex, via @alessiodevoto). `gh release upload "${{ github.event.release.tag_name }}"` interpolated the tag into the shell script. GitHub expands `${{ }}` textually before bash parses the line, so a tag containing `$(...)` or backticks executes — the double quotes do not help, because substitution happens before quoting applies. The job holds `contents: write`. The tag now goes through `env:`, so bash sees it as data. Audited the rest of the file for the same class: this was the only instance; every other interpolation already used the env pattern. **Manual dispatch could reach real PyPI** (@alessiodevoto). The dispatch input offered `pypi` as well as `testpypi`, and publish-pypi ran on `inputs.target == 'pypi'`. Rather than reducing the choice list to one item, the input is removed entirely: a manual run is always a TestPyPI dry run, and real PyPI is reachable only by publishing a GitHub Release. A single-option selector would have been a knob that cannot be turned. **nooa-bench requires-python** (@alessiodevoto). It declared `>=3.12` with no upper bound while depending on nooa and nooa-cli, both `>=3.12,<3.14`. On 3.14 that surfaces as "could not find a version that satisfies nooa" rather than a clean "requires a different Python". Now consistent across all four packages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sklinglernv
force-pushed
the
ci/pypi-trusted-publishing
branch
from
July 30, 2026 05:53
251cdc7 to
f880b38
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sets up automated PyPI publishing for the four workspace packages. Publishing a GitHub Release is the release ceremony — the release's tag is what
uv-dynamic-versioningturns into the version, exactly asRELEASING.mdalready described.Nothing here can publish anything on merge.
publish.ymltriggers only onrelease: publishedandworkflow_dispatch, and no GitHub Release exists yet.Why Releases rather than tag push
A Release is a tag, so versioning is unchanged. What it adds: notes live with the artifacts, a draft state to fix mistakes before anything fires, and a human click before an upload that can never be undone — PyPI does not allow reusing a version, even after a delete.
Safety gates
A bad upload is permanent, so the build job refuses to hand off unless:
.devNversion (which would mean the tag was not reachable from the checked-out commit — the shallow-clone failure mode);nooa --versionruns.One environment per package
Publishing fans out into a 4-way matrix, each job in its own
pypi-<package>environment.PyPI keys a pending trusted publisher on (owner, repo, workflow, environment). Four packages sharing one environment collide — the second registration is rejected with "a pending trusted publisher matching this configuration has already been registered for a different project name", because PyPI cannot tell which project to create on first upload. Distinct environments also give per-package approval gates.
Each job uploads only its own artifacts. The glob anchors on the
-before the version, sonooa-*matches the core's files and nevernooa_cli-*; verified to split the 8 artifacts exactly 2-per-package.uv
--no-sourcesRelease builds now pass
--no-sources, as the uv packaging guide recommends — it disablestool.uv.sourcesso the build is exercised the way a non-uv consumer sees it. Relevant here because all three sub-packages resolve the core vianooa = { workspace = true }. The CI build job mirrors the flag so it stays a real canary for release-build breakage.Verified locally: all four build with
--no-sources,twine checkpasses on all 8 artifacts, and cross-package deps come out as a plainRequires-Dist: nooawith no workspace leakage.Packaging metadata
Project URLs on all four; classifiers and keywords on the core; READMEs for
nooa-memoryandnooa-bench, which would otherwise have published blank PyPI pages.nooa-memory's description read "not yet published" — that string is the one-liner PyPI renders under the package name.Before the first release
RELEASING.md) — environment name differs per packagepypi-*environmentsThe environments themselves do not need pre-creating — GitHub creates them on first workflow reference. But an auto-created environment has no protection rules, so an admin should add reviewers before the first real release. A TestPyPI dry run (Actions → Publish →
testpypi) is safe unattended.🤖 Generated with Claude Code