Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
52 changes: 52 additions & 0 deletions scripts/check_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions scripts/test_check_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import json
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
Expand All @@ -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()

Expand Down Expand Up @@ -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()
Loading