diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49acfaf42..431860278 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -561,7 +561,7 @@ jobs: - name: Install test dependencies run: | python -m pip install --upgrade pip - pip install pytest + pip install pytest 'opentelemetry-api>=1.24.0' - name: Run native installer wrapper tests run: pytest tests/test_install/test_native_installers.py -q @@ -579,7 +579,7 @@ jobs: run: | brew install bash python -m pip install --upgrade pip - python -m pip install --retries 10 --timeout 60 pytest + python -m pip install --retries 10 --timeout 60 pytest 'opentelemetry-api>=1.24.0' - name: Run native installer wrapper tests run: | BASH_PREFIX="$(brew --prefix bash)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81b90ebbc..1e3f19bf6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ env: NPM_REGISTRY_URL: https://registry.npmjs.org NPM_SDK_PACKAGE: headroom-ai NPM_OPENCLAW_PACKAGE: headroom-openclaw + NPM_OPENCODE_PACKAGE: headroom-opencode # GitHub Package Registry GITHUB_PACKAGES_REGISTRY_URL: https://npm.pkg.github.com @@ -852,10 +853,30 @@ jobs: npm publish --access public continue-on-error: true - - name: npm publish notice - if: steps.npm-sdk-publish.outcome == 'failure' || steps.npm-openclaw-publish.outcome == 'failure' + - name: Publish ${{ env.NPM_OPENCODE_PACKAGE }} to npmjs.org + id: npm-opencode-publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - echo "::notice::One or more npm publishes failed. Set NPM_SKIP=true in repo Variables to skip both npm publishes if tokens are not configured." + version="${{ needs.detect-version.outputs.npm_version }}" + cd plugins/opencode + npm ci + npm run build + npm version "$version" --no-git-tag-version --allow-same-version + HEADROOM_NPM_VERSION="$version" node <<'EOF' + const fs = require("fs"); + const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); + pkg.dependencies = pkg.dependencies || {}; + pkg.dependencies["headroom-ai"] = `^${process.env.HEADROOM_NPM_VERSION}`; + fs.writeFileSync("package.json", `${JSON.stringify(pkg, null, 2)}\n`); + EOF + npm publish --access public + continue-on-error: true + + - name: npm publish notice + if: steps.npm-sdk-publish.outcome == 'failure' || steps.npm-openclaw-publish.outcome == 'failure' || steps.npm-opencode-publish.outcome == 'failure' + run: | + echo "::notice::One or more npm publishes failed. Set NPM_SKIP=true in repo Variables to skip npm publishes if tokens are not configured." publish-github-packages: needs: [detect-version, build] diff --git a/.release-please-config.json b/.release-please-config.json index dadabab91..dcf999fa9 100644 --- a/.release-please-config.json +++ b/.release-please-config.json @@ -23,6 +23,11 @@ "type": "json", "path": "plugins/openclaw/package.json", "jsonpath": "$.version" + }, + { + "type": "json", + "path": "plugins/opencode/package.json", + "jsonpath": "$.version" } ] } diff --git a/docs/content/docs/releases.mdx b/docs/content/docs/releases.mdx index bf58bbffc..c876f4080 100644 --- a/docs/content/docs/releases.mdx +++ b/docs/content/docs/releases.mdx @@ -16,10 +16,11 @@ For the end-to-end visual flow, see [CI/CD Flow Diagrams](/docs/ci-cd-flows). | Package | Type | Registry | Environment Variable | |---------|------|----------|----------------------| | `headroom-ai` | Python | PyPI | `PYPI_PACKAGE` | -| `headroom-ai` | TypeScript SDK | npmjs.org | `NPM_SDK_PACKAGE` | -| `headroom-openclaw` | TypeScript plugin | npmjs.org | `NPM_OPENCLAW_PACKAGE` | -| `@{owner}/headroom-ai` | TypeScript SDK | GitHub Package Registry | — | -| `@{owner}/headroom-openclaw` | TypeScript plugin | GitHub Package Registry | — | +| `headroom-ai` | TypeScript SDK | npmjs.org | `NPM_SDK_PACKAGE` | +| `headroom-openclaw` | TypeScript plugin | npmjs.org | `NPM_OPENCLAW_PACKAGE` | +| `headroom-opencode` | TypeScript plugin | npmjs.org | `NPM_OPENCODE_PACKAGE` | +| `@{owner}/headroom-ai` | TypeScript SDK | GitHub Package Registry | — | +| `@{owner}/headroom-openclaw` | TypeScript plugin | GitHub Package Registry | — | | `headroom-ai-{version}.tar.gz` / `headroom_ai-{version}-py3-none-any.whl` | Python package distributions | GitHub Release (`{owner}/headroom`) | — | | `headroom-ai-{version}.tgz` / `headroom-openclaw-{version}.tgz` | Node release assets | GitHub Release (`{owner}/headroom`) | — | | `ghcr.io/{owner}/headroom` | Docker image | GitHub Container Registry | — | @@ -41,6 +42,7 @@ Release Please calculates the release version from conventional commits and the - `pyproject.toml` - `[project].version` - `headroom/_version.py` - `__version__`, synced at build time - `plugins/openclaw/package.json` - `version`, synced at build time +- `plugins/opencode/package.json` - `version`, synced at build time - `sdk/typescript/package.json` - `version`, synced at build time `release.yml` does not commit back to the repo. Version synchronization happens inside the release build workspace. @@ -98,9 +100,10 @@ Installs the built wheels into representative customer environments and imports Downloads the Python dist artifact and publishes to PyPI via `pypa/gh-action-pypi-publish@release/v1` (trusted publisher). ### publish-npm -Publishes both TypeScript packages to npmjs.org: -- `sdk/typescript/` as `headroom-ai` -- `plugins/openclaw/` as `headroom-openclaw` +Publishes all npm packages to npmjs.org: +- `sdk/typescript/` as `headroom-ai` +- `plugins/openclaw/` as `headroom-openclaw` +- `plugins/opencode/` as `headroom-opencode` ### publish-github-packages Publishes both Node packages to GitHub Package Registry (`npm.pkg.github.com`) using the current repository owner as the npm scope: @@ -124,10 +127,11 @@ All package names, registry URLs, and environment names are defined as top-level env: PYPI_PACKAGE: headroom-ai PYPI_ENVIRONMENT: pypi - NPM_REGISTRY_URL: https://registry.npmjs.org - NPM_SDK_PACKAGE: headroom-ai - NPM_OPENCLAW_PACKAGE: headroom-openclaw - GITHUB_PACKAGES_REGISTRY_URL: https://npm.pkg.github.com + NPM_REGISTRY_URL: https://registry.npmjs.org + NPM_SDK_PACKAGE: headroom-ai + NPM_OPENCLAW_PACKAGE: headroom-openclaw + NPM_OPENCODE_PACKAGE: headroom-opencode + GITHUB_PACKAGES_REGISTRY_URL: https://npm.pkg.github.com ``` To rename a package, update the corresponding constant — all references throughout the workflow update automatically. diff --git a/plugins/opencode/package-lock.json b/plugins/opencode/package-lock.json index 700553f65..c9e6afc6d 100644 --- a/plugins/opencode/package-lock.json +++ b/plugins/opencode/package-lock.json @@ -1,12 +1,12 @@ { "name": "headroom-opencode", - "version": "0.1.0", + "version": "0.32.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "headroom-opencode", - "version": "0.1.0", + "version": "0.32.0", "license": "Apache-2.0", "dependencies": { "@opencode-ai/plugin": "^1.18.2", diff --git a/plugins/opencode/package.json b/plugins/opencode/package.json index 1f84f8aba..5e5718366 100644 --- a/plugins/opencode/package.json +++ b/plugins/opencode/package.json @@ -1,6 +1,6 @@ { "name": "headroom-opencode", - "version": "0.1.0", + "version": "0.32.0", "description": "Headroom proxy integration plugin for OpenCode - routes LLM traffic through the Headroom proxy for token compression", "type": "module", "main": "./dist/index.js", diff --git a/scripts/tests/test_version_sync.py b/scripts/tests/test_version_sync.py index c25c72e07..5d6225edc 100644 --- a/scripts/tests/test_version_sync.py +++ b/scripts/tests/test_version_sync.py @@ -22,6 +22,8 @@ def temp_project(tmp_path: Path) -> dict[str, Path]: plugins = root / "plugins" openclaw = plugins / "openclaw" openclaw.mkdir(parents=True) + opencode = plugins / "opencode" + opencode.mkdir(parents=True) agent_hooks_claude = plugins / "headroom-agent-hooks" / ".claude-plugin" agent_hooks_claude.mkdir(parents=True) agent_hooks_github = plugins / "headroom-agent-hooks" / ".github" / "plugin" @@ -50,6 +52,17 @@ def temp_project(tmp_path: Path) -> dict[str, Path]: ) ) + opencode_pkg = opencode / "package.json" + opencode_pkg.write_text( + json.dumps( + { + "name": "test-opencode", + "version": "0.5.25", + "dependencies": {"headroom-ai": "^0.22.3"}, + } + ) + ) + repo_claude_marketplace = repo_claude_plugin / "marketplace.json" repo_claude_marketplace.write_text( json.dumps( @@ -99,6 +112,7 @@ def temp_project(tmp_path: Path) -> dict[str, Path]: "pyproject": pyproject, "version_py": version_py, "openclaw_pkg": openclaw_pkg, + "opencode_pkg": opencode_pkg, "repo_claude_marketplace": repo_claude_marketplace, "repo_github_marketplace": repo_github_marketplace, "claude_plugin": claude_plugin, @@ -134,6 +148,10 @@ def test_version_sync_explicit_version(temp_project: dict[str, Path]) -> None: assert openclaw_pkg["version"] == "0.7.0" assert openclaw_pkg["dependencies"]["headroom-ai"] == "^0.22.3" + opencode_pkg = json.loads(temp_project["opencode_pkg"].read_text()) + assert opencode_pkg["version"] == "0.7.0" + assert opencode_pkg["dependencies"]["headroom-ai"] == "^0.22.3" + # Verify sdk/typescript/package.json typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text()) assert typescript_pkg["version"] == "0.7.0" @@ -160,6 +178,7 @@ def test_version_sync_explicit_version(temp_project: dict[str, Path]) -> None: assert metadata["packages"]["pypi"] == "0.7.0" assert metadata["packages"]["npm-sdk"] == "0.7.0" assert metadata["packages"]["npm-openclaw"] == "0.7.0" + assert metadata["packages"]["npm-opencode"] == "0.7.0" assert metadata["packages"]["agent-hooks-plugin"] == "0.7.0" @@ -187,6 +206,10 @@ def test_bump_patch(temp_project: dict[str, Path]) -> None: assert openclaw_pkg["version"] == "0.5.26" assert openclaw_pkg["dependencies"]["headroom-ai"] == "^0.22.3" + opencode_pkg = json.loads(temp_project["opencode_pkg"].read_text()) + assert opencode_pkg["version"] == "0.5.26" + assert opencode_pkg["dependencies"]["headroom-ai"] == "^0.22.3" + typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text()) assert typescript_pkg["version"] == "0.5.26" @@ -218,6 +241,10 @@ def test_bump_minor(temp_project: dict[str, Path]) -> None: assert openclaw_pkg["version"] == "0.6.0" assert openclaw_pkg["dependencies"]["headroom-ai"] == "^0.22.3" + opencode_pkg = json.loads(temp_project["opencode_pkg"].read_text()) + assert opencode_pkg["version"] == "0.6.0" + assert opencode_pkg["dependencies"]["headroom-ai"] == "^0.22.3" + typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text()) assert typescript_pkg["version"] == "0.6.0" @@ -249,6 +276,10 @@ def test_bump_major(temp_project: dict[str, Path]) -> None: assert openclaw_pkg["version"] == "1.0.0" assert openclaw_pkg["dependencies"]["headroom-ai"] == "^0.22.3" + opencode_pkg = json.loads(temp_project["opencode_pkg"].read_text()) + assert opencode_pkg["version"] == "1.0.0" + assert opencode_pkg["dependencies"]["headroom-ai"] == "^0.22.3" + typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text()) assert typescript_pkg["version"] == "1.0.0" @@ -279,6 +310,7 @@ def test_release_metadata_written(temp_project: dict[str, Path]) -> None: "pypi": "0.6.0", "npm-sdk": "0.6.0", "npm-openclaw": "0.6.0", + "npm-opencode": "0.6.0", "agent-hooks-plugin": "0.6.0", }, } @@ -309,6 +341,7 @@ def test_plugin_manifests_only_leaves_package_versions_unchanged( assert 'version = "0.5.25"' in temp_project["pyproject"].read_text() assert '__version__ = "0.5.25"' in temp_project["version_py"].read_text() assert json.loads(temp_project["openclaw_pkg"].read_text())["version"] == "0.5.25" + assert json.loads(temp_project["opencode_pkg"].read_text())["version"] == "0.5.25" assert json.loads(temp_project["typescript_pkg"].read_text())["version"] == "0.5.25" assert json.loads(temp_project["claude_plugin"].read_text())["version"] == "0.8.0" assert ( @@ -362,3 +395,22 @@ def test_server_json_version_is_synchronized(temp_project: dict[str, Path]) -> N # Untouched keys must survive so the file still matches the builder's output. assert server_json["name"] == "io.github.headroomlabs-ai/headroom" assert server_json["packages"][0]["registryType"] == "pypi" + + +def test_opencode_headroom_dependency_is_preserved_for_registry_installability( + temp_project: dict[str, Path], +) -> None: + """Source package stays installable even when the next SDK is not on npm yet.""" + root = temp_project["root"] + script = Path(__file__).parent.parent / "version-sync.py" + + result = subprocess.run( + [sys.executable, str(script), "--root", str(root), "--version", "0.28.0"], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, f"Script failed: {result.stderr}" + opencode_pkg = json.loads(temp_project["opencode_pkg"].read_text()) + assert opencode_pkg["version"] == "0.28.0" + assert opencode_pkg["dependencies"]["headroom-ai"] == "^0.22.3" diff --git a/scripts/verify-versions.py b/scripts/verify-versions.py index 5e7e92987..57d4a1f8a 100644 --- a/scripts/verify-versions.py +++ b/scripts/verify-versions.py @@ -40,6 +40,7 @@ def main() -> None: versions = { "pyproject.toml": py_ver, "plugins/openclaw/package.json": _read_json_version(ROOT / "plugins/openclaw/package.json"), + "plugins/opencode/package.json": _read_json_version(ROOT / "plugins/opencode/package.json"), "sdk/typescript/package.json": _read_json_version(ROOT / "sdk/typescript/package.json"), "plugins/headroom-agent-hooks/.claude-plugin/plugin.json": _read_json_version( ROOT / "plugins/headroom-agent-hooks/.claude-plugin/plugin.json" diff --git a/scripts/version-sync.py b/scripts/version-sync.py index c4bcdf81c..76309fcef 100644 --- a/scripts/version-sync.py +++ b/scripts/version-sync.py @@ -124,6 +124,21 @@ def update_openclaw_package_json(file_path: Path, version: str) -> None: f.write("\n") +def update_opencode_package_json(file_path: Path, version: str) -> None: + """Update opencode package.json version. + + Keep the source `headroom-ai` dependency registry-installable. The release + workflow rewrites the packed dependency to the exact release range right + before npm publication. + """ + with open(file_path, encoding="utf-8") as f: + data = json.load(f) + data["version"] = version + with open(file_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + + def update_pyproject_version(root: Path, version: str) -> None: """Update pyproject.toml version.""" pyproject_path = root / "pyproject.toml" @@ -145,6 +160,7 @@ def write_release_metadata(root: Path, version: str) -> None: "pypi": version, "npm-sdk": version, "npm-openclaw": version, + "npm-opencode": version, "agent-hooks-plugin": version, }, } @@ -192,6 +208,7 @@ def main() -> None: # Update all versioned files update_pyproject_version(args.root, version) update_openclaw_package_json(args.root / "plugins" / "openclaw" / "package.json", version) + update_opencode_package_json(args.root / "plugins" / "opencode" / "package.json", version) update_package_json(args.root / "sdk" / "typescript" / "package.json", version) update_plugin_versions(args.root, version) update_server_json(args.root / "server.json", version) diff --git a/tests/test_dashboard_cache_lifetime_playwright.py b/tests/test_dashboard_cache_lifetime_playwright.py index d52642e48..fb4638080 100644 --- a/tests/test_dashboard_cache_lifetime_playwright.py +++ b/tests/test_dashboard_cache_lifetime_playwright.py @@ -1,11 +1,8 @@ -"""Playwright validation for the persisted lifetime Cache Reads tile. +"""Playwright validation for persisted lifetime Prefix Cache metrics. -The Prefix Cache Impact card historically rendered only from in-memory -session counters, so every proxy restart blanked the operator's cache -savings. These tests pin the durable behavior: the card renders from -``persistent_savings.lifetime.cache_read_tokens`` alone after a restart -with zero traffic, session-scoped tiles read "no activity since restart", -and the card stays hidden when neither session nor lifetime data exists. +The Session dashboard now reports only current-process cache activity, +while durable cache totals live behind the Lifetime view. These tests pin +that split after a restart with zero session traffic. """ from __future__ import annotations @@ -29,24 +26,44 @@ expect = playwright.expect sync_playwright = playwright.sync_playwright -def _stats_lifetime_only() -> dict: - """Post-restart shape: zero session cache traffic, persisted lifetime present.""" +def _session_stats_no_cache() -> dict: + """Post-restart session payload: zero current-process cache traffic.""" stats = copy.deepcopy(_sample_stats()) totals = stats.setdefault("prefix_cache", {}).setdefault("totals", {}) totals.update({"requests": 0, "cache_read_tokens": 0, "cache_write_tokens": 0}) - stats.setdefault("persistent_savings", {})["lifetime"] = { - "requests": 6088, - "tokens_saved": 42_181, - "compression_savings_usd": 0.5, - "cache_read_tokens": 629_537_547, - "cache_savings_usd": 7.2, - "total_input_tokens": 1_294_591_655, - "total_input_cost_usd": 12.5, - } return stats -def _install_dashboard_routes(page: Page, stats: dict) -> None: +def _lifetime_cache_payload( + *, + cache_read_tokens: int = 629_537_547, + cache_savings_usd: float = 7.2, +) -> dict: + return { + "requests": {"total": 6088, "failed": 0, "rate_limited": 0, "cached": 0}, + "tokens": {"saved": 42_181}, + "cost": { + "input_usd": 12.5, + "compression_savings_usd": 0.5, + "cache_savings_usd": cache_savings_usd, + }, + "prefix_cache": { + "cache_read_tokens": cache_read_tokens, + "cache_write_tokens": 0, + "hit_requests": 0, + "requests": 0, + "bust_count": 0, + "bust_tokens": 0, + }, + "projects": {}, + "by_model": {}, + "by_stack": {}, + "waste_signals": {}, + "persistence": {"healthy": True, "enabled": True, "pending_records": 0}, + } + + +def _install_dashboard_routes(page: Page, stats: dict, lifetime: dict) -> None: history = _sample_history() health = {"status": "healthy", "version": "0.3.0"} dashboard_html = get_dashboard_html() @@ -61,6 +78,9 @@ def _install_dashboard_routes(page: Page, stats: dict) -> None: if "/stats-history" in path: route.fulfill(status=200, content_type="application/json", body=json.dumps(history)) return + if path.endswith("/stats-lifetime"): + route.fulfill(status=200, content_type="application/json", body=json.dumps(lifetime)) + return if path.endswith("/stats"): route.fulfill(status=200, content_type="application/json", body=json.dumps(stats)) return @@ -72,8 +92,8 @@ def _install_dashboard_routes(page: Page, stats: dict) -> None: page.route("**/*", handler) -def _open_dashboard(page: Page, stats: dict) -> None: - _install_dashboard_routes(page, stats) +def _open_dashboard(page: Page, stats: dict, lifetime: dict) -> None: + _install_dashboard_routes(page, stats, lifetime) page.goto("http://headroom.local/dashboard") page.wait_for_load_state("networkidle") @@ -82,31 +102,25 @@ def test_card_renders_lifetime_cache_reads_after_zero_traffic_restart() -> None: with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page(viewport={"width": 1440, "height": 1600}) - _open_dashboard(page, _stats_lifetime_only()) + _open_dashboard(page, _session_stats_no_cache(), _lifetime_cache_payload()) - expect(page.get_by_text("Prefix Cache Impact", exact=True)).to_be_visible() - expect(page.get_by_text("Cache Reads (lifetime)", exact=True)).to_be_visible() - expect(page.get_by_text("629.5M", exact=True)).to_be_visible() - expect(page.get_by_text("$7.20 saved")).to_be_visible() - # Session-scoped siblings read as inactive, not as literal zeros. - expect(page.get_by_text("no activity since restart").first).to_be_visible() - assert page.get_by_text("no activity since restart").count() >= 5 - # x-show hides via CSS (element stays in the DOM), so assert - # visibility, not count — unlike the x-if card gate below. - expect(page.get_by_text("Cache Efficiency", exact=True)).to_be_hidden() + expect(page.get_by_text("Prefix Cache Impact", exact=True)).to_have_count(0) + page.get_by_role("button", name="Lifetime", exact=True).click() + expect(page.get_by_role("heading", name="Prefix Cache", exact=True)).to_be_visible() + expect(page.get_by_text("629.5M / 0", exact=True)).to_be_visible() + expect(page.get_by_text("$7.20", exact=True)).to_be_visible() browser.close() def test_card_hidden_when_no_session_and_no_lifetime_data() -> None: - stats = _stats_lifetime_only() - stats["persistent_savings"]["lifetime"]["cache_read_tokens"] = 0 - stats["persistent_savings"]["lifetime"]["cache_savings_usd"] = 0.0 + stats = _session_stats_no_cache() + lifetime = _lifetime_cache_payload(cache_read_tokens=0, cache_savings_usd=0.0) with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page(viewport={"width": 1440, "height": 1600}) - _open_dashboard(page, stats) + _open_dashboard(page, stats, lifetime) expect(page.get_by_text("Prefix Cache Impact", exact=True)).to_have_count(0) diff --git a/tests/test_dashboard_cache_ttl_playwright.py b/tests/test_dashboard_cache_ttl_playwright.py index 73cec0990..674f91ad7 100644 --- a/tests/test_dashboard_cache_ttl_playwright.py +++ b/tests/test_dashboard_cache_ttl_playwright.py @@ -175,6 +175,7 @@ def _fulfill_static_asset(route, path: str) -> bool: # type: ignore[no-untyped- def _install_dashboard_routes(page: Page) -> None: stats = _sample_stats() history = _sample_history() + lifetime = {"projects": {}} health = {"status": "healthy", "version": "0.3.0"} dashboard_html = get_dashboard_html() @@ -195,6 +196,9 @@ def _install_dashboard_routes(page: Page) -> None: body=json.dumps(history), ) return + if path.endswith("/stats-lifetime"): + route.fulfill(status=200, content_type="application/json", body=json.dumps(lifetime)) + return if path.endswith("/stats"): route.fulfill(status=200, content_type="application/json", body=json.dumps(stats)) return @@ -213,6 +217,7 @@ def test_dashboard_per_project_setup_url_uses_current_origin() -> None: _install_dashboard_routes(page) page.goto("http://127.0.0.1:8788/dashboard", wait_until="load") + page.get_by_role("button", name="Lifetime", exact=True).click() expect( page.get_by_text( "ANTHROPIC_BASE_URL: http://127.0.0.1:8788/p/", exact=True @@ -225,6 +230,7 @@ def test_dashboard_per_project_setup_url_uses_current_origin() -> None: ).to_have_count(0) page.goto("http://headroom.local:9393/dashboard", wait_until="load") + page.get_by_role("button", name="Lifetime", exact=True).click() expect( page.get_by_text( "ANTHROPIC_BASE_URL: http://headroom.local:9393/p/", exact=True diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 3db9d77db..7911330ac 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -675,6 +675,19 @@ def test_openclaw_source_dependency_matches_lockfile_registry_range() -> None: assert source_range == lock_range == "^0.22.3" +def test_opencode_source_dependency_matches_lockfile_registry_range() -> None: + """The source checkout must remain npm-ci installable before a release exists.""" + import json + + package_json = json.loads((ROOT / "plugins" / "opencode" / "package.json").read_text()) + package_lock = json.loads((ROOT / "plugins" / "opencode" / "package-lock.json").read_text()) + + source_range = package_json["dependencies"]["headroom-ai"] + lock_range = package_lock["packages"][""]["dependencies"]["headroom-ai"] + + assert source_range == lock_range == "^0.22.3" + + def test_python_release_smoke_imports_installed_wheel_outside_source_tree() -> None: """The wheel smoke must not import the checkout package by accident.""" script = (ROOT / "scripts" / "build_python_release_smoke.py").read_text(encoding="utf-8") @@ -700,6 +713,21 @@ def test_publish_npm_regenerates_openclaw_dist_metadata_after_version_and_depend assert version < dependency < prepare_dist < publish +def test_publish_npm_rewrites_opencode_dependency_after_version_and_before_publish() -> None: + """The direct npm publish path must version and retarget the Opencode dependency.""" + content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + + start = content.index("name: Publish ${{ env.NPM_OPENCODE_PACKAGE }} to npmjs.org") + end = content.index("continue-on-error: true", start) + block = content[start:end] + + version = block.index('npm version "$version"') + dependency = block.index('pkg.dependencies["headroom-ai"]') + publish = block.index("npm publish --access public") + + assert version < dependency < publish + + def test_sdist_license_is_packaged_and_verified_before_upload() -> None: """STRUCTURAL INVARIANT: the sdist tarball must physically contain every license file PEP 639 declares in PKG-INFO, and the release @@ -1259,7 +1287,7 @@ def test_release_please_config_and_manifest_are_present_and_consistent() -> None "changelog because the bot can't find its baseline." ) - # extra-files: TypeScript SDK and openclaw plugin package.json + # extra-files: TypeScript SDK and npm plugin package.json files # files must be in lockstep with pyproject.toml. extra_paths = {ef["path"] for ef in root_pkg.get("extra-files", [])} assert "sdk/typescript/package.json" in extra_paths, ( @@ -1270,6 +1298,10 @@ def test_release_please_config_and_manifest_are_present_and_consistent() -> None "release-please must bump plugins/openclaw/package.json so the " "openclaw npm publish stays in sync." ) + assert "plugins/opencode/package.json" in extra_paths, ( + "release-please must bump plugins/opencode/package.json so the " + "opencode npm publish stays in sync." + ) def test_release_metadata_sync_runs_on_release_please_branch() -> None: