Skip to content

Commit e22ed1b

Browse files
piratemonadoid
andauthored
[fix] dev tag for sea binary should never be used (#326)
* fix dev tag for sea binary should never be used * fix: reject prerelease binary versions --------- Co-authored-by: monadoid <sam.finton@gmail.com>
1 parent 763131a commit e22ed1b

File tree

5 files changed

+718
-377
lines changed

5 files changed

+718
-377
lines changed

.github/workflows/publish-pypi.yml

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,40 @@ jobs:
5656
repo: 'stagehand',
5757
per_page: 100,
5858
});
59-
const release = data.find(r => typeof r.tag_name === 'string' && r.tag_name.startsWith('stagehand-server-v3/v'));
60-
if (!release) {
61-
core.setFailed('No stagehand-server-v3/v* release found in browserbase/stagehand');
59+
const parseStableTag = (tag) => {
60+
if (typeof tag !== 'string') return null;
61+
const match = /^stagehand-server-v3\/v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
62+
if (!match) return null;
63+
return match.slice(1).map(Number);
64+
};
65+
66+
let best = null;
67+
for (const release of data) {
68+
if (release.draft || release.prerelease) continue;
69+
const version = parseStableTag(release.tag_name);
70+
if (!version) continue;
71+
if (!best) {
72+
best = { release, version };
73+
continue;
74+
}
75+
76+
const isGreater =
77+
version[0] > best.version[0] ||
78+
(version[0] === best.version[0] && version[1] > best.version[1]) ||
79+
(version[0] === best.version[0] && version[1] === best.version[1] && version[2] > best.version[2]);
80+
81+
if (isGreater) {
82+
best = { release, version };
83+
}
84+
}
85+
86+
if (!best) {
87+
core.setFailed('No stable stagehand-server-v3/vX.Y.Z release found in browserbase/stagehand');
6288
return;
6389
}
64-
core.info(`Using stagehand/server-v3 release tag: ${release.tag_name}`);
65-
core.setOutput('tag', release.tag_name);
66-
core.setOutput('id', String(release.id));
90+
core.info(`Using stagehand/server-v3 release tag: ${best.release.tag_name}`);
91+
core.setOutput('tag', best.release.tag_name);
92+
core.setOutput('id', String(best.release.id));
6793
6894
- name: Download stagehand/server SEA binary (from GitHub Release assets)
6995
env:

scripts/download-binary.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,10 @@ def _parse_server_tag(tag: str) -> tuple[int, int, int] | None:
5858
return None
5959

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

120122

123+
def normalize_server_tag(version: str) -> str:
124+
"""Normalize explicit CLI input to a stable stagehand-server-v3 tag."""
125+
tag = version if version.startswith("stagehand-server-v3/v") else f"stagehand-server-v3/{version}"
126+
if _parse_server_tag(tag) is None:
127+
raise ValueError(
128+
"Invalid stagehand server version. Expected a stable tag like "
129+
"'v3.2.0' or 'stagehand-server-v3/v3.2.0'."
130+
)
131+
return tag
132+
133+
121134
def download_binary(version: str) -> None:
122135
"""Download the binary for the current platform."""
123136
plat, arch = get_platform_info()
@@ -126,7 +139,7 @@ def download_binary(version: str) -> None:
126139

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

132145
# Destination path

src/stagehand/lib/sea_binary.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from pathlib import Path
99
from contextlib import suppress
1010

11+
from .._version import __version__
12+
1113

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

105107
# Fallback: source checkout layout (works for local dev in-repo).

tests/test_sea_binary.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from __future__ import annotations
2+
3+
import importlib.util
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from stagehand.lib import sea_binary
9+
from stagehand._version import __version__
10+
11+
12+
def _load_download_binary_module():
13+
script_path = Path(__file__).resolve().parents[1] / "scripts" / "download-binary.py"
14+
spec = importlib.util.spec_from_file_location("download_binary_script", script_path)
15+
assert spec is not None
16+
assert spec.loader is not None
17+
18+
module = importlib.util.module_from_spec(spec)
19+
spec.loader.exec_module(module)
20+
return module
21+
22+
23+
download_binary = _load_download_binary_module()
24+
25+
26+
def test_resolve_binary_path_defaults_cache_version_to_package_version(
27+
monkeypatch: pytest.MonkeyPatch,
28+
tmp_path: Path,
29+
) -> None:
30+
resource_path = tmp_path / "stagehand-test"
31+
resource_path.write_bytes(b"binary")
32+
33+
captured: dict[str, object] = {}
34+
35+
monkeypatch.delenv("STAGEHAND_VERSION", raising=False)
36+
def _fake_resource_binary_path(_filename: str) -> Path:
37+
return resource_path
38+
39+
monkeypatch.setattr(sea_binary, "_resource_binary_path", _fake_resource_binary_path)
40+
41+
def _fake_copy_to_cache(*, src: Path, filename: str, version: str) -> Path:
42+
captured["src"] = src
43+
captured["filename"] = filename
44+
captured["version"] = version
45+
return tmp_path / "cache" / filename
46+
47+
monkeypatch.setattr(sea_binary, "_copy_to_cache", _fake_copy_to_cache)
48+
49+
resolved = sea_binary.resolve_binary_path()
50+
51+
assert resolved == tmp_path / "cache" / sea_binary.default_binary_filename()
52+
assert captured["src"] == resource_path
53+
assert captured["filename"] == sea_binary.default_binary_filename()
54+
assert captured["version"] == __version__
55+
56+
57+
def test_parse_server_tag_rejects_prerelease_tags() -> None:
58+
assert download_binary._parse_server_tag("stagehand-server-v3/v3.20.0-dev") is None
59+
assert download_binary._parse_server_tag("stagehand-server-v3/v3.20.0+build.1") is None
60+
61+
62+
def test_normalize_server_tag_rejects_prerelease_input() -> None:
63+
try:
64+
download_binary.normalize_server_tag("v3.20.0-dev")
65+
except ValueError as exc:
66+
assert "stable tag" in str(exc)
67+
else:
68+
raise AssertionError("Expected prerelease version input to be rejected")
69+
70+
71+
def test_resolve_latest_server_tag_ignores_dev_releases(
72+
monkeypatch: pytest.MonkeyPatch,
73+
) -> None:
74+
releases = [
75+
{"tag_name": "stagehand-server-v3/v3.20.0-dev"},
76+
{"tag_name": "stagehand-server-v3/v3.19.1"},
77+
{"tag_name": "stagehand-server-v3/v3.19.0"},
78+
]
79+
80+
def _fake_http_get_json(_url: str) -> list[dict[str, str]]:
81+
return releases
82+
83+
monkeypatch.setattr(download_binary, "_http_get_json", _fake_http_get_json)
84+
85+
assert download_binary.resolve_latest_server_tag() == "stagehand-server-v3/v3.19.1"

0 commit comments

Comments
 (0)