Publish PyPI workflow (browserbase/stagehand-python)
The Publish PyPI workflow from browserbase/stagehand-python, explained and optimized by Latchkey.
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.
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
# 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
- Run on Latchkey managed runners with one line (
runs-on), which apply the fixes below automatically and self-heal transient failures. This example useslatchkey-small; pick the runner size that fits the job. - Add a job timeout so a hung step cannot burn hours of runner time.
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:
- Network fetches
This workflow runs 3 jobs per trigger. On Latchkey the same minutes cost up to 58% less than GitHub-hosted, with zero queue time.