Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,40 @@ jobs:
repo: 'stagehand',
per_page: 100,
});
const release = data.find(r => typeof r.tag_name === 'string' && r.tag_name.startsWith('stagehand-server-v3/v'));
if (!release) {
core.setFailed('No stagehand-server-v3/v* release found in browserbase/stagehand');
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: ${release.tag_name}`);
core.setOutput('tag', release.tag_name);
core.setOutput('id', String(release.id));
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:
Expand Down
19 changes: 16 additions & 3 deletions scripts/download-binary.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ def _parse_server_tag(tag: str) -> tuple[int, int, int] | None:
return None

ver = tag.removeprefix("stagehand-server-v3/v")
# Drop any pre-release/build metadata (we only expect stable tags here).
ver = ver.split("-", 1)[0].split("+", 1)[0]
# Only accept stable tags. Pre-release/build tags like "-dev" should not
# be used for local binary downloads or release packaging.
if "-" in ver or "+" in ver:
return None
parts = ver.split(".")
if len(parts) != 3:
return None
Expand Down Expand Up @@ -118,6 +120,17 @@ def resolve_latest_server_tag() -> str:
return best[1]


def normalize_server_tag(version: str) -> str:
"""Normalize explicit CLI input to a stable stagehand-server-v3 tag."""
tag = version if version.startswith("stagehand-server-v3/v") else f"stagehand-server-v3/{version}"
if _parse_server_tag(tag) is None:
raise ValueError(
"Invalid stagehand server version. Expected a stable tag like "
"'v3.2.0' or 'stagehand-server-v3/v3.2.0'."
)
return tag


def download_binary(version: str) -> None:
"""Download the binary for the current platform."""
plat, arch = get_platform_info()
Expand All @@ -126,7 +139,7 @@ def download_binary(version: str) -> None:

# GitHub release URL
repo = "browserbase/stagehand"
tag = version if version.startswith("stagehand-server-v3/v") else f"stagehand-server-v3/{version}"
tag = normalize_server_tag(version)
url = f"https://github.com/{repo}/releases/download/{tag}/{binary_filename}"

# Destination path
Expand Down
4 changes: 3 additions & 1 deletion src/stagehand/lib/sea_binary.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from pathlib import Path
from contextlib import suppress

from .._version import __version__


def _platform_tag() -> tuple[str, str]:
plat = "win32" if sys.platform.startswith("win") else ("darwin" if sys.platform == "darwin" else "linux")
Expand Down Expand Up @@ -99,7 +101,7 @@ def resolve_binary_path(
if resource_path is not None:
# Best-effort versioning to keep cached binaries stable across upgrades.
if version is None:
version = os.environ.get("STAGEHAND_VERSION", "dev")
version = os.environ.get("STAGEHAND_VERSION") or __version__
return _copy_to_cache(src=resource_path, filename=filename, version=version)

# Fallback: source checkout layout (works for local dev in-repo).
Expand Down
85 changes: 85 additions & 0 deletions tests/test_sea_binary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from __future__ import annotations

import importlib.util
from pathlib import Path

import pytest

from stagehand.lib import sea_binary
from stagehand._version import __version__


def _load_download_binary_module():
script_path = Path(__file__).resolve().parents[1] / "scripts" / "download-binary.py"
spec = importlib.util.spec_from_file_location("download_binary_script", script_path)
assert spec is not None
assert spec.loader is not None

module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


download_binary = _load_download_binary_module()


def test_resolve_binary_path_defaults_cache_version_to_package_version(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
resource_path = tmp_path / "stagehand-test"
resource_path.write_bytes(b"binary")

captured: dict[str, object] = {}

monkeypatch.delenv("STAGEHAND_VERSION", raising=False)
def _fake_resource_binary_path(_filename: str) -> Path:
return resource_path

monkeypatch.setattr(sea_binary, "_resource_binary_path", _fake_resource_binary_path)

def _fake_copy_to_cache(*, src: Path, filename: str, version: str) -> Path:
captured["src"] = src
captured["filename"] = filename
captured["version"] = version
return tmp_path / "cache" / filename

monkeypatch.setattr(sea_binary, "_copy_to_cache", _fake_copy_to_cache)

resolved = sea_binary.resolve_binary_path()

assert resolved == tmp_path / "cache" / sea_binary.default_binary_filename()
assert captured["src"] == resource_path
assert captured["filename"] == sea_binary.default_binary_filename()
assert captured["version"] == __version__


def test_parse_server_tag_rejects_prerelease_tags() -> None:
assert download_binary._parse_server_tag("stagehand-server-v3/v3.20.0-dev") is None
assert download_binary._parse_server_tag("stagehand-server-v3/v3.20.0+build.1") is None


def test_normalize_server_tag_rejects_prerelease_input() -> None:
try:
download_binary.normalize_server_tag("v3.20.0-dev")
except ValueError as exc:
assert "stable tag" in str(exc)
else:
raise AssertionError("Expected prerelease version input to be rejected")


def test_resolve_latest_server_tag_ignores_dev_releases(
monkeypatch: pytest.MonkeyPatch,
) -> None:
releases = [
{"tag_name": "stagehand-server-v3/v3.20.0-dev"},
{"tag_name": "stagehand-server-v3/v3.19.1"},
{"tag_name": "stagehand-server-v3/v3.19.0"},
]

def _fake_http_get_json(_url: str) -> list[dict[str, str]]:
return releases

monkeypatch.setattr(download_binary, "_http_get_json", _fake_http_get_json)

assert download_binary.resolve_latest_server_tag() == "stagehand-server-v3/v3.19.1"
Loading
Loading