From c5d795c2afdf90bbe09d450f16f9b572931e1370 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 21 Apr 2026 20:03:14 -0500 Subject: [PATCH] build: sync agent hook manifests to repo semver Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .claude-plugin/marketplace.json | 10 +- .github/plugin/marketplace.json | 10 +- .gitignore | 437 ++++++++-------- .pre-commit-config.yaml | 42 +- headroom/cli/init.py | 2 +- .../.claude-plugin/plugin.json | 8 +- .../.github/plugin/plugin.json | 8 +- scripts/sync-plugin-versions.py | 55 ++ scripts/tests/test_version_sync.py | 485 +++++++++++------- scripts/version-sync.py | 328 +++++++----- tests/test_plugin_manifests.py | 15 + 11 files changed, 814 insertions(+), 586 deletions(-) create mode 100644 scripts/sync-plugin-versions.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 25e5b050e..afc9d8b26 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,20 +5,20 @@ }, "metadata": { "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.1.0" + "version": "0.10.0" }, "plugins": [ { "name": "headroom", "source": "./plugins/headroom-agent-hooks", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.1.0", + "version": "0.10.0", "author": { "name": "Headroom Contributors", - "url": "https://github.com/JerrettDavis/headroom" + "url": "https://github.com/chopratejas/headroom" }, - "homepage": "https://github.com/JerrettDavis/headroom", - "repository": "https://github.com/JerrettDavis/headroom", + "homepage": "https://github.com/chopratejas/headroom", + "repository": "https://github.com/chopratejas/headroom", "keywords": [ "headroom", "hooks", diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 25e5b050e..afc9d8b26 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -5,20 +5,20 @@ }, "metadata": { "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.1.0" + "version": "0.10.0" }, "plugins": [ { "name": "headroom", "source": "./plugins/headroom-agent-hooks", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.1.0", + "version": "0.10.0", "author": { "name": "Headroom Contributors", - "url": "https://github.com/JerrettDavis/headroom" + "url": "https://github.com/chopratejas/headroom" }, - "homepage": "https://github.com/JerrettDavis/headroom", - "repository": "https://github.com/JerrettDavis/headroom", + "homepage": "https://github.com/chopratejas/headroom", + "repository": "https://github.com/chopratejas/headroom", "keywords": [ "headroom", "hooks", diff --git a/.gitignore b/.gitignore index 2319912cd..122f5731f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,218 +1,219 @@ -# Private scripts (contain credentials). Allowlist checked-in helpers below. -scripts/ -!scripts/ -scripts/* -!scripts/install.sh -!scripts/install.ps1 -!scripts/version-sync.py -!scripts/changelog-gen.py -!scripts/verify-versions.py -!scripts/tests/ -!scripts/README.md -!scripts/repro_codex_replay.py -!scripts/fixtures/ -!scripts/fixtures/*.json - -# Swift SDK (separate repo) -swift/ - -# Local planning docs (never commit) -ENTERPRISE_HARDENING.md - -# Audit/scan outputs (contain security findings — never commit) -bandit_result.txt -pip_audit_result.txt -ruff_result.txt -reqs.txt - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -pytest_cache/ - -# Translations -*.mo -*.pot - -# Environments -.env -.env.* -!.env.act.example -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ -.python-version - -# Secrets and API keys - NEVER commit these -*.pem -*.key -secrets.json -credentials.json -.secrets -api_keys.txt -.anthropic -.openai - -# IDE and editors -.idea/ -.vscode/ -*.swp -*.swo -*~ -.project -.pydevproject -.settings/ -*.sublime-project -*.sublime-workspace -.spyproject -.spyderproject - -# Jupyter Notebook -.ipynb_checkpoints -*.ipynb - -# macOS -.DS_Store -.AppleDouble -.LSOverride -._* - -# Thumbnails -Icon? -._* - -# Windows -Thumbs.db -ehthumbs.db -Desktop.ini - -# Linux -*~ - -# Local configuration -local_settings.py -*.local.py -*.local.json -*.local.yaml - -# Database files -*.db -*.sqlite -*.sqlite3 - -# Log files -*.log -logs/ -log/ - -# Temporary files -tmp/ -temp/ -*.tmp -*.bak -*.swp - -# Benchmark results (keep framework, not results) -.benchmarks/ -benchmark_results.json -benchmark_results/ - -# DeepEval cache -.deepeval/ - -# Headroom specific -headroom.db -headroom_*.db -*.jsonl -!tests/fixtures/*.jsonl - -# Documentation build -docs/_build/ -site/ - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Ruff -.ruff_cache/ - -# pyright -pyrightconfig.json - -# Editor backup files -*~ -\#*\# -.\#* - -# Local development configuration -CLAUDE.md - -# Vitals provenance data -.vitals/ - -# Superpowers working files (plans, specs, brainstorming) -# docs/spec/ (lives in git - git-versioned living specification) - -# Managed platform (separate private repo) -headroom-managed/ - -# Local act testing (never commit test tokens) -/.env.act -.actrc.local - -# Release metadata artifact -.releaseetadata - -# uv lockfile: regenerated locally; not committed -uv.lock +# Private scripts (contain credentials). Allowlist checked-in helpers below. +scripts/ +!scripts/ +scripts/* +!scripts/install.sh +!scripts/install.ps1 +!scripts/version-sync.py +!scripts/sync-plugin-versions.py +!scripts/changelog-gen.py +!scripts/verify-versions.py +!scripts/tests/ +!scripts/README.md +!scripts/repro_codex_replay.py +!scripts/fixtures/ +!scripts/fixtures/*.json + +# Swift SDK (separate repo) +swift/ + +# Local planning docs (never commit) +ENTERPRISE_HARDENING.md + +# Audit/scan outputs (contain security findings — never commit) +bandit_result.txt +pip_audit_result.txt +ruff_result.txt +reqs.txt + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +pytest_cache/ + +# Translations +*.mo +*.pot + +# Environments +.env +.env.* +!.env.act.example +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.python-version + +# Secrets and API keys - NEVER commit these +*.pem +*.key +secrets.json +credentials.json +.secrets +api_keys.txt +.anthropic +.openai + +# IDE and editors +.idea/ +.vscode/ +*.swp +*.swo +*~ +.project +.pydevproject +.settings/ +*.sublime-project +*.sublime-workspace +.spyproject +.spyderproject + +# Jupyter Notebook +.ipynb_checkpoints +*.ipynb + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* + +# Thumbnails +Icon? +._* + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# Linux +*~ + +# Local configuration +local_settings.py +*.local.py +*.local.json +*.local.yaml + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Log files +*.log +logs/ +log/ + +# Temporary files +tmp/ +temp/ +*.tmp +*.bak +*.swp + +# Benchmark results (keep framework, not results) +.benchmarks/ +benchmark_results.json +benchmark_results/ + +# DeepEval cache +.deepeval/ + +# Headroom specific +headroom.db +headroom_*.db +*.jsonl +!tests/fixtures/*.jsonl + +# Documentation build +docs/_build/ +site/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Ruff +.ruff_cache/ + +# pyright +pyrightconfig.json + +# Editor backup files +*~ +\#*\# +.\#* + +# Local development configuration +CLAUDE.md + +# Vitals provenance data +.vitals/ + +# Superpowers working files (plans, specs, brainstorming) +# docs/spec/ (lives in git - git-versioned living specification) + +# Managed platform (separate private repo) +headroom-managed/ + +# Local act testing (never commit test tokens) +/.env.act +.actrc.local + +# Release metadata artifact +.releaseetadata + +# uv lockfile: regenerated locally; not committed +uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b124868f2..42f7f867d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,25 @@ -repos: - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.4 - hooks: - - id: ruff - args: [--fix] - exclude: ^experiments/ - - id: ruff-format - exclude: ^experiments/ - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.14.1 - hooks: - - id: mypy - args: [--ignore-missing-imports] - exclude: ^experiments/ - pass_filenames: false - entry: mypy headroom +repos: + - repo: local + hooks: + - id: sync-plugin-versions + name: Sync plugin versions + entry: python scripts/sync-plugin-versions.py + language: system + pass_filenames: false + always_run: true + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.4 + hooks: + - id: ruff + args: [--fix] + exclude: ^experiments/ + - id: ruff-format + exclude: ^experiments/ + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.14.1 + hooks: + - id: mypy + args: [--ignore-missing-imports] + exclude: ^experiments/ + pass_filenames: false + entry: mypy headroom diff --git a/headroom/cli/init.py b/headroom/cli/init.py index e5ea3ca29..09767fc65 100644 --- a/headroom/cli/init.py +++ b/headroom/cli/init.py @@ -394,7 +394,7 @@ def _marketplace_source() -> str: repo_root = Path(__file__).resolve().parents[2] if (repo_root / ".claude-plugin" / "marketplace.json").exists(): return str(repo_root) - return "JerrettDavis/headroom" + return "chopratejas/headroom" def _run_checked(command: list[str], *, action: str) -> None: diff --git a/plugins/headroom-agent-hooks/.claude-plugin/plugin.json b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json index 6d180897c..4b2fd52e0 100644 --- a/plugins/headroom-agent-hooks/.claude-plugin/plugin.json +++ b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json @@ -1,13 +1,13 @@ { "name": "headroom", - "version": "0.1.0", + "version": "0.10.0", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", "author": { "name": "Headroom Contributors", - "url": "https://github.com/JerrettDavis/headroom" + "url": "https://github.com/chopratejas/headroom" }, - "homepage": "https://github.com/JerrettDavis/headroom", - "repository": "https://github.com/JerrettDavis/headroom", + "homepage": "https://github.com/chopratejas/headroom", + "repository": "https://github.com/chopratejas/headroom", "keywords": [ "headroom", "hooks", diff --git a/plugins/headroom-agent-hooks/.github/plugin/plugin.json b/plugins/headroom-agent-hooks/.github/plugin/plugin.json index 143337063..f8fe831d1 100644 --- a/plugins/headroom-agent-hooks/.github/plugin/plugin.json +++ b/plugins/headroom-agent-hooks/.github/plugin/plugin.json @@ -1,13 +1,13 @@ { "name": "headroom", - "version": "0.1.0", + "version": "0.10.0", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", "author": { "name": "Headroom Contributors", - "url": "https://github.com/JerrettDavis/headroom" + "url": "https://github.com/chopratejas/headroom" }, - "homepage": "https://github.com/JerrettDavis/headroom", - "repository": "https://github.com/JerrettDavis/headroom", + "homepage": "https://github.com/chopratejas/headroom", + "repository": "https://github.com/chopratejas/headroom", "keywords": [ "headroom", "hooks", diff --git a/scripts/sync-plugin-versions.py b/scripts/sync-plugin-versions.py new file mode 100644 index 000000000..e26217226 --- /dev/null +++ b/scripts/sync-plugin-versions.py @@ -0,0 +1,55 @@ +"""Sync plugin manifest versions to the repo's computed release semver.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from headroom.release_version import ( # noqa: E402 + compute_release_version, + determine_bump_level, + find_latest_release_tag, + get_canonical_version, + list_release_commits, + list_release_tags, +) + + +def compute_repo_semver(root: Path) -> str: + """Return the npm-style semver for the repo's next release.""" + tags = list_release_tags(root) + previous_tag = find_latest_release_tag(tags) or "" + level = determine_bump_level(list_release_commits(root, previous_tag)) + info = compute_release_version( + canonical_version=get_canonical_version(root), + level=level, + tags=tags, + ) + return info.npm_version + + +def main() -> None: + root = ROOT + version = compute_repo_semver(root) + subprocess.run( + [ + sys.executable, + str(root / "scripts" / "version-sync.py"), + "--root", + str(root), + "--version", + version, + "--plugin-manifests-only", + ], + cwd=root, + check=True, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_version_sync.py b/scripts/tests/test_version_sync.py index 476c64144..7a4d72ea6 100644 --- a/scripts/tests/test_version_sync.py +++ b/scripts/tests/test_version_sync.py @@ -1,194 +1,291 @@ -"""Tests for version-sync.py.""" - -import json -import subprocess -import sys -from pathlib import Path - -import pytest - - -@pytest.fixture -def temp_project(tmp_path: Path) -> dict[str, Path]: - """Create a temporary project with all versioned files.""" - # Create directory structure - root = tmp_path / "project" - headroom = root / "headroom" - headroom.mkdir(parents=True) - plugins = root / "plugins" - openclaw = plugins / "openclaw" - openclaw.mkdir(parents=True) - sdk = root / "sdk" - typescript = sdk / "typescript" - typescript.mkdir(parents=True) - - # pyproject.toml - pyproject = root / "pyproject.toml" - pyproject.write_text('[project]\nversion = "0.5.25"\n') - - # headroom/_version.py - version_py = headroom / "_version.py" - version_py.write_text('"""Package version metadata."""\n\n__version__ = "0.5.25"\n') - - # plugins/openclaw/package.json - openclaw_pkg = openclaw / "package.json" - openclaw_pkg.write_text(json.dumps({"name": "test", "version": "0.5.25"})) - - # sdk/typescript/package.json - typescript_pkg = typescript / "package.json" - typescript_pkg.write_text(json.dumps({"name": "test", "version": "0.5.25"})) - - return { - "root": root, - "pyproject": pyproject, - "version_py": version_py, - "openclaw_pkg": openclaw_pkg, - "typescript_pkg": typescript_pkg, - } - - -def test_version_sync_explicit_version(temp_project: dict[str, Path]) -> None: - """Test --version flag updates all files.""" - root = temp_project["root"] - script = Path(__file__).parent.parent / "version-sync.py" - - result = subprocess.run( - [sys.executable, str(script), "--root", str(root), "--version", "0.7.0"], - capture_output=True, - text=True, - ) - - assert result.returncode == 0, f"Script failed: {result.stderr}" - - # Verify pyproject.toml - pyproject_content = temp_project["pyproject"].read_text() - assert 'version = "0.7.0"' in pyproject_content - - # Verify headroom/_version.py - version_py_content = temp_project["version_py"].read_text() - assert '__version__ = "0.7.0"' in version_py_content - - # Verify plugins/openclaw/package.json - openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) - assert openclaw_pkg["version"] == "0.7.0" - - # Verify sdk/typescript/package.json - typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text()) - assert typescript_pkg["version"] == "0.7.0" - - # Verify .releaseetadata was created - release_metadata = root / ".releaseetadata" - assert release_metadata.exists() - metadata = json.loads(release_metadata.read_text()) - assert metadata["version"] == "0.7.0" - assert metadata["packages"]["pypi"] == "0.7.0" - assert metadata["packages"]["npm-sdk"] == "0.7.0" - assert metadata["packages"]["npm-openclaw"] == "0.7.0" - - -def test_bump_patch(temp_project: dict[str, Path]) -> None: - """Test --bump patch bumps 0.5.25 to 0.5.26.""" - root = temp_project["root"] - script = Path(__file__).parent.parent / "version-sync.py" - - result = subprocess.run( - [sys.executable, str(script), "--root", str(root), "--bump", "patch"], - capture_output=True, - text=True, - ) - - assert result.returncode == 0, f"Script failed: {result.stderr}" - - # Verify all files updated to 0.5.26 - pyproject_content = temp_project["pyproject"].read_text() - assert 'version = "0.5.26"' in pyproject_content - - version_py_content = temp_project["version_py"].read_text() - assert '__version__ = "0.5.26"' in version_py_content - - openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) - assert openclaw_pkg["version"] == "0.5.26" - - typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text()) - assert typescript_pkg["version"] == "0.5.26" - - -def test_bump_minor(temp_project: dict[str, Path]) -> None: - """Test --bump minor bumps 0.5.25 to 0.6.0.""" - root = temp_project["root"] - script = Path(__file__).parent.parent / "version-sync.py" - - result = subprocess.run( - [sys.executable, str(script), "--root", str(root), "--bump", "minor"], - capture_output=True, - text=True, - ) - - assert result.returncode == 0, f"Script failed: {result.stderr}" - - # Verify all files updated to 0.6.0 - pyproject_content = temp_project["pyproject"].read_text() - assert 'version = "0.6.0"' in pyproject_content - - version_py_content = temp_project["version_py"].read_text() - assert '__version__ = "0.6.0"' in version_py_content - - openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) - assert openclaw_pkg["version"] == "0.6.0" - - typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text()) - assert typescript_pkg["version"] == "0.6.0" - - -def test_bump_major(temp_project: dict[str, Path]) -> None: - """Test --bump major bumps 0.5.25 to 1.0.0.""" - root = temp_project["root"] - script = Path(__file__).parent.parent / "version-sync.py" - - result = subprocess.run( - [sys.executable, str(script), "--root", str(root), "--bump", "major"], - capture_output=True, - text=True, - ) - - assert result.returncode == 0, f"Script failed: {result.stderr}" - - # Verify all files updated to 1.0.0 - pyproject_content = temp_project["pyproject"].read_text() - assert 'version = "1.0.0"' in pyproject_content - - version_py_content = temp_project["version_py"].read_text() - assert '__version__ = "1.0.0"' in version_py_content - - openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) - assert openclaw_pkg["version"] == "1.0.0" - - typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text()) - assert typescript_pkg["version"] == "1.0.0" - - -def test_release_metadata_written(temp_project: dict[str, Path]) -> None: - """Test .releaseetadata is written correctly.""" - root = temp_project["root"] - script = Path(__file__).parent.parent / "version-sync.py" - - result = subprocess.run( - [sys.executable, str(script), "--root", str(root), "--version", "0.6.0"], - capture_output=True, - text=True, - ) - - assert result.returncode == 0, f"Script failed: {result.stderr}" - - release_metadata = root / ".releaseetadata" - assert release_metadata.exists() - - metadata = json.loads(release_metadata.read_text()) - assert metadata == { - "version": "0.6.0", - "packages": { - "pypi": "0.6.0", - "npm-sdk": "0.6.0", - "npm-openclaw": "0.6.0", - }, - } +"""Tests for version-sync.py.""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + + +@pytest.fixture +def temp_project(tmp_path: Path) -> dict[str, Path]: + """Create a temporary project with all versioned files.""" + # Create directory structure + root = tmp_path / "project" + headroom = root / "headroom" + headroom.mkdir(parents=True) + repo_claude_plugin = root / ".claude-plugin" + repo_claude_plugin.mkdir(parents=True) + repo_github_plugin = root / ".github" / "plugin" + repo_github_plugin.mkdir(parents=True) + plugins = root / "plugins" + openclaw = plugins / "openclaw" + openclaw.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" + agent_hooks_github.mkdir(parents=True) + sdk = root / "sdk" + typescript = sdk / "typescript" + typescript.mkdir(parents=True) + + # pyproject.toml + pyproject = root / "pyproject.toml" + pyproject.write_text('[project]\nversion = "0.5.25"\n') + + # headroom/_version.py + version_py = headroom / "_version.py" + version_py.write_text('"""Package version metadata."""\n\n__version__ = "0.5.25"\n') + + # plugins/openclaw/package.json + openclaw_pkg = openclaw / "package.json" + openclaw_pkg.write_text(json.dumps({"name": "test", "version": "0.5.25"})) + + repo_claude_marketplace = repo_claude_plugin / "marketplace.json" + repo_claude_marketplace.write_text( + json.dumps( + { + "metadata": {"name": "claude-marketplace", "version": "0.1.0"}, + "plugins": [{"name": "headroom-agent-hooks", "version": "0.1.0"}], + } + ) + ) + + repo_github_marketplace = repo_github_plugin / "marketplace.json" + repo_github_marketplace.write_text( + json.dumps( + { + "metadata": {"name": "copilot-marketplace", "version": "0.1.0"}, + "plugins": [{"name": "headroom-agent-hooks", "version": "0.1.0"}], + } + ) + ) + + claude_plugin = agent_hooks_claude / "plugin.json" + claude_plugin.write_text(json.dumps({"name": "headroom-agent-hooks", "version": "0.1.0"})) + + github_plugin = agent_hooks_github / "plugin.json" + github_plugin.write_text(json.dumps({"name": "headroom-agent-hooks", "version": "0.1.0"})) + + # sdk/typescript/package.json + typescript_pkg = typescript / "package.json" + typescript_pkg.write_text(json.dumps({"name": "test", "version": "0.5.25"})) + + return { + "root": root, + "pyproject": pyproject, + "version_py": version_py, + "openclaw_pkg": openclaw_pkg, + "repo_claude_marketplace": repo_claude_marketplace, + "repo_github_marketplace": repo_github_marketplace, + "claude_plugin": claude_plugin, + "github_plugin": github_plugin, + "typescript_pkg": typescript_pkg, + } + + +def test_version_sync_explicit_version(temp_project: dict[str, Path]) -> None: + """Test --version flag updates all files.""" + root = temp_project["root"] + script = Path(__file__).parent.parent / "version-sync.py" + + result = subprocess.run( + [sys.executable, str(script), "--root", str(root), "--version", "0.7.0"], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, f"Script failed: {result.stderr}" + + # Verify pyproject.toml + pyproject_content = temp_project["pyproject"].read_text() + assert 'version = "0.7.0"' in pyproject_content + + # Verify headroom/_version.py + version_py_content = temp_project["version_py"].read_text() + assert '__version__ = "0.7.0"' in version_py_content + + # Verify plugins/openclaw/package.json + openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) + assert openclaw_pkg["version"] == "0.7.0" + + # Verify sdk/typescript/package.json + typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text()) + assert typescript_pkg["version"] == "0.7.0" + + repo_claude_marketplace = json.loads(temp_project["repo_claude_marketplace"].read_text()) + assert repo_claude_marketplace["metadata"]["version"] == "0.7.0" + assert repo_claude_marketplace["plugins"][0]["version"] == "0.7.0" + + repo_github_marketplace = json.loads(temp_project["repo_github_marketplace"].read_text()) + assert repo_github_marketplace["metadata"]["version"] == "0.7.0" + assert repo_github_marketplace["plugins"][0]["version"] == "0.7.0" + + claude_plugin = json.loads(temp_project["claude_plugin"].read_text()) + assert claude_plugin["version"] == "0.7.0" + + github_plugin = json.loads(temp_project["github_plugin"].read_text()) + assert github_plugin["version"] == "0.7.0" + + # Verify .releaseetadata was created + release_metadata = root / ".releaseetadata" + assert release_metadata.exists() + metadata = json.loads(release_metadata.read_text()) + assert metadata["version"] == "0.7.0" + 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"]["agent-hooks-plugin"] == "0.7.0" + + +def test_bump_patch(temp_project: dict[str, Path]) -> None: + """Test --bump patch bumps 0.5.25 to 0.5.26.""" + root = temp_project["root"] + script = Path(__file__).parent.parent / "version-sync.py" + + result = subprocess.run( + [sys.executable, str(script), "--root", str(root), "--bump", "patch"], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, f"Script failed: {result.stderr}" + + # Verify all files updated to 0.5.26 + pyproject_content = temp_project["pyproject"].read_text() + assert 'version = "0.5.26"' in pyproject_content + + version_py_content = temp_project["version_py"].read_text() + assert '__version__ = "0.5.26"' in version_py_content + + openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) + assert openclaw_pkg["version"] == "0.5.26" + + typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text()) + assert typescript_pkg["version"] == "0.5.26" + + claude_plugin = json.loads(temp_project["claude_plugin"].read_text()) + assert claude_plugin["version"] == "0.5.26" + + +def test_bump_minor(temp_project: dict[str, Path]) -> None: + """Test --bump minor bumps 0.5.25 to 0.6.0.""" + root = temp_project["root"] + script = Path(__file__).parent.parent / "version-sync.py" + + result = subprocess.run( + [sys.executable, str(script), "--root", str(root), "--bump", "minor"], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, f"Script failed: {result.stderr}" + + # Verify all files updated to 0.6.0 + pyproject_content = temp_project["pyproject"].read_text() + assert 'version = "0.6.0"' in pyproject_content + + version_py_content = temp_project["version_py"].read_text() + assert '__version__ = "0.6.0"' in version_py_content + + openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) + assert openclaw_pkg["version"] == "0.6.0" + + typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text()) + assert typescript_pkg["version"] == "0.6.0" + + github_plugin = json.loads(temp_project["github_plugin"].read_text()) + assert github_plugin["version"] == "0.6.0" + + +def test_bump_major(temp_project: dict[str, Path]) -> None: + """Test --bump major bumps 0.5.25 to 1.0.0.""" + root = temp_project["root"] + script = Path(__file__).parent.parent / "version-sync.py" + + result = subprocess.run( + [sys.executable, str(script), "--root", str(root), "--bump", "major"], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, f"Script failed: {result.stderr}" + + # Verify all files updated to 1.0.0 + pyproject_content = temp_project["pyproject"].read_text() + assert 'version = "1.0.0"' in pyproject_content + + version_py_content = temp_project["version_py"].read_text() + assert '__version__ = "1.0.0"' in version_py_content + + openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) + assert openclaw_pkg["version"] == "1.0.0" + + typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text()) + assert typescript_pkg["version"] == "1.0.0" + + repo_claude_marketplace = json.loads(temp_project["repo_claude_marketplace"].read_text()) + assert repo_claude_marketplace["metadata"]["version"] == "1.0.0" + + +def test_release_metadata_written(temp_project: dict[str, Path]) -> None: + """Test .releaseetadata is written correctly.""" + root = temp_project["root"] + script = Path(__file__).parent.parent / "version-sync.py" + + result = subprocess.run( + [sys.executable, str(script), "--root", str(root), "--version", "0.6.0"], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, f"Script failed: {result.stderr}" + + release_metadata = root / ".releaseetadata" + assert release_metadata.exists() + + metadata = json.loads(release_metadata.read_text()) + assert metadata == { + "version": "0.6.0", + "packages": { + "pypi": "0.6.0", + "npm-sdk": "0.6.0", + "npm-openclaw": "0.6.0", + "agent-hooks-plugin": "0.6.0", + }, + } + + +def test_plugin_manifests_only_leaves_package_versions_unchanged( + temp_project: dict[str, Path], +) -> None: + """Test plugin-only sync leaves canonical package versions alone.""" + root = temp_project["root"] + script = Path(__file__).parent.parent / "version-sync.py" + + result = subprocess.run( + [ + sys.executable, + str(script), + "--root", + str(root), + "--version", + "0.8.0", + "--plugin-manifests-only", + ], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, f"Script failed: {result.stderr}" + 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["typescript_pkg"].read_text())["version"] == "0.5.25" + assert json.loads(temp_project["claude_plugin"].read_text())["version"] == "0.8.0" + assert ( + json.loads(temp_project["repo_github_marketplace"].read_text())["metadata"]["version"] + == "0.8.0" + ) + assert not (root / ".releaseetadata").exists() diff --git a/scripts/version-sync.py b/scripts/version-sync.py index 49b8ab6a9..71f30f341 100644 --- a/scripts/version-sync.py +++ b/scripts/version-sync.py @@ -1,138 +1,190 @@ -#!/usr/bin/env python3 -"""Synchronize version across all headroom packages.""" - -from __future__ import annotations - -import argparse -import json -import re -from pathlib import Path - -import tomllib - - -def get_version_from_pyproject(root: Path) -> str: - """Read version from pyproject.toml.""" - pyproject_path = root / "pyproject.toml" - with open(pyproject_path, "rb") as f: - data = tomllib.load(f) - return data["project"]["version"] - - -def bump_version(version: str, bump_type: str) -> str: - """Bump version according to bump_type (major, minor, patch).""" - major, minor, patch = map(int, version.split(".")) - if bump_type == "major": - major += 1 - minor = 0 - patch = 0 - elif bump_type == "minor": - minor += 1 - patch = 0 - elif bump_type == "patch": - patch += 1 - return f"{major}.{minor}.{patch}" - - -def update_version_py(root: Path, version: str) -> None: - """Update headroom/_version.py with new version.""" - version_py_path = root / "headroom" / "_version.py" - content = version_py_path.read_text(encoding="utf-8") - updated = re.sub( - r'__version__ = "[^"]+"', - f'__version__ = "{version}"', - content, - ) - version_py_path.write_text(updated, encoding="utf-8") - - -def update_package_json(file_path: Path, version: str) -> None: - """Update a package.json version field.""" - 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) - f.write("\n") - - -def update_openclaw_package_json(file_path: Path, version: str, sdk_version: str) -> None: - """Update openclaw package.json version and headroom-ai dependency range.""" - with open(file_path, encoding="utf-8") as f: - data = json.load(f) - data["version"] = version - if "dependencies" in data and "headroom-ai" in data["dependencies"]: - data["dependencies"]["headroom-ai"] = f"^{sdk_version}" - with open(file_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - f.write("\n") - - -def update_pyproject_version(root: Path, version: str) -> None: - """Update pyproject.toml version.""" - pyproject_path = root / "pyproject.toml" - content = pyproject_path.read_text(encoding="utf-8") - updated = re.sub( - r'^version = "[^"]+"', - f'version = "{version}"', - content, - flags=re.MULTILINE, - ) - pyproject_path.write_text(updated, encoding="utf-8") - - -def write_release_metadata(root: Path, version: str) -> None: - """Write .releaseetadata JSON file.""" - metadata = { - "version": version, - "packages": { - "pypi": version, - "npm-sdk": version, - "npm-openclaw": version, - }, - } - metadata_path = root / ".releaseetadata" - with open(metadata_path, "w", encoding="utf-8") as f: - json.dump(metadata, f, indent=2) - f.write("\n") - - -def main() -> None: - parser = argparse.ArgumentParser(description="Synchronize version across headroom packages") - parser.add_argument( - "--root", - type=Path, - default=Path(__file__).parent.parent, - help="Root directory of the project", - ) - group = parser.add_mutually_exclusive_group() - group.add_argument("--version", help="Explicit version to set (e.g., 0.6.0)") - group.add_argument( - "--bump", - choices=["major", "minor", "patch"], - help="Bump version from pyproject.toml", - ) - args = parser.parse_args() - - if args.version: - version = args.version - elif args.bump: - base_version = get_version_from_pyproject(args.root) - version = bump_version(base_version, args.bump) - else: - version = get_version_from_pyproject(args.root) - - # Update all versioned files - update_pyproject_version(args.root, version) - update_version_py(args.root, version) - update_openclaw_package_json( - args.root / "plugins" / "openclaw" / "package.json", version, version - ) - update_package_json(args.root / "sdk" / "typescript" / "package.json", version) - write_release_metadata(args.root, version) - - print(f"Version synchronized to {version}") - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""Synchronize version across all headroom packages.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +import tomllib + + +def get_version_from_pyproject(root: Path) -> str: + """Read version from pyproject.toml.""" + pyproject_path = root / "pyproject.toml" + with open(pyproject_path, "rb") as f: + data = tomllib.load(f) + return data["project"]["version"] + + +def bump_version(version: str, bump_type: str) -> str: + """Bump version according to bump_type (major, minor, patch).""" + major, minor, patch = map(int, version.split(".")) + if bump_type == "major": + major += 1 + minor = 0 + patch = 0 + elif bump_type == "minor": + minor += 1 + patch = 0 + elif bump_type == "patch": + patch += 1 + return f"{major}.{minor}.{patch}" + + +def update_version_py(root: Path, version: str) -> None: + """Update headroom/_version.py with new version.""" + version_py_path = root / "headroom" / "_version.py" + content = version_py_path.read_text(encoding="utf-8") + updated = re.sub( + r'__version__ = "[^"]+"', + f'__version__ = "{version}"', + content, + ) + version_py_path.write_text(updated, encoding="utf-8") + + +def update_package_json(file_path: Path, version: str) -> None: + """Update a package.json version field.""" + 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) + f.write("\n") + + +def update_plugin_manifest(file_path: Path, version: str) -> None: + """Update a plugin.json version field.""" + 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) + f.write("\n") + + +def update_marketplace_manifest(file_path: Path, version: str) -> None: + """Update marketplace metadata and plugin entry versions.""" + with open(file_path, encoding="utf-8") as f: + data = json.load(f) + metadata = data.get("metadata") + if isinstance(metadata, dict): + metadata["version"] = version + plugins = data.get("plugins") + if isinstance(plugins, list): + for plugin in plugins: + if isinstance(plugin, dict): + plugin["version"] = version + with open(file_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + 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) + update_marketplace_manifest(root / ".github" / "plugin" / "marketplace.json", version) + update_plugin_manifest( + root / "plugins" / "headroom-agent-hooks" / ".claude-plugin" / "plugin.json", version + ) + update_plugin_manifest( + root / "plugins" / "headroom-agent-hooks" / ".github" / "plugin" / "plugin.json", + version, + ) + + +def update_openclaw_package_json(file_path: Path, version: str, sdk_version: str) -> None: + """Update openclaw package.json version and headroom-ai dependency range.""" + with open(file_path, encoding="utf-8") as f: + data = json.load(f) + data["version"] = version + if "dependencies" in data and "headroom-ai" in data["dependencies"]: + data["dependencies"]["headroom-ai"] = f"^{sdk_version}" + with open(file_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") + + +def update_pyproject_version(root: Path, version: str) -> None: + """Update pyproject.toml version.""" + pyproject_path = root / "pyproject.toml" + content = pyproject_path.read_text(encoding="utf-8") + updated = re.sub( + r'^version = "[^"]+"', + f'version = "{version}"', + content, + flags=re.MULTILINE, + ) + pyproject_path.write_text(updated, encoding="utf-8") + + +def write_release_metadata(root: Path, version: str) -> None: + """Write .releaseetadata JSON file.""" + metadata = { + "version": version, + "packages": { + "pypi": version, + "npm-sdk": version, + "npm-openclaw": version, + "agent-hooks-plugin": version, + }, + } + metadata_path = root / ".releaseetadata" + with open(metadata_path, "w", encoding="utf-8") as f: + json.dump(metadata, f, indent=2) + f.write("\n") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Synchronize version across headroom packages") + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).parent.parent, + help="Root directory of the project", + ) + group = parser.add_mutually_exclusive_group() + group.add_argument("--version", help="Explicit version to set (e.g., 0.6.0)") + group.add_argument( + "--bump", + choices=["major", "minor", "patch"], + help="Bump version from pyproject.toml", + ) + parser.add_argument( + "--plugin-manifests-only", + action="store_true", + help="Only update marketplace/plugin manifest versions", + ) + args = parser.parse_args() + + if args.version: + version = args.version + elif args.bump: + base_version = get_version_from_pyproject(args.root) + version = bump_version(base_version, args.bump) + else: + version = get_version_from_pyproject(args.root) + + if args.plugin_manifests_only: + update_plugin_versions(args.root, version) + print(f"Plugin versions synchronized to {version}") + return + + # Update all versioned files + update_pyproject_version(args.root, version) + update_version_py(args.root, version) + update_openclaw_package_json( + args.root / "plugins" / "openclaw" / "package.json", version, version + ) + update_package_json(args.root / "sdk" / "typescript" / "package.json", version) + update_plugin_versions(args.root, version) + write_release_metadata(args.root, version) + + print(f"Version synchronized to {version}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_plugin_manifests.py b/tests/test_plugin_manifests.py index a3cd7b083..1a80c45d2 100644 --- a/tests/test_plugin_manifests.py +++ b/tests/test_plugin_manifests.py @@ -38,3 +38,18 @@ def test_marketplace_entry_points_to_plugin_root() -> None: assert plugin_root.is_dir() assert (plugin_root / ".claude-plugin" / "plugin.json").is_file() assert (plugin_root / "hooks" / "hooks.json").is_file() + + +def test_plugin_metadata_points_to_upstream_repo() -> None: + expected_repo = "https://github.com/chopratejas/headroom" + marketplace = _load_json(".claude-plugin/marketplace.json") + claude = _load_json("plugins/headroom-agent-hooks/.claude-plugin/plugin.json") + assert isinstance(marketplace, dict) + assert isinstance(claude, dict) + plugin = marketplace["plugins"][0] + assert plugin["author"]["url"] == expected_repo + assert plugin["homepage"] == expected_repo + assert plugin["repository"] == expected_repo + assert claude["author"]["url"] == expected_repo + assert claude["homepage"] == expected_repo + assert claude["repository"] == expected_repo