diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c8c4240ca..dba745ab1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.31.0" + "version": "0.32.0" }, "plugins": [ { "name": "headroom", "source": "./plugins/headroom-agent-hooks", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.31.0", + "version": "0.32.0", "author": { "name": "Headroom Contributors", "url": "https://github.com/chopratejas/headroom" diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index c8c4240ca..dba745ab1 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.31.0" + "version": "0.32.0" }, "plugins": [ { "name": "headroom", "source": "./plugins/headroom-agent-hooks", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.31.0", + "version": "0.32.0", "author": { "name": "Headroom Contributors", "url": "https://github.com/chopratejas/headroom" diff --git a/.github/workflows/release-metadata-sync.yml b/.github/workflows/release-metadata-sync.yml new file mode 100644 index 000000000..ef775cf3b --- /dev/null +++ b/.github/workflows/release-metadata-sync.yml @@ -0,0 +1,87 @@ +name: Release Metadata Sync + +# Keep generated version-carrying files in sync on release-please's branch. +# +# Why this exists +# --------------- +# release-please only rewrites `pyproject.toml` plus the `extra-files` listed in +# `.release-please-config.json` (currently the TypeScript SDK and OpenClaw +# package.json). Several other tracked files also carry the version, and +# `server.json` is asserted byte-for-byte against `render_server_json()` — which +# derives its version from `pyproject.toml`. So the moment release-please bumps +# the version, `tests/test_mcp_registry/test_server_json.py:: +# test_root_server_json_matches_builder` fails on the release PR, and the release +# cannot be merged. That is what blocked v0.33.0 (PR #2339). +# +# `release.yml` already runs `scripts/version-sync.py` before its own +# `verify-versions.py` gate, so the release *build* self-heals in the workspace. +# The regular CI test job does not, so the fix has to be committed. +# +# Why a workflow rather than more `extra-files` entries +# ---------------------------------------------------- +# `scripts/version-sync.py` is the single place that knows every version-carrying +# file. Restating that list as per-file jsonpaths would duplicate it, and a +# jsonpath that silently fails to match produces exactly the failure we are trying +# to remove. Running the script instead means files added to it in future are +# covered with no change here. +# +# Why the push trigger +# -------------------- +# release-please regenerates (force-pushes) its branch on every merge to main. +# That is what repeatedly wiped the hand-pushed metadata fixes on #2339. Keying +# off a push to the branch means the sync re-applies after every regeneration +# instead of being lost. + +on: + push: + branches: + - "release-please--branches--**" + +permissions: + contents: write + +concurrency: + # Never cancel: a half-applied sync would leave the release PR inconsistent. + group: release-metadata-sync-${{ github.ref }} + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.ref_name }} + # PAT (not GITHUB_TOKEN) for the same reason release-please.yml uses one: + # a push made with GITHUB_TOKEN does not trigger workflows, so the release + # PR's checks would never re-run against the synced commit and would stay + # red. Falls back to GITHUB_TOKEN, where the sync still lands and a manual + # re-run of the PR's checks picks it up. + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + # version-sync.py is stdlib-only (json/re/tomllib), so no install step. + - name: Sync version-carrying files release-please does not bump + run: python scripts/version-sync.py + + - name: Verify all versions agree + run: python scripts/verify-versions.py + + - name: Commit and push if anything changed + run: | + if git diff --quiet; then + echo "Already in sync — nothing to commit." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "chore: sync generated version metadata" + # This push re-triggers this workflow. version-sync.py is idempotent, so + # the next run finds no diff and exits above without pushing — the loop + # terminates after one no-op run. + git push origin HEAD:"${GITHUB_REF_NAME}" diff --git a/.releasemetadata b/.releasemetadata index 7735c0e48..27d6fdedb 100644 --- a/.releasemetadata +++ b/.releasemetadata @@ -1,9 +1,9 @@ { - "version": "0.31.0", + "version": "0.32.0", "packages": { - "pypi": "0.31.0", - "npm-sdk": "0.31.0", - "npm-openclaw": "0.31.0", - "agent-hooks-plugin": "0.31.0" + "pypi": "0.32.0", + "npm-sdk": "0.32.0", + "npm-openclaw": "0.32.0", + "agent-hooks-plugin": "0.32.0" } } diff --git a/plugins/headroom-agent-hooks/.claude-plugin/plugin.json b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json index d025fb3f6..a3c4e2bec 100644 --- a/plugins/headroom-agent-hooks/.claude-plugin/plugin.json +++ b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "headroom", - "version": "0.31.0", + "version": "0.32.0", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", "author": { "name": "Headroom Contributors", diff --git a/plugins/headroom-agent-hooks/.github/plugin/plugin.json b/plugins/headroom-agent-hooks/.github/plugin/plugin.json index bcd4f237e..ff7f9b2ce 100644 --- a/plugins/headroom-agent-hooks/.github/plugin/plugin.json +++ b/plugins/headroom-agent-hooks/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "headroom", - "version": "0.31.0", + "version": "0.32.0", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", "author": { "name": "Headroom Contributors", diff --git a/scripts/tests/test_version_sync.py b/scripts/tests/test_version_sync.py index 075453fe0..c25c72e07 100644 --- a/scripts/tests/test_version_sync.py +++ b/scripts/tests/test_version_sync.py @@ -80,6 +80,20 @@ def temp_project(tmp_path: Path) -> dict[str, Path]: typescript_pkg = typescript / "package.json" typescript_pkg.write_text(json.dumps({"name": "test", "version": "0.5.25"})) + # server.json — the MCP registry descriptor. Asserted byte-for-byte against + # render_server_json(), which reads the version from pyproject.toml, so it has + # to move with every bump or the release PR's test job fails. + server_json = root / "server.json" + server_json.write_text( + json.dumps( + { + "name": "io.github.headroomlabs-ai/headroom", + "version": "0.5.25", + "packages": [{"registryType": "pypi", "version": "0.5.25"}], + } + ) + ) + return { "root": root, "pyproject": pyproject, @@ -90,6 +104,7 @@ def temp_project(tmp_path: Path) -> dict[str, Path]: "claude_plugin": claude_plugin, "github_plugin": github_plugin, "typescript_pkg": typescript_pkg, + "server_json": server_json, } @@ -320,3 +335,30 @@ def test_openclaw_headroom_dependency_is_preserved_for_registry_installability( openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) assert openclaw_pkg["version"] == "0.28.0" assert openclaw_pkg["dependencies"]["headroom-ai"] == "^0.22.3" + + +def test_server_json_version_is_synchronized(temp_project: dict[str, Path]) -> None: + """server.json must track the bump or the release PR's test job fails. + + ``tests/test_mcp_registry/test_server_json.py::test_root_server_json_matches_builder`` + asserts the tracked file equals ``render_server_json()``, which reads the version + from ``pyproject.toml``. Nothing regenerated server.json, so it fell behind every + release and blocked v0.33.0 (PR #2339). + """ + root = temp_project["root"] + script = Path(__file__).parent.parent / "version-sync.py" + + result = subprocess.run( + [sys.executable, str(script), "--root", str(root), "--version", "0.33.0"], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, f"Script failed: {result.stderr}" + server_json = json.loads(temp_project["server_json"].read_text()) + assert server_json["version"] == "0.33.0" + # The packages[] entry carries its own version and is checked by the builder too. + assert [p["version"] for p in server_json["packages"]] == ["0.33.0"] + # 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" diff --git a/scripts/version-sync.py b/scripts/version-sync.py index 79fa78fd0..c4bcdf81c 100644 --- a/scripts/version-sync.py +++ b/scripts/version-sync.py @@ -74,6 +74,28 @@ def update_marketplace_manifest(file_path: Path, version: str) -> None: f.write("\n") +def update_server_json(file_path: Path, version: str) -> None: + """Update the MCP registry descriptor's version fields. + + ``server.json`` is asserted byte-for-byte against ``render_server_json()`` + (tests/test_mcp_registry/test_server_json.py), which derives the version from + ``pyproject.toml``. Nothing regenerated this file, so it silently fell behind + every release and failed that test on the release PR. Values are rewritten in + place so key order and formatting keep matching the builder's output. + """ + with open(file_path, encoding="utf-8") as f: + data = json.load(f) + data["version"] = version + packages = data.get("packages") + if isinstance(packages, list): + for package in packages: + if isinstance(package, dict): + package["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_plugin_versions(root: Path, version: str) -> None: """Update marketplace and plugin manifest versions.""" update_marketplace_manifest(root / ".claude-plugin" / "marketplace.json", version) @@ -172,6 +194,7 @@ def main() -> None: update_openclaw_package_json(args.root / "plugins" / "openclaw" / "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) write_release_metadata(args.root, version) print(f"Version synchronized to {version}") diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 8933987f7..9acd4faa2 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -1219,3 +1219,66 @@ 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." ) + + +def test_release_metadata_sync_runs_on_release_please_branch() -> None: + """The release branch must self-heal the versions release-please does not bump. + + release-please rewrites `pyproject.toml` plus its configured `extra-files` only. + `server.json` is asserted byte-for-byte against `render_server_json()`, which + reads the version from `pyproject.toml`, so a bump without a sync fails + `test_root_server_json_matches_builder` on the release PR — that is what blocked + v0.33.0 (#2339). `release.yml` syncs in-workspace before its own gate, but the + regular CI test job does not, so the sync has to be committed to the branch. + """ + content = (ROOT / ".github" / "workflows" / "release-metadata-sync.yml").read_text( + encoding="utf-8" + ) + + # Keyed off a push to the release branch: release-please force-regenerates that + # branch on every merge to main, which is what wiped the hand-pushed fixes. + assert '"release-please--branches--**"' in content + assert "contents: write" in content + + # Sync, then gate on the verifier, then commit — in that order. + sync = content.index("python scripts/version-sync.py") + verify = content.index("python scripts/verify-versions.py", sync) + commit = content.index("git commit", verify) + assert sync < verify < commit + + # Must no-op rather than loop when the branch is already in sync. + assert "git diff --quiet" in content + + # A GITHUB_TOKEN push would not re-trigger the release PR's checks. + assert "RELEASE_PLEASE_TOKEN" in content + + +def test_version_sync_covers_every_file_the_verifier_gates() -> None: + """version-sync.py must write every version location verify-versions.py checks. + + These two scripts drifting apart is the root cause of the stuck release: the + verifier gated files nothing propagated a version to. + """ + sync = (ROOT / "scripts" / "version-sync.py").read_text(encoding="utf-8") + verify = (ROOT / "scripts" / "verify-versions.py").read_text(encoding="utf-8") + + gated = [ + "pyproject.toml", + "plugins/openclaw/package.json", + "sdk/typescript/package.json", + "plugins/headroom-agent-hooks/.claude-plugin/plugin.json", + "plugins/headroom-agent-hooks/.github/plugin/plugin.json", + "marketplace.json", + ] + for path in gated: + assert path in verify, f"{path} unexpectedly no longer gated by verify-versions.py" + + # version-sync builds paths piecewise, so match on the distinctive components. + for fragment in [ + "openclaw", + "typescript", + "headroom-agent-hooks", + "marketplace.json", + "server.json", + ]: + assert fragment in sync, f"version-sync.py no longer propagates a version to {fragment}"