Files
agent-pet-companion/script/validate_codex_plugin_version.py
2026-08-11 12:08:39 +08:00

207 lines
7.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Require a Codex plugin version bump whenever its shipped bundle changes."""
from __future__ import annotations
import argparse
import json
import pathlib
import re
import subprocess
import sys
from typing import Any
ROOT = pathlib.Path(__file__).resolve().parents[1]
MANIFEST = pathlib.PurePosixPath("plugins/codex/.codex-plugin/plugin.json")
HOOKS_TEMPLATE = pathlib.PurePosixPath("plugins/codex/hooks/hooks.json.tpl")
VERSIONED_SKILLS = (
pathlib.PurePosixPath("skills/agent-pet-studio/SKILL.md"),
pathlib.PurePosixPath("skills/agent-pet-maker/SKILL.md"),
)
HOOKS_ALLOWED_TOP_LEVEL_FIELDS = frozenset(("description", "hooks"))
SKILL_VERSION_PATTERN = re.compile(r"^version:[ \t]*(\S+)[ \t]*$", re.MULTILINE)
PLUGIN_BUNDLE_PATHS = (
"plugins/codex",
"skills/agent-pet-maker",
"skills/agent-pet-studio",
)
SEMVER_PATTERN = re.compile(
r"(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)"
)
def parse_version(value: Any, *, source: str) -> tuple[int, int, int]:
if not isinstance(value, str):
raise ValueError(f"{source} plugin version must be a string")
match = SEMVER_PATTERN.fullmatch(value)
if match is None:
raise ValueError(f"{source} plugin version must be strict X.Y.Z SemVer")
return tuple(int(component) for component in match.groups())
def load_manifest_bytes(data: bytes, *, source: str) -> tuple[int, int, int]:
try:
value = json.loads(data)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError(f"{source} plugin manifest is not valid UTF-8 JSON") from error
if not isinstance(value, dict):
raise ValueError(f"{source} plugin manifest must be a JSON object")
if value.get("name") != "agent-pet-companion":
raise ValueError(f"{source} plugin manifest has the wrong plugin name")
if value.get("hooks") != "./hooks/hooks.json":
raise ValueError(f"{source} plugin manifest must expose the bundled hooks file")
if value.get("skills") != "./skills/":
raise ValueError(f"{source} plugin manifest must expose only the bundled skills root")
return parse_version(value.get("version"), source=source)
def git(*arguments: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
return subprocess.run(
["git", "-C", str(ROOT), *arguments],
check=check,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
def resolve_commit(reference: str) -> str:
if not reference or reference.startswith("-") or any(
character in reference for character in "\r\n\0"
):
raise ValueError("--base-ref must be a non-option, single-line Git reference")
result = git("rev-parse", "--verify", f"{reference}^{{commit}}")
commit = result.stdout.decode("ascii").strip()
if re.fullmatch(r"[0-9a-f]{40}", commit) is None:
raise ValueError("--base-ref did not resolve to one full Git commit")
return commit
def read_base_manifest(commit: str) -> bytes:
return read_base_file(commit, MANIFEST, description="Codex plugin manifest")
def read_base_file(
commit: str,
path: pathlib.PurePosixPath,
*,
description: str,
) -> bytes:
result = git("show", f"{commit}:{path.as_posix()}", check=False)
if result.returncode != 0:
raise ValueError(f"base commit does not contain the {description}")
return result.stdout
def skill_front_matter_version(data: bytes, *, source: str) -> tuple[int, int, int]:
try:
text = data.decode("utf-8")
except UnicodeDecodeError as error:
raise ValueError(f"{source} Skill is not valid UTF-8") from error
if not text.startswith("---\n"):
raise ValueError(f"{source} Skill must start with YAML front matter")
front_matter, separator, _ = text[4:].partition("\n---")
if not separator:
raise ValueError(f"{source} Skill front matter is not terminated")
matches = SKILL_VERSION_PATTERN.findall(front_matter)
if len(matches) != 1:
raise ValueError(
f"{source} Skill front matter must declare exactly one version: line"
)
return parse_version(matches[0], source=f"{source} Skill")
def hooks_template_uses_supported_schema(data: bytes, *, source: str) -> None:
try:
value = json.loads(data)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError(f"{source} hooks template is not valid UTF-8 JSON") from error
if not isinstance(value, dict) or not isinstance(value.get("hooks"), dict):
raise ValueError(f"{source} hooks template must contain a hooks object")
unsupported = sorted(set(value) - HOOKS_ALLOWED_TOP_LEVEL_FIELDS)
if unsupported:
raise ValueError(
f"{source} hooks template contains unsupported top-level fields: "
f"{', '.join(unsupported)}"
)
def bundle_changed(commit: str) -> bool:
result = git(
"diff",
"--quiet",
commit,
"--",
*PLUGIN_BUNDLE_PATHS,
check=False,
)
if result.returncode not in (0, 1):
raise ValueError("Git could not compare the Codex plugin bundle")
return result.returncode == 1
def validate(base_reference: str) -> tuple[str, str, bool]:
current_path = ROOT / MANIFEST
current = load_manifest_bytes(current_path.read_bytes(), source="current")
commit = resolve_commit(base_reference)
previous = load_manifest_bytes(read_base_manifest(commit), source="base")
changed = bundle_changed(commit)
if current < previous:
raise ValueError(
"Codex plugin version must never decrease "
f"({'.'.join(map(str, previous))} -> {'.'.join(map(str, current))})"
)
if changed and current <= previous:
raise ValueError(
"Codex plugin, agent-pet-maker, or agent-pet-studio content changed "
"without increasing plugins/codex/.codex-plugin/plugin.json version"
)
# Codex rejects unknown top-level Hook metadata. Hook staleness is therefore
# bound by the plugin manifest plus exact bundle-content verification, while
# separately parsed Skills keep their own matching version markers.
hooks_template_uses_supported_schema(
(ROOT / HOOKS_TEMPLATE).read_bytes(),
source="current",
)
for skill in VERSIONED_SKILLS:
skill_version = skill_front_matter_version(
(ROOT / skill).read_bytes(),
source=skill.as_posix(),
)
if skill_version != current:
raise ValueError(
f"{skill.as_posix()} declares version "
f"{'.'.join(map(str, skill_version))} but the Codex plugin ships "
f"{'.'.join(map(str, current))}"
)
return (
".".join(map(str, previous)),
".".join(map(str, current)),
changed,
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--base-ref", required=True)
arguments = parser.parse_args()
try:
previous, current, changed = validate(arguments.base_ref)
except (OSError, subprocess.CalledProcessError, ValueError) as error:
print(f"Codex plugin version validation failed: {error}", file=sys.stderr)
return 1
state = "changed with a required version increase" if changed else "unchanged"
print(f"Codex plugin bundle {state}: {previous} -> {current}")
print(
f"Plugin and both Skills declare version {current}; "
"hooks use the supported top-level schema"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())