|
| 1 | +name: Publish |
| 2 | + |
| 3 | +# Publishes `openrouter-agent-sdk` to PyPI using trusted publishing (OIDC) — no |
| 4 | +# long-lived API token is stored in this repo. |
| 5 | +# |
| 6 | +# Publishing is irreversible: a version number can never be reused on PyPI, even |
| 7 | +# after a yank. So this workflow is manual-only, defaults to a dry run, and |
| 8 | +# refuses to publish a version that already exists on the index. |
| 9 | +# |
| 10 | +# Release procedure: |
| 11 | +# 1. Land the version bump (pyproject.toml `version`). For a port sync that is |
| 12 | +# done by scripts/upstream; otherwise edit it in a PR. |
| 13 | +# 2. Run this workflow with target=testpypi to rehearse (optional but cheap). |
| 14 | +# 3. Run with target=pypi, dry-run=true, and read the summary. |
| 15 | +# 4. Run with target=pypi, dry-run=false to release. |
| 16 | +# |
| 17 | +# One-time setup on PyPI, before the first real publish — the workflow cannot do |
| 18 | +# this for you: |
| 19 | +# PyPI → the project (or "pending publisher" if it does not exist yet) → |
| 20 | +# Publishing → add a GitHub trusted publisher with |
| 21 | +# owner: OpenRouterTeam repo: python-agent |
| 22 | +# workflow: publish.yaml environment: pypi |
| 23 | +# Then create the `pypi` (and `testpypi`) environment in repo Settings, and set |
| 24 | +# its deployment branch policy to `main`. |
| 25 | +# |
| 26 | +# That environment branch policy is the real ref restriction. The `if:` guard |
| 27 | +# below stops accidents, not a determined actor: workflow_dispatch runs the |
| 28 | +# workflow file from the selected ref, so a branch whose copy drops the guard |
| 29 | +# would ignore it. PyPI's trusted publisher pins owner/repo/workflow/environment |
| 30 | +# and carries no branch claim, so the environment policy is what actually binds |
| 31 | +# publishing to main. |
| 32 | + |
| 33 | +on: |
| 34 | + workflow_dispatch: |
| 35 | + inputs: |
| 36 | + target: |
| 37 | + description: "Index to publish to. Rehearse on testpypi first." |
| 38 | + required: true |
| 39 | + type: choice |
| 40 | + options: |
| 41 | + - testpypi |
| 42 | + - pypi |
| 43 | + default: testpypi |
| 44 | + dry-run: |
| 45 | + description: "Build and verify, but do not upload. Leave enabled until you have read the summary." |
| 46 | + required: false |
| 47 | + default: true |
| 48 | + type: boolean |
| 49 | + |
| 50 | +permissions: |
| 51 | + contents: read |
| 52 | + |
| 53 | +concurrency: |
| 54 | + group: publish-${{ inputs.target }} |
| 55 | + cancel-in-progress: false |
| 56 | + |
| 57 | +jobs: |
| 58 | + publish: |
| 59 | + runs-on: ubuntu-latest |
| 60 | + timeout-minutes: 20 |
| 61 | + # Selects the trusted-publisher identity and, via its deployment branch |
| 62 | + # policy, restricts which refs may publish. A dry run still targets the |
| 63 | + # environment so an approval gate is exercised in rehearsal too. |
| 64 | + environment: ${{ inputs.target }} |
| 65 | + permissions: |
| 66 | + contents: read |
| 67 | + id-token: write # OIDC token exchange for trusted publishing |
| 68 | + # Real publishes only from main; dry runs allowed anywhere so a PR branch can |
| 69 | + # verify the artifact without ever reaching the upload step. |
| 70 | + if: github.ref == 'refs/heads/main' || inputs.dry-run |
| 71 | + steps: |
| 72 | + - uses: actions/checkout@v4 |
| 73 | + |
| 74 | + - uses: actions/setup-python@v5 |
| 75 | + with: |
| 76 | + python-version: "3.11" |
| 77 | + |
| 78 | + - uses: astral-sh/setup-uv@v5 |
| 79 | + with: |
| 80 | + enable-cache: true |
| 81 | + |
| 82 | + - run: uv sync --frozen --all-extras |
| 83 | + |
| 84 | + # A broken release is worse than a late one, so re-run the gate here rather |
| 85 | + # than trusting that CI passed on some earlier commit. This is the same |
| 86 | + # script that gates the port sync. |
| 87 | + - name: Verify (lint, types, tests, coverage floor, required API) |
| 88 | + run: ./.upstreamer/scripts/verify.sh |
| 89 | + |
| 90 | + - name: Build sdist and wheel |
| 91 | + run: | |
| 92 | + set -euo pipefail |
| 93 | + rm -rf dist |
| 94 | + uv build --out-dir dist |
| 95 | + ls -l dist |
| 96 | +
|
| 97 | + # Catches the metadata problems PyPI rejects on upload — a malformed |
| 98 | + # long_description is the classic one, and it fails *after* the version is |
| 99 | + # burned if you find out at upload time. |
| 100 | + - name: Check metadata renders for PyPI |
| 101 | + run: uv run --with twine twine check --strict dist/* |
| 102 | + |
| 103 | + # Proves the artifact, not the source tree: installs the built wheel with |
| 104 | + # the repo off sys.path. |
| 105 | + - name: Import the public API from the built wheel |
| 106 | + run: | |
| 107 | + set -euo pipefail |
| 108 | + wheel=$(ls dist/*.whl) |
| 109 | + uv run --isolated --no-project --with "$wheel" python -c " |
| 110 | + from openrouter_agent import call_model, OpenRouter, tool, ModelResult |
| 111 | + import importlib.metadata as md |
| 112 | + print('imported openrouter-agent-sdk', md.version('openrouter-agent-sdk'))" |
| 113 | +
|
| 114 | + # PyPI rejects a re-upload of an existing version with a 400. Failing here |
| 115 | + # instead makes the cause obvious ("you forgot to bump") and keeps the |
| 116 | + # error out of the upload step. |
| 117 | + - name: Confirm this version is not already published |
| 118 | + id: version |
| 119 | + run: | |
| 120 | + set -euo pipefail |
| 121 | + VERSION="$(uv run python -c "import importlib.metadata as m; print(m.version('openrouter-agent-sdk'))")" |
| 122 | + echo "version=$VERSION" >> "$GITHUB_OUTPUT" |
| 123 | + if [ "${{ inputs.target }}" = "pypi" ]; then |
| 124 | + INDEX="https://pypi.org/pypi/openrouter-agent-sdk/json" |
| 125 | + else |
| 126 | + INDEX="https://test.pypi.org/pypi/openrouter-agent-sdk/json" |
| 127 | + fi |
| 128 | + # Collision test done in Python, not by word-splitting a shell string: |
| 129 | + # the shell form is subtly non-portable (zsh does not split unquoted |
| 130 | + # variables the way bash does), and a guard that silently stops |
| 131 | + # matching is worse than no guard — it would wave through the exact |
| 132 | + # re-upload it exists to catch. Exit 2 = already published. |
| 133 | + if curl -fsSL "$INDEX" -o /tmp/index.json 2>/dev/null; then |
| 134 | + python3 - "$VERSION" <<'PY' |
| 135 | + import json, sys |
| 136 | + version = sys.argv[1] |
| 137 | + releases = json.load(open("/tmp/index.json")).get("releases", {}) |
| 138 | + print("already published:", " ".join(sorted(releases)) or "<none>") |
| 139 | + sys.exit(2 if version in releases else 0) |
| 140 | + PY |
| 141 | + status=$? |
| 142 | + if [ "$status" -eq 2 ]; then |
| 143 | + echo "::error::Version $VERSION is already published on ${{ inputs.target }}. A PyPI version can never be reused — bump the version in pyproject.toml." |
| 144 | + exit 1 |
| 145 | + elif [ "$status" -ne 0 ]; then |
| 146 | + echo "::error::Could not determine published versions (exit $status). Refusing to publish blind." |
| 147 | + exit 1 |
| 148 | + fi |
| 149 | + else |
| 150 | + echo "Project not on ${{ inputs.target }} yet — this would be the first release." |
| 151 | + fi |
| 152 | + echo "Version $VERSION is publishable on ${{ inputs.target }}." |
| 153 | +
|
| 154 | + - name: Summary |
| 155 | + run: | |
| 156 | + { |
| 157 | + echo "## Publish ${{ inputs.target }}" |
| 158 | + echo |
| 159 | + echo "- Version: \`${{ steps.version.outputs.version }}\`" |
| 160 | + echo "- Dry run: **${{ inputs.dry-run }}**" |
| 161 | + echo "- Ref: \`${{ github.ref }}\`" |
| 162 | + echo |
| 163 | + if [ "${{ inputs.dry-run }}" = "true" ]; then |
| 164 | + echo "Nothing was uploaded. Artifacts were built and verified only." |
| 165 | + echo "Re-run with dry-run disabled to publish." |
| 166 | + else |
| 167 | + echo "Uploading to ${{ inputs.target }}." |
| 168 | + fi |
| 169 | + echo |
| 170 | + echo '```' |
| 171 | + ls -l dist |
| 172 | + echo '```' |
| 173 | + } >> "$GITHUB_STEP_SUMMARY" |
| 174 | +
|
| 175 | + # Keep the artifacts from a dry run so the exact files that would ship can |
| 176 | + # be downloaded and inspected. |
| 177 | + - uses: actions/upload-artifact@v4 |
| 178 | + with: |
| 179 | + name: dist-${{ inputs.target }}-${{ steps.version.outputs.version }} |
| 180 | + path: dist/ |
| 181 | + |
| 182 | + - name: Publish to TestPyPI |
| 183 | + if: inputs.target == 'testpypi' && inputs.dry-run == false |
| 184 | + uses: pypa/gh-action-pypi-publish@release/v1 |
| 185 | + with: |
| 186 | + repository-url: https://test.pypi.org/legacy/ |
| 187 | + print-hash: true |
| 188 | + |
| 189 | + - name: Publish to PyPI |
| 190 | + if: inputs.target == 'pypi' && inputs.dry-run == false |
| 191 | + uses: pypa/gh-action-pypi-publish@release/v1 |
| 192 | + with: |
| 193 | + print-hash: true |
0 commit comments