Skip to content
Latchkey

Publish PyPI workflow (browserbase/stagehand-python)

The Publish PyPI workflow from browserbase/stagehand-python, explained and optimized by Latchkey.

B

CI health: B - good

Point runs-on at Latchkey and get job timeouts, SHA-pinned actions, self-healing for flaky steps, and up to 58% lower cost, applied automatically.

Grade your own workflow free or run it on Latchkey →
Source: browserbase/stagehand-python.github/workflows/publish-pypi.ymlLicense MITView source

What it does

This is the Publish PyPI workflow from the browserbase/stagehand-python repository, a real project running GitHub Actions. It is shown here with attribution under its MIT license.

Below, Latchkey shows a faster, safer version produced by its optimization engine.

The workflow

workflow (.yml)
# This workflow is triggered when a GitHub release is created.
# It can also be run manually to re-publish to PyPI in case it failed for some reason.
name: Publish PyPI

on:
  workflow_dispatch:
    inputs: {}

  release:
    types: [published]

jobs:
  build_wheels:
    name: build wheels (${{ matrix.binary_name }})
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            binary_name: stagehand-server-v3-linux-x64
            output_path: src/stagehand/_sea/stagehand-linux-x64
            wheel_platform_tag: manylinux2014_x86_64
          - os: macos-latest
            binary_name: stagehand-server-v3-darwin-arm64
            output_path: src/stagehand/_sea/stagehand-darwin-arm64
            wheel_platform_tag: macosx_11_0_arm64
          - os: macos-15-intel
            binary_name: stagehand-server-v3-darwin-x64
            output_path: src/stagehand/_sea/stagehand-darwin-x64
            wheel_platform_tag: macosx_10_9_x86_64
          - os: windows-latest
            binary_name: stagehand-server-v3-win32-x64.exe
            output_path: src/stagehand/_sea/stagehand-win32-x64.exe
            wheel_platform_tag: win_amd64

    runs-on: ${{ matrix.os }}
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Install uv
        uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
        with:
          version: "0.9.13"

      - name: Resolve latest stagehand/server-v3 release
        id: stagehand-server-release
        uses: actions/github-script@v6
        with:
          github-token: ${{ secrets.STAGEHAND_SOURCE_TOKEN || github.token }}
          script: |
            const { data } = await github.rest.repos.listReleases({
              owner: 'browserbase',
              repo: 'stagehand',
              per_page: 100,
            });
            const parseStableTag = (tag) => {
              if (typeof tag !== 'string') return null;
              const match = /^stagehand-server-v3\/v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
              if (!match) return null;
              return match.slice(1).map(Number);
            };

            let best = null;
            for (const release of data) {
              if (release.draft || release.prerelease) continue;
              const version = parseStableTag(release.tag_name);
              if (!version) continue;
              if (!best) {
                best = { release, version };
                continue;
              }

              const isGreater =
                version[0] > best.version[0] ||
                (version[0] === best.version[0] && version[1] > best.version[1]) ||
                (version[0] === best.version[0] && version[1] === best.version[1] && version[2] > best.version[2]);

              if (isGreater) {
                best = { release, version };
              }
            }

            if (!best) {
              core.setFailed('No stable stagehand-server-v3/vX.Y.Z release found in browserbase/stagehand');
              return;
            }
            core.info(`Using stagehand/server-v3 release tag: ${best.release.tag_name}`);
            core.setOutput('tag', best.release.tag_name);
            core.setOutput('id', String(best.release.id));

      - name: Download stagehand/server SEA binary (from GitHub Release assets)
        env:
          GH_TOKEN: ${{ secrets.STAGEHAND_SOURCE_TOKEN || github.token }}
          RELEASE_TAG: ${{ steps.stagehand-server-release.outputs.tag }}
          RELEASE_ID: ${{ steps.stagehand-server-release.outputs.id }}
          ASSET_NAME: ${{ matrix.binary_name }}
          OUTPUT_PATH: ${{ matrix.output_path }}
        shell: bash
        run: |
          set -euo pipefail

          # Ensure we only ship the binary for this runner's OS/arch.
          python - <<'PY'
          from pathlib import Path
          sea_dir = Path("src/stagehand/_sea")
          sea_dir.mkdir(parents=True, exist_ok=True)
          for p in sea_dir.glob("stagehand-*"):
              p.unlink(missing_ok=True)
          for p in sea_dir.glob("*.exe"):
              p.unlink(missing_ok=True)
          PY

          echo "Downloading ${ASSET_NAME} from browserbase/stagehand@${RELEASE_TAG}"

          url="$(
            curl -fsSL \
              -H "Authorization: Bearer ${GH_TOKEN}" \
              -H "Accept: application/vnd.github+json" \
              "https://api.github.com/repos/browserbase/stagehand/releases/${RELEASE_ID}" \
              | python -c 'import json,sys; d=json.load(sys.stdin); a=next((x for x in d.get("assets",[]) if x.get("name")==sys.argv[1]), None); print(a.get("url","") if a else "")' \
              "${ASSET_NAME}"
          )"

          if [ -z "${url}" ]; then
            echo "Release asset not found: ${ASSET_NAME} (tag=${RELEASE_TAG})" >&2
            exit 1
          fi

          mkdir -p "$(dirname "${OUTPUT_PATH}")"
          curl -fsSL \
            -H "Authorization: Bearer ${GH_TOKEN}" \
            -H "Accept: application/octet-stream" \
            "${url}" \
            -o "${OUTPUT_PATH}"

          chmod +x "${OUTPUT_PATH}" 2>/dev/null || true
          rm -f src/stagehand/_sea/.keep || true

      - name: Build wheel
        env:
          STAGEHAND_WHEEL_TAG: py3-none-${{ matrix.wheel_platform_tag }}
        run: uv build --wheel

      - name: Log SEA contents
        shell: bash
        run: |
          echo "Contents of src/stagehand/_sea/"
          ls -al src/stagehand/_sea || true
          python - <<'PY'
          import pathlib, zipfile, collections, sys
          had_error = False
          for wheel in sorted(pathlib.Path("dist").glob("*.whl")):
              print(f"Contents of {wheel.name} entries matching stagehand/_sea")
              with zipfile.ZipFile(wheel, "r") as zf:
                  names = [info.filename for info in zf.infolist()]
                  counts = collections.Counter(names)
                  dups = sorted([name for name, n in counts.items() if n > 1])
                  for info in zf.infolist():
                      if "stagehand/_sea/" in info.filename:
                          print(info.filename)
                  if dups:
                      had_error = True
                      print("ERROR: duplicate zip entries detected:")
                      for name in dups:
                          print(f"  {counts[name]}x {name}")
          if had_error:
              sys.exit(1)
          PY

      - name: Upload wheel artifact
        uses: actions/upload-artifact@v4
        with:
          name: wheel-${{ matrix.binary_name }}
          path: dist/*.whl
          retention-days: 7

  build_sdist:
    name: build sdist
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v5
        with:
          version: "0.9.13"

      - name: Build sdist
        run: uv build --sdist

      - name: Upload sdist artifact
        uses: actions/upload-artifact@v4
        with:
          name: sdist
          path: dist/*.tar.gz
          retention-days: 7

  publish:
    name: publish
    needs: [build_wheels, build_sdist]
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v5
        with:
          version: "0.9.13"

      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          path: dist

      - name: Flatten dist directory
        shell: bash
        run: |
          set -euo pipefail
          mkdir -p dist_out
          find dist -type f \( -name "*.whl" -o -name "*.tar.gz" \) -print0 | while IFS= read -r -d '' f; do
            cp -f "$f" dist_out/
          done
          ls -la dist_out

      - name: Publish to PyPI
        env:
          PYPI_TOKEN: ${{ secrets.STAGEHAND_PYPI_TOKEN || secrets.PYPI_TOKEN }}
        run: |
          set -euo pipefail
          uv publish --token="$PYPI_TOKEN" dist_out/*

The same workflow, on Latchkey

Removes redundant runs and caps runaway jobs. Added and changed lines are highlighted.

# This workflow is triggered when a GitHub release is created.
# It can also be run manually to re-publish to PyPI in case it failed for some reason.
name: Publish PyPI
 
on:
  workflow_dispatch:
    inputs: {}
 
  release:
    types: [published]
 
jobs:
  build_wheels:
    timeout-minutes: 30
    name: build wheels (${{ matrix.binary_name }})
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            binary_name: stagehand-server-v3-linux-x64
            output_path: src/stagehand/_sea/stagehand-linux-x64
            wheel_platform_tag: manylinux2014_x86_64
          - os: macos-latest
            binary_name: stagehand-server-v3-darwin-arm64
            output_path: src/stagehand/_sea/stagehand-darwin-arm64
            wheel_platform_tag: macosx_11_0_arm64
          - os: macos-15-intel
            binary_name: stagehand-server-v3-darwin-x64
            output_path: src/stagehand/_sea/stagehand-darwin-x64
            wheel_platform_tag: macosx_10_9_x86_64
          - os: windows-latest
            binary_name: stagehand-server-v3-win32-x64.exe
            output_path: src/stagehand/_sea/stagehand-win32-x64.exe
            wheel_platform_tag: win_amd64
 
    runs-on: ${{ matrix.os }}
    permissions:
      contents: read
 
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
 
      - name: Install uv
        uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
        with:
          version: "0.9.13"
 
      - name: Resolve latest stagehand/server-v3 release
        id: stagehand-server-release
        uses: actions/github-script@v6
        with:
          github-token: ${{ secrets.STAGEHAND_SOURCE_TOKEN || github.token }}
          script: |
            const { data } = await github.rest.repos.listReleases({
              owner: 'browserbase',
              repo: 'stagehand',
              per_page: 100,
            });
            const parseStableTag = (tag) => {
              if (typeof tag !== 'string') return null;
              const match = /^stagehand-server-v3\/v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
              if (!match) return null;
              return match.slice(1).map(Number);
            };
 
            let best = null;
            for (const release of data) {
              if (release.draft || release.prerelease) continue;
              const version = parseStableTag(release.tag_name);
              if (!version) continue;
              if (!best) {
                best = { release, version };
                continue;
              }
 
              const isGreater =
                version[0] > best.version[0] ||
                (version[0] === best.version[0] && version[1] > best.version[1]) ||
                (version[0] === best.version[0] && version[1] === best.version[1] && version[2] > best.version[2]);
 
              if (isGreater) {
                best = { release, version };
              }
            }
 
            if (!best) {
              core.setFailed('No stable stagehand-server-v3/vX.Y.Z release found in browserbase/stagehand');
              return;
            }
            core.info(`Using stagehand/server-v3 release tag: ${best.release.tag_name}`);
            core.setOutput('tag', best.release.tag_name);
            core.setOutput('id', String(best.release.id));
 
      - name: Download stagehand/server SEA binary (from GitHub Release assets)
        env:
          GH_TOKEN: ${{ secrets.STAGEHAND_SOURCE_TOKEN || github.token }}
          RELEASE_TAG: ${{ steps.stagehand-server-release.outputs.tag }}
          RELEASE_ID: ${{ steps.stagehand-server-release.outputs.id }}
          ASSET_NAME: ${{ matrix.binary_name }}
          OUTPUT_PATH: ${{ matrix.output_path }}
        shell: bash
        run: |
          set -euo pipefail
 
          # Ensure we only ship the binary for this runner's OS/arch.
          python - <<'PY'
          from pathlib import Path
          sea_dir = Path("src/stagehand/_sea")
          sea_dir.mkdir(parents=True, exist_ok=True)
          for p in sea_dir.glob("stagehand-*"):
              p.unlink(missing_ok=True)
          for p in sea_dir.glob("*.exe"):
              p.unlink(missing_ok=True)
          PY
 
          echo "Downloading ${ASSET_NAME} from browserbase/stagehand@${RELEASE_TAG}"
 
          url="$(
            curl -fsSL \
              -H "Authorization: Bearer ${GH_TOKEN}" \
              -H "Accept: application/vnd.github+json" \
              "https://api.github.com/repos/browserbase/stagehand/releases/${RELEASE_ID}" \
              | python -c 'import json,sys; d=json.load(sys.stdin); a=next((x for x in d.get("assets",[]) if x.get("name")==sys.argv[1]), None); print(a.get("url","") if a else "")' \
              "${ASSET_NAME}"
          )"
 
          if [ -z "${url}" ]; then
            echo "Release asset not found: ${ASSET_NAME} (tag=${RELEASE_TAG})" >&2
            exit 1
          fi
 
          mkdir -p "$(dirname "${OUTPUT_PATH}")"
          curl -fsSL \
            -H "Authorization: Bearer ${GH_TOKEN}" \
            -H "Accept: application/octet-stream" \
            "${url}" \
            -o "${OUTPUT_PATH}"
 
          chmod +x "${OUTPUT_PATH}" 2>/dev/null || true
          rm -f src/stagehand/_sea/.keep || true
 
      - name: Build wheel
        env:
          STAGEHAND_WHEEL_TAG: py3-none-${{ matrix.wheel_platform_tag }}
        run: uv build --wheel
 
      - name: Log SEA contents
        shell: bash
        run: |
          echo "Contents of src/stagehand/_sea/"
          ls -al src/stagehand/_sea || true
          python - <<'PY'
          import pathlib, zipfile, collections, sys
          had_error = False
          for wheel in sorted(pathlib.Path("dist").glob("*.whl")):
              print(f"Contents of {wheel.name} entries matching stagehand/_sea")
              with zipfile.ZipFile(wheel, "r") as zf:
                  names = [info.filename for info in zf.infolist()]
                  counts = collections.Counter(names)
                  dups = sorted([name for name, n in counts.items() if n > 1])
                  for info in zf.infolist():
                      if "stagehand/_sea/" in info.filename:
                          print(info.filename)
                  if dups:
                      had_error = True
                      print("ERROR: duplicate zip entries detected:")
                      for name in dups:
                          print(f"  {counts[name]}x {name}")
          if had_error:
              sys.exit(1)
          PY
 
      - name: Upload wheel artifact
        uses: actions/upload-artifact@v4
        with:
          name: wheel-${{ matrix.binary_name }}
          path: dist/*.whl
          retention-days: 7
 
  build_sdist:
    timeout-minutes: 30
    name: build sdist
    runs-on: latchkey-small
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
 
      - name: Install uv
        uses: astral-sh/setup-uv@v5
        with:
          version: "0.9.13"
 
      - name: Build sdist
        run: uv build --sdist
 
      - name: Upload sdist artifact
        uses: actions/upload-artifact@v4
        with:
          name: sdist
          path: dist/*.tar.gz
          retention-days: 7
 
  publish:
    timeout-minutes: 30
    name: publish
    needs: [build_wheels, build_sdist]
    runs-on: latchkey-small
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
 
      - name: Install uv
        uses: astral-sh/setup-uv@v5
        with:
          version: "0.9.13"
 
      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          path: dist
 
      - name: Flatten dist directory
        shell: bash
        run: |
          set -euo pipefail
          mkdir -p dist_out
          find dist -type f \( -name "*.whl" -o -name "*.tar.gz" \) -print0 | while IFS= read -r -d '' f; do
            cp -f "$f" dist_out/
          done
          ls -la dist_out
 
      - name: Publish to PyPI
        env:
          PYPI_TOKEN: ${{ secrets.STAGEHAND_PYPI_TOKEN || secrets.PYPI_TOKEN }}
        run: |
          set -euo pipefail
          uv publish --token="$PYPI_TOKEN" dist_out/*
 

What changed

1 third-party action is referenced by a movable tag. Pin it to the commit SHA (Latchkey resolves and applies this automatically) so a repointed tag cannot change what runs.

What Latchkey heals here

This workflow has steps that commonly fail on transient issues (network, registries, flaky browsers). On Latchkey managed runners they are detected, retried, and self-healed instead of failing your build:

This workflow runs 3 jobs per trigger. On Latchkey the same minutes cost up to 58% less than GitHub-hosted, with zero queue time.

Actions used in this workflow