Files
agent-pet-companion/script/validate_codex_plugin_version.py
T

255 lines
9.1 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 hashlib
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")
STUDIO_SKILL = pathlib.PurePosixPath("skills/agent-pet-studio/SKILL.md")
STUDIO_SKILL_HISTORY = pathlib.PurePosixPath(
"crates/petcore/resources/codex-studio-skill-history.json"
)
RETIRED_V1_STUDIO_SKILL = pathlib.PurePosixPath(
"crates/petcore/tests/fixtures/retired-agent-pet-studio-v1.md"
)
STUDIO_SKILL_HISTORY_SCHEMA = "apc.codex-studio-skill-history.v1"
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 read_optional_base_file(
commit: str,
path: pathlib.PurePosixPath,
) -> bytes | None:
result = git("show", f"{commit}:{path.as_posix()}", check=False)
return result.stdout if result.returncode == 0 else None
def load_studio_skill_history(data: bytes, *, source: str) -> frozenset[str]:
try:
value = json.loads(data)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError(
f"{source} Studio Skill history is not valid UTF-8 JSON"
) from error
if not isinstance(value, dict) or set(value) != {
"schema_version",
"retired_sha256",
}:
raise ValueError(
f"{source} Studio Skill history must contain only schema_version "
"and retired_sha256"
)
if value.get("schema_version") != STUDIO_SKILL_HISTORY_SCHEMA:
raise ValueError(f"{source} Studio Skill history has the wrong schema")
retired = value.get("retired_sha256")
if not isinstance(retired, list) or not retired:
raise ValueError(
f"{source} Studio Skill history must contain retired SHA-256 values"
)
if any(
not isinstance(digest, str)
or re.fullmatch(r"[0-9a-f]{64}", digest) is None
for digest in retired
):
raise ValueError(
f"{source} Studio Skill history contains an invalid SHA-256"
)
if retired != sorted(set(retired)):
raise ValueError(
f"{source} Studio Skill history must be sorted and unique"
)
return frozenset(retired)
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, 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)
current_studio = (ROOT / STUDIO_SKILL).read_bytes()
base_studio = read_base_file(
commit,
STUDIO_SKILL,
description="agent-pet-studio Skill",
)
studio_changed = current_studio != base_studio
current_studio_digest = hashlib.sha256(current_studio).hexdigest()
base_studio_digest = hashlib.sha256(base_studio).hexdigest()
current_history = load_studio_skill_history(
(ROOT / STUDIO_SKILL_HISTORY).read_bytes(),
source="current",
)
base_history_bytes = read_optional_base_file(commit, STUDIO_SKILL_HISTORY)
base_history = (
load_studio_skill_history(base_history_bytes, source="base")
if base_history_bytes is not None
else None
)
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"
)
if current_studio_digest in current_history:
raise ValueError(
"current agent-pet-studio Skill SHA-256 must not be listed as retired"
)
if base_history is not None:
removed = base_history - current_history
if removed:
raise ValueError(
"Studio Skill history is append-only; retired SHA-256 values were removed: "
+ ", ".join(sorted(removed))
)
permitted_additions = {base_studio_digest} if studio_changed else set()
unexpected = (current_history - base_history) - permitted_additions
if unexpected:
raise ValueError(
"Studio Skill history added SHA-256 values that are not the previous "
"shipped Skill: "
+ ", ".join(sorted(unexpected))
)
else:
retired_v1_digest = hashlib.sha256(
(ROOT / RETIRED_V1_STUDIO_SKILL).read_bytes()
).hexdigest()
expected_bootstrap = {retired_v1_digest}
if studio_changed:
expected_bootstrap.add(base_studio_digest)
if current_history != expected_bootstrap:
raise ValueError(
"initial Studio Skill history must contain exactly the retired V1 "
"Skill and, when Studio changed, the previous shipped Skill"
)
if studio_changed and base_studio_digest not in current_history:
raise ValueError(
"agent-pet-studio changed without preserving the previous shipped Skill "
f"SHA-256 {base_studio_digest} in {STUDIO_SKILL_HISTORY.as_posix()}"
)
return (
".".join(map(str, previous)),
".".join(map(str, current)),
changed,
studio_changed,
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--base-ref", required=True)
arguments = parser.parse_args()
try:
previous, current, changed, studio_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}")
if studio_changed:
print("Previous shipped Studio Skill ownership digest is preserved")
return 0
if __name__ == "__main__":
raise SystemExit(main())