Skip to content

Commit 6f526c8

Browse files
authored
Merge pull request #49 from NVIDIA-NeMo/ci/pypi-trusted-publishing
ci: publish to PyPI via Trusted Publishing on GitHub Releases
2 parents 4afa5ea + f880b38 commit 6f526c8

10 files changed

Lines changed: 413 additions & 29 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,13 @@ jobs:
4747
uses: astral-sh/setup-uv@v5
4848
with:
4949
python-version: "3.12"
50+
# Mirrors the publish workflow's build exactly (--no-sources included) so
51+
# this job stays a real canary for release-build breakage.
5052
- name: Build all packages
5153
run: |
5254
rm -rf dist
5355
for pkg in nooa nooa-cli nooa-memory nooa-bench; do
54-
uv build --package "$pkg" --out-dir dist
56+
uv build --no-sources --package "$pkg" --out-dir dist
5557
done
5658
- name: Upload wheels
5759
uses: actions/upload-artifact@v4

.github/workflows/publish.yml

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Publishes the four workspace packages to PyPI.
2+
#
3+
# Trigger: a GitHub Release is *published* (the release's tag `vX.Y.Z` is what
4+
# uv-dynamic-versioning turns into the package version — see RELEASING.md).
5+
# Auth: PyPI Trusted Publishing (OIDC). No API tokens, no repo secrets.
6+
#
7+
# `workflow_dispatch` runs the same build against TestPyPI for a dry run.
8+
#
9+
# Every `uses:` here is an `actions/*` action, i.e. GitHub-created. That is
10+
# deliberate: this org enforces an Actions allowlist, and a disallowed action
11+
# fails the *entire workflow* at startup (see PR #50 — one blocked action left
12+
# CI dead for 8 days). A publish workflow that cannot start is a publish
13+
# workflow that silently never ships. uv is installed from a pinned, versioned
14+
# install script and does the upload itself via `uv publish`.
15+
name: Publish
16+
17+
on:
18+
release:
19+
types: [published]
20+
# Manual runs are ALWAYS a TestPyPI dry run. There is deliberately no input
21+
# to select the index: real PyPI is reachable only by publishing a GitHub
22+
# Release, so a mis-click here cannot burn a version number on PyPI.
23+
workflow_dispatch:
24+
25+
permissions: {}
26+
27+
env:
28+
# Pinned: the install script is fetched at runtime, so an unversioned URL
29+
# would make every run depend on whatever uv ships that day.
30+
UV_VERSION: "0.11.4"
31+
32+
jobs:
33+
build:
34+
runs-on: ubuntu-latest
35+
steps:
36+
- uses: actions/checkout@v4
37+
with:
38+
fetch-depth: 0 # tags needed for uv-dynamic-versioning
39+
40+
- name: Install uv
41+
run: |
42+
set -euo pipefail
43+
curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh
44+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
45+
46+
# --no-sources: build with `tool.uv.sources` disabled, so the build is
47+
# exercised the way a non-uv consumer's build backend would see it. Without
48+
# it, a workspace source could paper over a dependency that is unresolvable
49+
# off this machine. Recommended by
50+
# https://docs.astral.sh/uv/guides/package/
51+
- name: Build all packages
52+
run: |
53+
rm -rf dist
54+
for pkg in nooa nooa-cli nooa-memory nooa-bench; do
55+
uv build --no-sources --package "$pkg" --out-dir dist
56+
done
57+
ls -l dist
58+
59+
# Guards the two ways a release can silently ship the wrong version:
60+
# a shallow checkout (no tag reachable -> `.devN`), or a Release created
61+
# from a commit that is not the tagged one.
62+
- name: Check built version matches the release tag
63+
if: github.event_name == 'release'
64+
env:
65+
TAG: ${{ github.event.release.tag_name }}
66+
run: |
67+
uv run --no-project --with packaging python - <<'PY'
68+
import os, pathlib, sys
69+
from packaging.utils import parse_wheel_filename
70+
from packaging.version import Version
71+
72+
expected = Version(os.environ["TAG"].removeprefix("v"))
73+
wheels = sorted(pathlib.Path("dist").glob("*.whl"))
74+
assert len(wheels) == 4, f"expected 4 wheels, got {[w.name for w in wheels]}"
75+
for whl in wheels:
76+
_, version, _, _ = parse_wheel_filename(whl.name)
77+
if version.is_devrelease:
78+
sys.exit(f"{whl.name}: dev version — the tag is not reachable from HEAD")
79+
if version != expected:
80+
sys.exit(f"{whl.name}: built {version}, but the tag says {expected}")
81+
print(f"OK — all four packages built as {expected}")
82+
PY
83+
84+
# Catches a broken wheel before it is on PyPI forever.
85+
- name: Smoke-test the wheels in a clean environment
86+
run: |
87+
uv venv /tmp/smoke --python 3.12
88+
VIRTUAL_ENV=/tmp/smoke uv pip install \
89+
dist/nooa-*.whl dist/nooa_cli-*.whl dist/nooa_memory-*.whl dist/nooa_bench-*.whl
90+
/tmp/smoke/bin/python -c "import nooa, nooa_cli, nooa_memory, nooa_bench; print(nooa.__version__)"
91+
/tmp/smoke/bin/nooa --version
92+
93+
- uses: actions/upload-artifact@v4
94+
with:
95+
name: dist
96+
path: dist/
97+
98+
# One job per package, each in its OWN environment (`pypi-<package>`).
99+
#
100+
# PyPI keys a *pending* trusted publisher on
101+
# (owner, repo, workflow filename, environment). Four packages sharing one
102+
# environment collide: PyPI rejects the 2nd registration with "a pending
103+
# trusted publisher matching this configuration has already been registered
104+
# for a different project name", because it cannot tell which project to
105+
# create on first upload. A distinct environment per package makes each
106+
# tuple unique. (The constraint applies only while a publisher is pending —
107+
# but a distinct environment is also what gives per-package approval gates.)
108+
publish-testpypi:
109+
needs: build
110+
if: github.event_name == 'workflow_dispatch'
111+
runs-on: ubuntu-latest
112+
strategy:
113+
fail-fast: false # a partial publish is recoverable; a cancelled one is messier
114+
matrix:
115+
package: [nooa, nooa-cli, nooa-memory, nooa-bench]
116+
environment:
117+
name: testpypi-${{ matrix.package }}
118+
url: https://test.pypi.org/p/${{ matrix.package }}
119+
permissions:
120+
id-token: write # required for Trusted Publishing
121+
steps:
122+
- uses: actions/download-artifact@v4
123+
with:
124+
name: dist
125+
path: dist/
126+
# Upload only this package's files. Distribution filenames normalise `-`
127+
# to `_`, and the glob anchors on the `-` before the version, so
128+
# `nooa-*` matches nooa's own files and never `nooa_cli-*`.
129+
- name: Isolate this package's artifacts
130+
env:
131+
PKG: ${{ matrix.package }}
132+
run: |
133+
set -euo pipefail
134+
mkdir upload
135+
cp dist/"${PKG//-/_}"-* upload/
136+
test "$(ls upload | wc -l)" -eq 2 # exactly one wheel + one sdist
137+
ls -l upload
138+
- name: Install uv
139+
run: |
140+
set -euo pipefail
141+
curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh
142+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
143+
# --trusted-publishing always: never silently fall back to looking for a
144+
# token if the OIDC exchange fails. Fail instead, so a broken publisher
145+
# config surfaces as an error rather than an auth prompt.
146+
- name: Publish to TestPyPI
147+
run: |
148+
uv publish --trusted-publishing always \
149+
--publish-url https://test.pypi.org/legacy/ \
150+
--check-url https://test.pypi.org/simple/ \
151+
upload/*
152+
153+
publish-pypi:
154+
needs: build
155+
# Real PyPI is reachable ONLY from a published GitHub Release.
156+
if: github.event_name == 'release'
157+
runs-on: ubuntu-latest
158+
strategy:
159+
fail-fast: false
160+
matrix:
161+
package: [nooa, nooa-cli, nooa-memory, nooa-bench]
162+
environment:
163+
name: pypi-${{ matrix.package }}
164+
url: https://pypi.org/p/${{ matrix.package }}
165+
permissions:
166+
id-token: write # required for Trusted Publishing
167+
steps:
168+
- uses: actions/download-artifact@v4
169+
with:
170+
name: dist
171+
path: dist/
172+
# Upload only this package's files. Distribution filenames normalise `-`
173+
# to `_`, and the glob anchors on the `-` before the version, so
174+
# `nooa-*` matches nooa's own files and never `nooa_cli-*`.
175+
- name: Isolate this package's artifacts
176+
env:
177+
PKG: ${{ matrix.package }}
178+
run: |
179+
set -euo pipefail
180+
mkdir upload
181+
cp dist/"${PKG//-/_}"-* upload/
182+
test "$(ls upload | wc -l)" -eq 2 # exactly one wheel + one sdist
183+
ls -l upload
184+
- name: Install uv
185+
run: |
186+
set -euo pipefail
187+
curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh
188+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
189+
# --trusted-publishing always: never silently fall back to looking for a
190+
# token if the OIDC exchange fails. Fail instead, so a broken publisher
191+
# config surfaces as an error rather than an auth prompt.
192+
#
193+
# --check-url makes a re-run idempotent: already-uploaded files are
194+
# skipped rather than erroring. Matters because `fail-fast: false` means
195+
# a partial publish is a state you can land in and need to resume from.
196+
- name: Publish to PyPI
197+
run: |
198+
uv publish --trusted-publishing always \
199+
--check-url https://pypi.org/simple/ \
200+
upload/*
201+
202+
# Attach the built wheels to the GitHub Release so `pip install <url>` and
203+
# air-gapped consumers get the exact artifacts that went to PyPI.
204+
attach-to-release:
205+
needs: publish-pypi
206+
if: github.event_name == 'release'
207+
runs-on: ubuntu-latest
208+
permissions:
209+
contents: write
210+
steps:
211+
- uses: actions/download-artifact@v4
212+
with:
213+
name: dist
214+
path: dist/
215+
# The tag name goes through `env:`, not `${{ }}` inside the script.
216+
# GitHub expands `${{ }}` textually *before* bash parses the line, so a
217+
# tag containing `$(...)` or backticks would execute — double quotes do
218+
# not help, because the substitution happens before quoting is applied.
219+
# This job holds `contents: write`. Via env, bash sees the value as data.
220+
- name: Attach artifacts to the release
221+
env:
222+
GH_TOKEN: ${{ github.token }}
223+
RELEASE_TAG: ${{ github.event.release.tag_name }}
224+
run: gh release upload "$RELEASE_TAG" dist/* --repo "$GITHUB_REPOSITORY"

RELEASING.md

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,50 +33,118 @@ initial development).
3333

3434
## Cutting a release
3535

36+
Publishing to PyPI is automated by
37+
[`.github/workflows/publish.yml`](.github/workflows/publish.yml). **Publishing a
38+
GitHub Release is the release ceremony** — the release's tag is what
39+
`uv-dynamic-versioning` turns into the version.
40+
3641
```bash
3742
git checkout main && git pull
38-
git tag -a v0.0.6 -m "NOOA 0.0.6 — research preview"
39-
git push origin v0.0.6
43+
gh release create v0.0.7 --title "NOOA 0.0.7" --generate-notes --draft
44+
# review the draft notes, then publish it:
45+
gh release edit v0.0.7 --draft=false
4046
```
4147

42-
Build the four packages from the tagged commit:
48+
Publishing the release triggers the workflow, which:
49+
50+
1. Builds all four packages from the tagged commit.
51+
2. Fails the run if the built version does not match the tag, or is a `.devN`
52+
version (which means the tag was not reachable from the checked-out commit).
53+
3. Smoke-tests the wheels in a clean venv (imports + `nooa --version`).
54+
4. Uploads to PyPI via **Trusted Publishing** (`uv publish`) — no API tokens.
55+
5. Attaches the wheels and sdists to the GitHub Release.
56+
57+
Each upload waits on its `pypi-<package>` GitHub Environment, so a required
58+
reviewer there gives a second pair of eyes before the irreversible step.
59+
60+
> **Why no third-party actions.** Every `uses:` in `publish.yml` is an
61+
> `actions/*` action. This org enforces a GitHub Actions allowlist, and a
62+
> disallowed action fails the *entire workflow* at startup — that is what left
63+
> CI dead for eight days (PR #50). A publish workflow that cannot start is one
64+
> that silently never ships, so uv is installed from a pinned install script
65+
> and does the upload itself.
66+
>
67+
> The tradeoff is **no PEP 740 attestations**: `uv publish` uploads them but
68+
> [does not generate them](https://docs.astral.sh/uv/guides/package/), and the
69+
> action that does (`pypa/gh-action-pypi-publish`) may not be allowlisted.
70+
> Worth revisiting if it is added to the allowlist, or once uv can generate
71+
> them. Trusted Publishing itself is unaffected.
72+
73+
### Dry run against TestPyPI
74+
75+
Run the **Publish** workflow manually (Actions → Publish → Run workflow). This
76+
exercises the identical build, version check, and smoke test.
77+
78+
A manual run always targets TestPyPI — there is no index selector. Real PyPI is
79+
reachable only by publishing a GitHub Release, so a mis-click here cannot burn
80+
a version number on PyPI.
81+
82+
### Doing it by hand
83+
84+
`--no-sources` disables `tool.uv.sources` so the build is exercised the way a
85+
non-uv consumer sees it — the [uv packaging
86+
guide](https://docs.astral.sh/uv/guides/package/) recommends it for release
87+
builds.
4388

4489
```bash
4590
rm -rf dist
4691
for p in nooa nooa-cli nooa-memory nooa-bench; do
47-
uv build --package "$p" --out-dir dist
92+
uv build --no-sources --package "$p" --out-dir dist
4893
done
94+
uvx twine check dist/*
95+
uv venv /tmp/nooa-smoke --python 3.12
96+
VIRTUAL_ENV=/tmp/nooa-smoke uv pip install dist/nooa-*.whl dist/nooa_cli-*.whl \
97+
dist/nooa_memory-*.whl dist/nooa_bench-*.whl
98+
/tmp/nooa-smoke/bin/python -c "import nooa, nooa_cli, nooa_memory, nooa_bench; print(nooa.__version__)"
4999
```
50100

51-
**Smoke-test the wheels in a clean environment** before publishing:
101+
### Pre-release tags
52102

53-
```bash
54-
python3.12 -m venv /tmp/nooa-smoke && . /tmp/nooa-smoke/bin/activate
55-
pip install dist/nooa-*.whl dist/nooa_cli-*.whl dist/nooa_memory-*.whl dist/nooa_bench-*.whl
56-
python -c "import nooa, nooa_cli, nooa_memory, nooa_bench; print(nooa.__version__)"
57-
nooa --version
58-
deactivate
59-
```
103+
Annotated tags like `v0.0.6-rc1` build as `0.0.6rc1` (PEP 440 normalized). Mark
104+
the GitHub Release as a pre-release; PyPI will not serve it to plain
105+
`pip install nooa`.
60106

61-
### Pre-release tags
107+
## One-time PyPI setup
62108

63-
Annotated tags like `v0.0.6-rc1` build as `0.0.6rc1` (PEP 440 normalized).
109+
Each of the four project names needs a **pending publisher** registered at
110+
<https://pypi.org/manage/account/publishing/> before its first upload. Owner
111+
`NVIDIA-NeMo`, repository `labs-OO-Agents`, workflow `publish.yml` for all four
112+
— but the **environment name differs per package**:
64113

65-
## Distribution
114+
| PyPI Project Name | Environment name |
115+
|---|---|
116+
| `nooa` | `pypi-nooa` |
117+
| `nooa-cli` | `pypi-nooa-cli` |
118+
| `nooa-memory` | `pypi-nooa-memory` |
119+
| `nooa-bench` | `pypi-nooa-bench` |
120+
121+
> **Why one environment per package.** PyPI keys a *pending* publisher on
122+
> (owner, repo, workflow filename, environment). If all four shared one
123+
> environment, the second registration fails with *"a pending trusted publisher
124+
> matching this configuration has already been registered for a different
125+
> project name"* — PyPI cannot tell which project to create on first upload.
126+
> The restriction lifts once a project exists, but the per-package environment
127+
> is kept because it also gives per-package approval gates.
128+
129+
Repeat on <https://test.pypi.org> using `testpypi-<package>` environment names
130+
for dry runs. After the first successful upload each pending publisher becomes
131+
a normal one.
132+
133+
The matching **GitHub Environments** must exist too (Settings → Environments):
134+
`pypi-nooa`, `pypi-nooa-cli`, `pypi-nooa-memory`, `pypi-nooa-bench`, and the
135+
four `testpypi-*` equivalents.
66136

67-
The packages are currently distributed as **source** — install directly from
68-
GitHub at a tag:
137+
## Distribution
69138

70139
```bash
71-
uv add "nooa @ git+https://github.com/NVIDIA-NeMo/labs-OO-Agents.git@v0.0.6"
140+
uv add nooa nooa-cli
72141
```
73142

74-
Optionally attach the built wheels to a **GitHub Release** for the tag.
143+
Installing straight from a tag also works, and does not require a release:
75144

76-
> **PyPI publishing is not yet enabled.** When it is, a GitHub Actions workflow
77-
> (PyPI Trusted Publishing) will build and upload all four packages on each
78-
> `vX.Y.Z` tag. The names `nooa`, `nooa-cli`, `nooa-memory`, and `nooa-bench`
79-
> are available on PyPI and can be reserved ahead of the first publish.
145+
```bash
146+
uv add "nooa @ git+https://github.com/NVIDIA-NeMo/labs-OO-Agents.git@v0.0.7"
147+
```
80148

81149
## Cross-package dependencies
82150

packages/nooa-bench/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# nooa-bench
2+
3+
Benchmark agent (`BenchAgent`) and Harbor runner for
4+
[NOOA](https://github.com/NVIDIA-NeMo/labs-OO-Agents). Reproduces the SWE-bench
5+
and Terminal-Bench results from the NOOA tech report.
6+
7+
```bash
8+
uv add nooa-bench
9+
nemo-harbor --help
10+
```
11+
12+
See the [main repository](https://github.com/NVIDIA-NeMo/labs-OO-Agents) for
13+
documentation.
14+
15+
Apache-2.0 licensed.

0 commit comments

Comments
 (0)