Publish #1
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
| name: Publish | |
| # Publishes `openrouter-agent-sdk` to PyPI using trusted publishing (OIDC) — no | |
| # long-lived API token is stored in this repo. | |
| # | |
| # Publishing is irreversible: a version number can never be reused on PyPI, even | |
| # after a yank. So this workflow is manual-only, defaults to a dry run, and | |
| # refuses to publish a version that already exists on the index. | |
| # | |
| # Release procedure: | |
| # 1. Land the version bump (pyproject.toml `version`). For a port sync that is | |
| # done by scripts/upstream; otherwise edit it in a PR. | |
| # 2. Run this workflow with target=testpypi to rehearse (optional but cheap). | |
| # 3. Run with target=pypi, dry-run=true, and read the summary. | |
| # 4. Run with target=pypi, dry-run=false to release. | |
| # | |
| # One-time setup on PyPI, before the first real publish — the workflow cannot do | |
| # this for you: | |
| # PyPI → the project (or "pending publisher" if it does not exist yet) → | |
| # Publishing → add a GitHub trusted publisher with | |
| # owner: OpenRouterTeam repo: python-agent | |
| # workflow: publish.yaml environment: pypi | |
| # Then create the `pypi` (and `testpypi`) environment in repo Settings, and set | |
| # its deployment branch policy to `main`. | |
| # | |
| # That environment branch policy is the real ref restriction. The `if:` guard | |
| # below stops accidents, not a determined actor: workflow_dispatch runs the | |
| # workflow file from the selected ref, so a branch whose copy drops the guard | |
| # would ignore it. PyPI's trusted publisher pins owner/repo/workflow/environment | |
| # and carries no branch claim, so the environment policy is what actually binds | |
| # publishing to main. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| target: | |
| description: "Index to publish to. Rehearse on testpypi first." | |
| required: true | |
| type: choice | |
| options: | |
| - testpypi | |
| - pypi | |
| default: testpypi | |
| dry-run: | |
| description: "Build and verify, but do not upload. Leave enabled until you have read the summary." | |
| required: false | |
| default: true | |
| type: boolean | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: publish-${{ inputs.target }} | |
| cancel-in-progress: false | |
| jobs: | |
| publish: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| # Selects the trusted-publisher identity and, via its deployment branch | |
| # policy, restricts which refs may publish. A dry run still targets the | |
| # environment so an approval gate is exercised in rehearsal too. | |
| environment: ${{ inputs.target }} | |
| permissions: | |
| contents: read | |
| id-token: write # OIDC token exchange for trusted publishing | |
| # Real publishes only from main; dry runs allowed anywhere so a PR branch can | |
| # verify the artifact without ever reaching the upload step. | |
| if: github.ref == 'refs/heads/main' || inputs.dry-run | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - uses: astral-sh/setup-uv@v5 | |
| with: | |
| enable-cache: true | |
| - run: uv sync --frozen --all-extras | |
| # A broken release is worse than a late one, so re-run the gate here rather | |
| # than trusting that CI passed on some earlier commit. This is the same | |
| # script that gates the port sync. | |
| - name: Verify (lint, types, tests, coverage floor, required API) | |
| run: ./.upstreamer/scripts/verify.sh | |
| # The port tracks upstream HEAD, so it is routinely AHEAD of the release | |
| # whose version number pyproject.toml carries. Publishing from that state | |
| # would ship unreleased upstream work as a released version — permanently, | |
| # since a PyPI version can never be reused. | |
| # | |
| # verify.sh reports this, but only when a sync run has left an upstream | |
| # checkout in tmp/. There is none here, so check it explicitly against the | |
| # public repo rather than letting the guard be silent at the one moment it | |
| # matters most. | |
| - name: Refuse to publish a version the port is ahead of | |
| run: | | |
| set -euo pipefail | |
| VERSION="$(grep -m1 '^version' pyproject.toml | sed 's/.*"\(.*\)".*/\1/')" | |
| PORTED="$(grep -m1 '^upstream_commit:' .upstreamer/state.yaml | awk '{print $2}')" | |
| echo "declared version: $VERSION" | |
| echo "ported commit: $PORTED" | |
| rm -rf /tmp/upstream-check | |
| git clone -q --filter=blob:none --no-checkout \ | |
| https://github.com/OpenRouterTeam/typescript-agent.git /tmp/upstream-check | |
| git -C /tmp/upstream-check fetch -q --tags origin | |
| TAG="@openrouter/agent@${VERSION}" | |
| if ! git -C /tmp/upstream-check rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then | |
| echo "::error::Upstream has no release tag $TAG. This package's version tracks the ported @openrouter/agent version, so publishing $VERSION means upstream released it. Wait for the upstream release, or correct the version." | |
| exit 1 | |
| fi | |
| AHEAD="$(git -C /tmp/upstream-check rev-list --count "refs/tags/$TAG..$PORTED" 2>/dev/null || echo 0)" | |
| if [ "${AHEAD:-0}" -gt 0 ]; then | |
| echo "::error::The ported commit is $AHEAD commit(s) ahead of the $TAG release tag. Publishing $VERSION now would ship unreleased upstream work under a released version number, and a PyPI version can never be reused. Publish from a commit level with a release tag, or wait for upstream to release what the port has reached." | |
| exit 1 | |
| fi | |
| echo "Ported tree is level with $TAG — $VERSION is honest to publish." | |
| - name: Build sdist and wheel | |
| run: | | |
| set -euo pipefail | |
| rm -rf dist | |
| uv build --out-dir dist | |
| ls -l dist | |
| # Catches the metadata problems PyPI rejects on upload — a malformed | |
| # long_description is the classic one, and it fails *after* the version is | |
| # burned if you find out at upload time. | |
| - name: Check metadata renders for PyPI | |
| run: uv run --with twine twine check --strict dist/* | |
| # Proves the artifact, not the source tree: installs the built wheel with | |
| # the repo off sys.path. | |
| - name: Import the public API from the built wheel | |
| run: | | |
| set -euo pipefail | |
| wheel=$(ls dist/*.whl) | |
| uv run --isolated --no-project --with "$wheel" python -c " | |
| from openrouter_agent import call_model, OpenRouter, tool, ModelResult | |
| import importlib.metadata as md | |
| print('imported openrouter-agent-sdk', md.version('openrouter-agent-sdk'))" | |
| # PyPI rejects a re-upload of an existing version with a 400. Failing here | |
| # instead makes the cause obvious ("you forgot to bump") and keeps the | |
| # error out of the upload step. | |
| - name: Confirm this version is not already published | |
| id: version | |
| run: | | |
| set -euo pipefail | |
| VERSION="$(uv run python -c "import importlib.metadata as m; print(m.version('openrouter-agent-sdk'))")" | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| if [ "${{ inputs.target }}" = "pypi" ]; then | |
| INDEX="https://pypi.org/pypi/openrouter-agent-sdk/json" | |
| else | |
| INDEX="https://test.pypi.org/pypi/openrouter-agent-sdk/json" | |
| fi | |
| # Collision test done in Python, not by word-splitting a shell string: | |
| # the shell form is subtly non-portable (zsh does not split unquoted | |
| # variables the way bash does), and a guard that silently stops | |
| # matching is worse than no guard — it would wave through the exact | |
| # re-upload it exists to catch. Exit 2 = already published. | |
| if curl -fsSL "$INDEX" -o /tmp/index.json 2>/dev/null; then | |
| python3 - "$VERSION" <<'PY' | |
| import json, sys | |
| version = sys.argv[1] | |
| releases = json.load(open("/tmp/index.json")).get("releases", {}) | |
| print("already published:", " ".join(sorted(releases)) or "<none>") | |
| sys.exit(2 if version in releases else 0) | |
| PY | |
| status=$? | |
| if [ "$status" -eq 2 ]; then | |
| echo "::error::Version $VERSION is already published on ${{ inputs.target }}. A PyPI version can never be reused — bump the version in pyproject.toml." | |
| exit 1 | |
| elif [ "$status" -ne 0 ]; then | |
| echo "::error::Could not determine published versions (exit $status). Refusing to publish blind." | |
| exit 1 | |
| fi | |
| else | |
| echo "Project not on ${{ inputs.target }} yet — this would be the first release." | |
| fi | |
| echo "Version $VERSION is publishable on ${{ inputs.target }}." | |
| - name: Summary | |
| run: | | |
| { | |
| echo "## Publish ${{ inputs.target }}" | |
| echo | |
| echo "- Version: \`${{ steps.version.outputs.version }}\`" | |
| echo "- Dry run: **${{ inputs.dry-run }}**" | |
| echo "- Ref: \`${{ github.ref }}\`" | |
| echo | |
| if [ "${{ inputs.dry-run }}" = "true" ]; then | |
| echo "Nothing was uploaded. Artifacts were built and verified only." | |
| echo "Re-run with dry-run disabled to publish." | |
| else | |
| echo "Uploading to ${{ inputs.target }}." | |
| fi | |
| echo | |
| echo '```' | |
| ls -l dist | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # Keep the artifacts from a dry run so the exact files that would ship can | |
| # be downloaded and inspected. | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: dist-${{ inputs.target }}-${{ steps.version.outputs.version }} | |
| path: dist/ | |
| - name: Publish to TestPyPI | |
| if: inputs.target == 'testpypi' && inputs.dry-run == false | |
| uses: pypa/gh-action-pypi-publish@release/v1 | |
| with: | |
| repository-url: https://test.pypi.org/legacy/ | |
| print-hash: true | |
| - name: Publish to PyPI | |
| if: inputs.target == 'pypi' && inputs.dry-run == false | |
| uses: pypa/gh-action-pypi-publish@release/v1 | |
| with: | |
| print-hash: true |