Files
diagram-design/scripts/test-plugin-package.py
T
Cathryn Lavery b6cae1e2e6 feat(plugin): automatic updates via native marketplaces and a version gate
Installs previously drifted: a standalone `npx skills add` copy never
followed the repo, and Claude Code disables auto-update for third-party
marketplaces by default. Route every client through its own marketplace so
merged changes reach users through the native update path.

- Add `.agents/plugins/marketplace.json` so Codex resolves the plugin from
  a Git marketplace it refreshes at startup.
- Add `author` to the Claude manifest so both native manifests agree.
- Add `scripts/bump-plugin-version.py` to increment the Claude and Codex
  versions together; it refuses to run when they already differ.
- Add `scripts/verify-plugin-package.py` to gate synchronized, strictly
  increasing semver against the base ref, marketplace paths that stay
  inside the repo root, and the packaged skill.
- Add `scripts/test-plugin-package.py` covering the adversarial cases:
  missing bump, mismatched manifests, malformed semver, and a marketplace
  target that does not exist.
- Add a `plugin-package` CI job running the tests, the version gate against
  the PR base sha, and `claude plugin validate . --strict`.
- Document the per-PR bump requirement, the new gates, and the per-client
  install/update paths, including the one-time migration off standalone
  copies.

Version bumps 2.3.0 -> 2.3.1 to satisfy the new gate.
2026-08-12 20:34:32 -07:00

202 lines
6.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Regression tests for plugin versioning and marketplace package verification."""
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
import tempfile
from contextlib import contextmanager
from pathlib import Path
from types import ModuleType
from typing import Iterator
ROOT = Path(__file__).resolve().parent.parent
VERIFY_SCRIPT = ROOT / "scripts/verify-plugin-package.py"
BUMP_SCRIPT = ROOT / "scripts/bump-plugin-version.py"
PLUGIN_NAME = "diagram-design"
def load_module(name: str, path: Path) -> ModuleType:
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise AssertionError(f"could not load {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
VERIFY = load_module("verify_plugin_package", VERIFY_SCRIPT)
BUMP = load_module("bump_plugin_version", BUMP_SCRIPT)
def write_json(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
def manifest(version: str, codex: bool = False) -> dict:
payload = {
"name": PLUGIN_NAME,
"description": "Create editorial diagrams.",
"version": version,
"author": {"name": "Cathryn Lavery"},
}
if codex:
payload["skills"] = "./skills/"
return payload
def seed_package(root: Path, version: str = "1.2.3") -> None:
write_json(root / ".claude-plugin/plugin.json", manifest(version))
write_json(root / ".codex-plugin/plugin.json", manifest(version, codex=True))
write_json(
root / ".claude-plugin/marketplace.json",
{
"name": PLUGIN_NAME,
"plugins": [{"name": PLUGIN_NAME, "source": "./"}],
},
)
write_json(
root / ".agents/plugins/marketplace.json",
{
"name": PLUGIN_NAME,
"plugins": [
{
"name": PLUGIN_NAME,
"source": {"source": "local", "path": "./"},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL",
},
"category": "Productivity",
}
],
},
)
skill = root / "skills" / PLUGIN_NAME / "SKILL.md"
skill.parent.mkdir(parents=True, exist_ok=True)
skill.write_text(f"---\nname: {PLUGIN_NAME}\n---\n", encoding="utf-8")
@contextmanager
def package_repo() -> Iterator[Path]:
with tempfile.TemporaryDirectory() as scratch:
root = Path(scratch)
seed_package(root)
subprocess.run(["git", "init", "-q"], cwd=root, check=True)
subprocess.run(["git", "config", "user.name", "Package Test"], cwd=root, check=True)
subprocess.run(
["git", "config", "user.email", "package-test@example.invalid"],
cwd=root,
check=True,
)
subprocess.run(["git", "add", "."], cwd=root, check=True)
subprocess.run(["git", "commit", "-qm", "base package"], cwd=root, check=True)
yield root
def set_versions(root: Path, claude: str, codex: str) -> None:
for relative, version in (
(Path(".claude-plugin/plugin.json"), claude),
(Path(".codex-plugin/plugin.json"), codex),
):
payload = json.loads((root / relative).read_text(encoding="utf-8"))
payload["version"] = version
write_json(root / relative, payload)
def expect_failure(label: str, errors: list[str], needle: str) -> None:
if not any(needle in error for error in errors):
raise AssertionError(f"{label}: expected {needle!r}, got {errors}")
print(f"OK: {label} rejected")
def test_verifier() -> None:
with package_repo() as root:
set_versions(root, "1.2.4", "1.2.4")
errors = VERIFY.verify_package(root, "HEAD")
if errors:
raise AssertionError(f"valid bump failed: {errors}")
print("OK: valid synchronized bump accepted")
with package_repo() as root:
expect_failure(
"missing bump",
VERIFY.verify_package(root, "HEAD"),
"must increase",
)
with package_repo() as root:
set_versions(root, "1.2.4", "1.2.5")
expect_failure(
"mismatched manifests",
VERIFY.verify_package(root, "HEAD"),
"versions must match",
)
with package_repo() as root:
set_versions(root, "1.2", "1.2")
expect_failure(
"malformed versions",
VERIFY.verify_package(root, "HEAD"),
"strict MAJOR.MINOR.PATCH",
)
with package_repo() as root:
set_versions(root, "1.2.4", "1.2.4")
marketplace_path = root / ".agents/plugins/marketplace.json"
marketplace = json.loads(marketplace_path.read_text(encoding="utf-8"))
marketplace["plugins"][0]["source"]["path"] = "./missing"
write_json(marketplace_path, marketplace)
expect_failure(
"missing marketplace target",
VERIFY.verify_package(root, "HEAD"),
"target does not exist",
)
def test_bumper() -> None:
cases = (("patch", "1.2.4"), ("minor", "1.3.0"), ("major", "2.0.0"))
for part, expected in cases:
with tempfile.TemporaryDirectory() as scratch:
root = Path(scratch)
seed_package(root)
actual = BUMP.bump(root, part)
versions = {
json.loads((root / relative).read_text(encoding="utf-8"))["version"]
for relative in BUMP.MANIFEST_PATHS
}
if actual != expected or versions != {expected}:
raise AssertionError(
f"{part} bump: expected {expected}, got {actual} and {versions}"
)
print(f"OK: {part} bump produced {expected}")
with tempfile.TemporaryDirectory() as scratch:
root = Path(scratch)
seed_package(root)
set_versions(root, "1.2.3", "1.2.4")
try:
BUMP.bump(root)
except BUMP.PackageVersionError as exc:
if "not synchronized" not in str(exc):
raise AssertionError(f"unexpected mismatch error: {exc}") from exc
else:
raise AssertionError("version bumper accepted mismatched manifests")
print("OK: version bumper rejects mismatched manifests")
def main() -> int:
test_verifier()
test_bumper()
print("All plugin package tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())