Skip to content

ci: publish to PyPI via Trusted Publishing on GitHub Releases - #49

Merged
sklinglernv merged 4 commits into
mainfrom
ci/pypi-trusted-publishing
Jul 30, 2026
Merged

ci: publish to PyPI via Trusted Publishing on GitHub Releases#49
sklinglernv merged 4 commits into
mainfrom
ci/pypi-trusted-publishing

Conversation

@sklinglernv

Copy link
Copy Markdown
Collaborator

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-versioning turns into the version, exactly as RELEASING.md already described.

Nothing here can publish anything on merge. publish.yml triggers only on release: published and workflow_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:

  • the built version equals the release tag, and is not a .devN version (which would mean the tag was not reachable from the checked-out commit — the shallow-clone failure mode);
  • all four wheels import cleanly in a fresh venv, and nooa --version runs.

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, so nooa-* matches the core's files and never nooa_cli-*; verified to split the 8 artifacts exactly 2-per-package.

uv --no-sources

Release builds now pass --no-sources, as the uv packaging guide recommends — it disables tool.uv.sources so the build is exercised the way a non-uv consumer sees it. Relevant here because all three sub-packages resolve the core via nooa = { 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 check passes on all 8 artifacts, and cross-package deps come out as a plain Requires-Dist: nooa with no workspace leakage.

Packaging metadata

Project URLs on all four; classifiers and keywords on the core; READMEs for nooa-memory and nooa-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

  • Register pending publishers for all four names (table in RELEASING.md) — environment name differs per package
  • Admin: add required reviewers to the pypi-* environments

The 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

Comment thread packages/nooa-bench/pyproject.toml Outdated
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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requires-python = ">=3.12,<3.14" since this depends on nooa-cli that has <3.14

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment thread .github/workflows/publish.yml Outdated
target:
description: "Index to upload to"
type: choice
options: [testpypi, pypi]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

sklinglernv and others added 4 commits July 30, 2026 07:51
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
sklinglernv force-pushed the ci/pypi-trusted-publishing branch from 251cdc7 to f880b38 Compare July 30, 2026 05:53
@sklinglernv
sklinglernv merged commit 6f526c8 into main Jul 30, 2026
5 checks passed
@alessiodevoto
alessiodevoto deleted the ci/pypi-trusted-publishing branch August 3, 2026 06:55
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.

2 participants