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.
This commit is contained in:
Executable
+242
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify synchronized version bumps and native marketplace packaging."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PLUGIN_NAME = "diagram-design"
|
||||
MANIFEST_PATHS = {
|
||||
"Claude": Path(".claude-plugin/plugin.json"),
|
||||
"Codex": Path(".codex-plugin/plugin.json"),
|
||||
}
|
||||
CLAUDE_MARKETPLACE = Path(".claude-plugin/marketplace.json")
|
||||
CODEX_MARKETPLACE = Path(".agents/plugins/marketplace.json")
|
||||
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
|
||||
|
||||
|
||||
def load_json(path: Path, errors: list[str]) -> dict[str, Any] | None:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
errors.append(f"could not read {path}: {exc}")
|
||||
return None
|
||||
except json.JSONDecodeError as exc:
|
||||
errors.append(f"{path} is not valid JSON: {exc}")
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
errors.append(f"{path} must contain a JSON object")
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def load_base_json(root: Path, base_ref: str, relative: Path, errors: list[str]) -> dict[str, Any] | None:
|
||||
result = subprocess.run(
|
||||
["git", "show", f"{base_ref}:{relative.as_posix()}"],
|
||||
cwd=root,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or "file not found"
|
||||
errors.append(f"could not read {relative} from {base_ref}: {detail}")
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
errors.append(f"{relative} at {base_ref} is not valid JSON: {exc}")
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
errors.append(f"{relative} at {base_ref} must contain a JSON object")
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def parse_semver(value: object, label: str, errors: list[str]) -> tuple[int, int, int] | None:
|
||||
if not isinstance(value, str) or (match := SEMVER.fullmatch(value)) is None:
|
||||
errors.append(
|
||||
f"{label} version must be strict MAJOR.MINOR.PATCH semver; got {value!r}"
|
||||
)
|
||||
return None
|
||||
return tuple(int(part) for part in match.groups()) # type: ignore[return-value]
|
||||
|
||||
|
||||
def resolve_local_path(root: Path, raw_path: object, label: str, errors: list[str]) -> Path | None:
|
||||
if not isinstance(raw_path, str) or not raw_path:
|
||||
errors.append(f"{label} must be a non-empty local path string")
|
||||
return None
|
||||
if raw_path not in {".", "./"} and not raw_path.startswith("./"):
|
||||
errors.append(f"{label} must be '.' or start with './'; got {raw_path!r}")
|
||||
return None
|
||||
|
||||
relative = raw_path[2:] if raw_path.startswith("./") else ""
|
||||
relative_path = Path(relative)
|
||||
if relative_path.is_absolute() or ".." in relative_path.parts:
|
||||
errors.append(f"{label} must stay inside the marketplace root; got {raw_path!r}")
|
||||
return None
|
||||
|
||||
resolved_root = root.resolve()
|
||||
resolved = (root / relative_path).resolve()
|
||||
if not resolved.is_relative_to(resolved_root):
|
||||
errors.append(f"{label} resolves outside the marketplace root: {raw_path!r}")
|
||||
return None
|
||||
if not resolved.is_dir():
|
||||
errors.append(f"{label} target does not exist or is not a directory: {raw_path!r}")
|
||||
return None
|
||||
return resolved
|
||||
|
||||
|
||||
def find_plugin_entry(
|
||||
marketplace: dict[str, Any], label: str, errors: list[str]
|
||||
) -> dict[str, Any] | None:
|
||||
if marketplace.get("name") != PLUGIN_NAME:
|
||||
errors.append(
|
||||
f"{label} marketplace name must remain {PLUGIN_NAME!r}; "
|
||||
f"got {marketplace.get('name')!r}"
|
||||
)
|
||||
plugins = marketplace.get("plugins")
|
||||
if not isinstance(plugins, list):
|
||||
errors.append(f"{label} marketplace plugins must be an array")
|
||||
return None
|
||||
matches = [entry for entry in plugins if isinstance(entry, dict) and entry.get("name") == PLUGIN_NAME]
|
||||
if len(matches) != 1:
|
||||
errors.append(
|
||||
f"{label} marketplace must contain exactly one {PLUGIN_NAME!r} entry; "
|
||||
f"found {len(matches)}"
|
||||
)
|
||||
return None
|
||||
return matches[0]
|
||||
|
||||
|
||||
def verify_versions(
|
||||
root: Path,
|
||||
base_ref: str,
|
||||
manifests: dict[str, dict[str, Any]],
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
current_versions = {label: payload.get("version") for label, payload in manifests.items()}
|
||||
version_values = list(current_versions.values())
|
||||
if any(version != version_values[0] for version in version_values[1:]):
|
||||
rendered = ", ".join(f"{label}={value!r}" for label, value in current_versions.items())
|
||||
errors.append(f"Claude and Codex manifest versions must match: {rendered}")
|
||||
|
||||
for label, relative in MANIFEST_PATHS.items():
|
||||
current = parse_semver(current_versions.get(label), f"current {label}", errors)
|
||||
base_manifest = load_base_json(root, base_ref, relative, errors)
|
||||
if base_manifest is None:
|
||||
continue
|
||||
base = parse_semver(base_manifest.get("version"), f"{label} at {base_ref}", errors)
|
||||
if current is not None and base is not None and current <= base:
|
||||
errors.append(
|
||||
f"{label} manifest version must increase relative to {base_ref}: "
|
||||
f"{base_manifest.get('version')} -> {current_versions.get(label)}"
|
||||
)
|
||||
|
||||
|
||||
def verify_manifest_identity(manifests: dict[str, dict[str, Any]], errors: list[str]) -> None:
|
||||
for label, payload in manifests.items():
|
||||
if payload.get("name") != PLUGIN_NAME:
|
||||
errors.append(
|
||||
f"{label} manifest name must remain {PLUGIN_NAME!r}; got {payload.get('name')!r}"
|
||||
)
|
||||
|
||||
|
||||
def verify_marketplaces(root: Path, errors: list[str]) -> None:
|
||||
claude_marketplace = load_json(root / CLAUDE_MARKETPLACE, errors)
|
||||
codex_marketplace = load_json(root / CODEX_MARKETPLACE, errors)
|
||||
if claude_marketplace is None or codex_marketplace is None:
|
||||
return
|
||||
|
||||
claude_entry = find_plugin_entry(claude_marketplace, "Claude", errors)
|
||||
codex_entry = find_plugin_entry(codex_marketplace, "Codex", errors)
|
||||
if claude_entry is None or codex_entry is None:
|
||||
return
|
||||
|
||||
claude_root = resolve_local_path(root, claude_entry.get("source"), "Claude plugin source", errors)
|
||||
|
||||
codex_source = codex_entry.get("source")
|
||||
if not isinstance(codex_source, dict) or codex_source.get("source") != "local":
|
||||
errors.append("Codex plugin source must be an object with source='local'")
|
||||
codex_root = None
|
||||
else:
|
||||
codex_root = resolve_local_path(root, codex_source.get("path"), "Codex plugin source.path", errors)
|
||||
|
||||
policy = codex_entry.get("policy")
|
||||
if not isinstance(policy, dict):
|
||||
errors.append("Codex marketplace entry must include a policy object")
|
||||
else:
|
||||
if policy.get("installation") != "AVAILABLE":
|
||||
errors.append("Codex policy.installation must be 'AVAILABLE'")
|
||||
if policy.get("authentication") != "ON_INSTALL":
|
||||
errors.append("Codex policy.authentication must be 'ON_INSTALL'")
|
||||
if not isinstance(codex_entry.get("category"), str) or not codex_entry.get("category"):
|
||||
errors.append("Codex marketplace entry must include a category")
|
||||
|
||||
if claude_root is not None and not (claude_root / MANIFEST_PATHS["Claude"]).is_file():
|
||||
errors.append("Claude marketplace target does not contain .claude-plugin/plugin.json")
|
||||
if codex_root is not None and not (codex_root / MANIFEST_PATHS["Codex"]).is_file():
|
||||
errors.append("Codex marketplace target does not contain .codex-plugin/plugin.json")
|
||||
if claude_root is not None and codex_root is not None and claude_root != codex_root:
|
||||
errors.append("Claude and Codex marketplaces must package the same plugin root")
|
||||
|
||||
plugin_root = codex_root or claude_root
|
||||
if plugin_root is None:
|
||||
return
|
||||
skill = plugin_root / "skills" / PLUGIN_NAME / "SKILL.md"
|
||||
if not skill.is_file():
|
||||
errors.append(f"packaged skill is missing: {skill.relative_to(root)}")
|
||||
|
||||
|
||||
def verify_codex_skill_path(root: Path, codex_manifest: dict[str, Any], errors: list[str]) -> None:
|
||||
skills_root = resolve_local_path(root, codex_manifest.get("skills"), "Codex manifest skills", errors)
|
||||
if skills_root is None:
|
||||
return
|
||||
skill = skills_root / PLUGIN_NAME / "SKILL.md"
|
||||
if not skill.is_file():
|
||||
errors.append(f"Codex skills path does not contain {PLUGIN_NAME}/SKILL.md")
|
||||
|
||||
|
||||
def verify_package(root: Path, base_ref: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
manifests: dict[str, dict[str, Any]] = {}
|
||||
for label, relative in MANIFEST_PATHS.items():
|
||||
payload = load_json(root / relative, errors)
|
||||
if payload is not None:
|
||||
manifests[label] = payload
|
||||
|
||||
if len(manifests) == len(MANIFEST_PATHS):
|
||||
verify_versions(root, base_ref, manifests, errors)
|
||||
verify_manifest_identity(manifests, errors)
|
||||
verify_codex_skill_path(root, manifests["Codex"], errors)
|
||||
verify_marketplaces(root, errors)
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print(f"Usage: {Path(sys.argv[0]).name} <base-ref>", file=sys.stderr)
|
||||
return 2
|
||||
base_ref = sys.argv[1]
|
||||
errors = verify_package(ROOT, base_ref)
|
||||
if errors:
|
||||
print("FAIL plugin package")
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
return 1
|
||||
versions = load_json(ROOT / MANIFEST_PATHS["Claude"], [])["version"]
|
||||
print(
|
||||
f"OK plugin package: Claude and Codex {versions}, "
|
||||
f"marketplace paths, and packaged skill"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user