A verb for browser_watch (#1238) #10
Workflow file for this run
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 | |
| # Publish `aihawk` from a tag via PyPI Trusted Publishing (OIDC), with no | |
| # long-lived token anywhere. The trust lives in the `pypi` GitHub Environment, | |
| # minted per run and valid for minutes. Only the `upload` job names that | |
| # environment, and it declares `needs: gate`, so a red gate means the only job | |
| # that can authenticate never starts. | |
| # | |
| # BEFORE THE FIRST RUN, on PyPI: the name is unregistered, so Your projects will | |
| # not list it. Use Account -> Publishing -> Add a pending publisher with: | |
| # PyPI Project Name: aihawk | |
| # Owner: feder-cr | |
| # Repository name: AIHawk | |
| # Workflow name: publish.yml | |
| # Environment name: pypi | |
| # Until that exists the upload fails with `invalid-publisher`, which is the | |
| # correct failure: nothing has said this workflow may speak for this project. | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| jobs: | |
| already-published: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| present: ${{ steps.probe.outputs.present }} | |
| version: ${{ steps.probe.outputs.version }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - id: probe | |
| shell: bash | |
| # Asks the INDEX, not the ledger and not the tag: a re-pushed tag or a | |
| # backfill is then a logged no-op rather than a red run, and the only | |
| # answer that stops everything is one that is neither yes nor no. | |
| run: | | |
| VERSION="$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)" | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| CODE=$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/aihawk/$VERSION/json") | |
| case "$CODE" in | |
| 200) echo "present=yes" >> "$GITHUB_OUTPUT" | |
| echo "::notice::aihawk $VERSION is already on the index. No-op, not a failure." ;; | |
| 404) echo "present=no" >> "$GITHUB_OUTPUT" ;; | |
| *) echo "the index answered $CODE for $VERSION, which is neither present nor absent. Refusing rather than guessing." >&2 | |
| exit 1 ;; | |
| esac | |
| gate: | |
| needs: already-published | |
| if: needs.already-published.outputs.present != 'yes' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: the tag names the version being built | |
| if: github.event_name == 'push' | |
| shell: bash | |
| run: | | |
| TAG="${GITHUB_REF_NAME#v}" | |
| HAVE="${{ needs.already-published.outputs.version }}" | |
| if [ "$TAG" != "$HAVE" ]; then | |
| echo "tag v$TAG does not name the version in pyproject.toml ($HAVE)" >&2 | |
| exit 1 | |
| fi | |
| - name: the MCP server floor is SATISFIABLE on the index | |
| # The ordering rule, as a gate instead of a sentence in a runbook, and it | |
| # is here because the rule was broken the day it was written: aihawk was | |
| # committed using `browser_select_option`, a tool that existed only in an | |
| # unpublished server. Locally everything was green - the checkout is an | |
| # editable install and cannot see this class of failure at all - while a | |
| # runner resolving from the index got a server without the tool. | |
| # | |
| # Measured that day, in a venv built to resolve the way CI does: the index | |
| # served 14 tools, the checkout offered 15, and the aihawk suite failed on | |
| # exactly the difference. | |
| # | |
| # A FLOOR, not an exact pin, so the question is not "is this version there" | |
| # but "does the index serve anything that satisfies it". That is a | |
| # different check from the wrapper's, and writing the wrapper's would pass | |
| # on a floor no release can meet. | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # Read the floor from the dependency itself. A number typed here would be | |
| # a second place to update, and the second place is the one that rots. | |
| FLOOR=$(python - <<'PY' | |
| import re, tomllib | |
| with open("pyproject.toml", "rb") as fh: | |
| deps = tomllib.load(fh)["project"]["dependencies"] | |
| # PEP 503: `invisible-playwright-mcp` and `invisible_playwright_mcp` are | |
| # the same project, so both spellings are accepted rather than assumed. | |
| want = [d for d in deps if re.match(r"invisible[-_]playwright[-_]mcp", d)] | |
| if len(want) != 1: | |
| raise SystemExit("expected exactly one MCP dependency, found %r" % want) | |
| m = re.search(r">=\s*([0-9]+(?:\.[0-9]+)*)", want[0]) | |
| if not m: | |
| raise SystemExit("no >= floor on the MCP dependency: %r" % want[0]) | |
| print(m.group(1)) | |
| PY | |
| ) | |
| echo "floor: $FLOOR" | |
| BEST=$(curl -sf "https://pypi.org/pypi/invisible-playwright-mcp/json" \ | |
| | python -c 'import json,sys; print(json.load(sys.stdin)["info"]["version"])') | |
| echo "index serves: $BEST" | |
| python - "$FLOOR" "$BEST" <<'PY' | |
| import sys | |
| def key(v): | |
| return tuple(int(p) for p in v.split(".")) | |
| floor, best = sys.argv[1], sys.argv[2] | |
| if key(best) < key(floor): | |
| sys.exit( | |
| "the index serves invisible-playwright-mcp %s and this release " | |
| "needs >=%s, so pip could not resolve it on any runner and the " | |
| "package would install broken for everyone. Publish the server " | |
| "first, then release this." % (best, floor)) | |
| print("resolvable: %s satisfies >=%s" % (best, floor)) | |
| PY | |
| - name: install and run the suite | |
| # A tag push does not trigger the ordinary test workflow, which runs on | |
| # branches and pull requests, so without this a tag could publish code | |
| # that no job has ever run. | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -e ".[test]" | |
| python -m pytest -q | |
| - name: the wheel installs into an empty environment and answers | |
| # The published artifact is a wheel, and the suite above imports from | |
| # src/ either way. Only a clean install can tell you that the thing | |
| # about to reach users actually starts. | |
| run: | | |
| pip install build | |
| python -m build --wheel | |
| python -m venv /tmp/clean | |
| /tmp/clean/bin/pip install dist/*.whl | |
| /tmp/clean/bin/aihawk --help | |
| upload: | |
| needs: [already-published, gate] | |
| if: needs.already-published.outputs.present != 'yes' | |
| runs-on: ubuntu-latest | |
| environment: pypi # the OIDC trust relationship lives HERE, behind the gate | |
| permissions: | |
| id-token: write # trusted publishing; no token in a secret anywhere | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - run: python -m pip install --upgrade pip build | |
| - run: python -m build | |
| - name: twine check | |
| run: | | |
| pip install twine | |
| twine check dist/* | |
| - uses: pypa/gh-action-pypi-publish@release/v1 |