207 lines
7.5 KiB
Python
Executable File
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())
|