|
| 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" |
0 commit comments