diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 436f457..c8ac934 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -10,11 +10,17 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: '3.13' - run: python -B -m unittest discover -s scripts -p 'test_*.py' - run: python -B scripts/check_package.py + - if: github.event_name == 'pull_request' + run: python -B scripts/check_package.py --base-sha "$BASE_SHA" + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} - uses: actions/setup-node@v4 with: node-version: '22' diff --git a/scripts/check_package.py b/scripts/check_package.py index 089ef1a..c05c4aa 100644 --- a/scripts/check_package.py +++ b/scripts/check_package.py @@ -3,15 +3,22 @@ from __future__ import annotations +import argparse import json import re import stat +import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SKILLS = ("sofa", "sofa-contribute", "sofa-status") MCP_URL = "https://agents.stackoverflow.com/mcp" +VERSIONED_CONTENT = ( + ".mcp.json", + "plugin-guidance.json", + *(f"skills/{name}/SKILL.md" for name in SKILLS), +) PACKAGE_DIRECTORIES = { ".claude-plugin", ".github", @@ -149,9 +156,54 @@ def check_package() -> None: raise ValueError("README needs at least 40 non-code words") +def version_parts(value: object) -> tuple[int, int, int]: + if not isinstance(value, str) or not re.fullmatch(r"\d+\.\d+\.\d+", value): + raise ValueError("base or proposed manifest version must be major.minor.patch") + major, minor, patch = value.split(".") + return int(major), int(minor), int(patch) + + +def check_version_against_base(base_sha: str) -> None: + """Require a higher reviewer-chosen version when release content changes.""" + if not re.fullmatch(r"[0-9a-f]{40}", base_sha): + raise ValueError("base SHA must be a full Git commit ID") + + baseline = subprocess.run( + ["git", "show", f"{base_sha}:.claude-plugin/plugin.json"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + if baseline.returncode != 0: + raise ValueError("cannot read the base plugin manifest") + + base_version = version_parts(json.loads(baseline.stdout).get("version")) + proposed_version = version_parts(read_json(".claude-plugin/plugin.json").get("version")) + if proposed_version < base_version: + raise ValueError("manifest version must not decrease from public main") + + difference = subprocess.run( + ["git", "diff", "--quiet", base_sha, "--", *VERSIONED_CONTENT], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + if difference.returncode not in (0, 1): + raise ValueError("cannot compare release content with public main") + if difference.returncode == 1 and proposed_version == base_version: + raise ValueError("changed plugin guidance or behavior needs a higher manifest version") + + if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-sha", help="public main commit to compare for PR version policy") + args = parser.parse_args() try: check_package() + if args.base_sha is not None: + check_version_against_base(args.base_sha) except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as error: sys.stderr.write(f"Package validation failed: {error}\n") raise SystemExit(1) from error diff --git a/scripts/test_check_package.py b/scripts/test_check_package.py index cbd086a..67a62fb 100644 --- a/scripts/test_check_package.py +++ b/scripts/test_check_package.py @@ -4,6 +4,8 @@ import json import shutil +import subprocess +import sys import tempfile import unittest from pathlib import Path @@ -27,6 +29,34 @@ def check_staged_package(self) -> None: with patch.object(check_package, "ROOT", self.root): check_package.check_package() + def set_version(self, version: str) -> None: + manifest_path = self.root / ".claude-plugin/plugin.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["version"] = version + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + def commit_baseline(self) -> str: + subprocess.run(["git", "init", "-q"], cwd=self.root, check=True) + subprocess.run(["git", "add", "."], cwd=self.root, check=True) + subprocess.run( + [ + "git", "-c", "user.name=SOFA Test", "-c", "user.email=test@example.com", + "-c", "commit.gpgsign=false", "commit", "-qm", "baseline", + ], + cwd=self.root, + check=True, + ) + return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=self.root, text=True).strip() + + def run_version_check(self, base_sha: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-B", "scripts/check_package.py", "--base-sha", base_sha], + cwd=self.root, + capture_output=True, + text=True, + check=False, + ) + def test_intact_package_with_license(self) -> None: self.check_staged_package() @@ -68,6 +98,48 @@ def test_different_author_name_is_rejected(self) -> None: with self.assertRaisesRegex(ValueError, "manifest author"): self.check_staged_package() + def test_changed_skill_requires_higher_version(self) -> None: + base_sha = self.commit_baseline() + skill = self.root / "skills/sofa/SKILL.md" + skill.write_text(skill.read_text(encoding="utf-8") + "\nUpdated guidance.\n", encoding="utf-8") + + result = self.run_version_check(base_sha) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("version", result.stderr) + + def test_numeric_version_increase_allows_changed_guidance(self) -> None: + self.set_version("0.1.9") + base_sha = self.commit_baseline() + guidance = self.root / "plugin-guidance.json" + guidance.write_text(guidance.read_text(encoding="utf-8") + "\n", encoding="utf-8") + self.set_version("0.1.10") + + self.assertEqual(self.run_version_check(base_sha).returncode, 0) + + def test_unrelated_change_does_not_require_version_bump(self) -> None: + base_sha = self.commit_baseline() + readme = self.root / "README.md" + readme.write_text(readme.read_text(encoding="utf-8") + "\nA documentation correction.\n", encoding="utf-8") + + self.assertEqual(self.run_version_check(base_sha).returncode, 0) + + def test_version_decrease_fails_without_guidance_change(self) -> None: + self.set_version("0.2.0") + base_sha = self.commit_baseline() + self.set_version("0.1.9") + + result = self.run_version_check(base_sha) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("version", result.stderr) + + def test_empty_base_sha_fails_closed(self) -> None: + result = self.run_version_check("") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("base SHA", result.stderr) + if __name__ == "__main__": unittest.main()