diff --git a/.github/workflows/plugin-version-check.yml b/.github/workflows/plugin-version-check.yml new file mode 100644 index 0000000..fedd4d6 --- /dev/null +++ b/.github/workflows/plugin-version-check.yml @@ -0,0 +1,59 @@ +name: Plugin Version Check + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + gate: + runs-on: ubuntu-latest + outputs: + run: ${{ steps.decide.outputs.run }} + steps: + - uses: actions/checkout@v4 + + - name: Decide whether a plugin directory changed + id: decide + env: + BASE_REF: ${{ github.base_ref }} + run: | + git fetch origin "$BASE_REF" --depth=1 + if git diff --name-only "origin/$BASE_REF" HEAD \ + | grep -qE '^plugins/[^/]+/'; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + fi + + check-versions: + needs: gate + if: needs.gate.outputs.run == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Fetch base branch + env: + BASE_REF: ${{ github.base_ref }} + run: git fetch origin "$BASE_REF" --depth=1 + + - name: Check plugin versions + env: + BASE_REF: ${{ github.base_ref }} + run: | + python3 scripts/check_plugin_versions.py --base-ref "origin/$BASE_REF" | tee "$GITHUB_STEP_SUMMARY" + exit "${PIPESTATUS[0]}" + + test-script: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pytest + run: pip install --user pytest + + - name: Run tests + run: python3 -m pytest scripts/test_check_plugin_versions.py -v diff --git a/plugins/dev-team/.claude-plugin/plugin.json b/plugins/dev-team/.claude-plugin/plugin.json index b4ad276..c7d3bfa 100644 --- a/plugins/dev-team/.claude-plugin/plugin.json +++ b/plugins/dev-team/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dev-team", - "version": "1.4.0", + "version": "1.5.0", "description": "Dev-team agent pipeline: planner, researcher, developer, reviewer, and debugger agents for implementing Jira tasks and fixing GitHub issues.", "commands": "./commands" } diff --git a/scripts/check_plugin_versions.py b/scripts/check_plugin_versions.py new file mode 100644 index 0000000..14ca159 --- /dev/null +++ b/scripts/check_plugin_versions.py @@ -0,0 +1,173 @@ +""" +check_plugin_versions.py — CI gate: require a plugin's version to be bumped +whenever a pull request touches files under that plugin's directory. + +Usage: + python3 check_plugin_versions.py --base-ref origin/main +""" + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import dataclass + +PLUGIN_FILE_RE = re.compile(r"^plugins/([^/]+)/") +SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +PLUGIN_MANIFEST_TEMPLATE = "plugins/{name}/.claude-plugin/plugin.json" + + +class PluginVersionError(Exception): + """Raised when a plugin's version field cannot be parsed or compared.""" + + +@dataclass +class CheckResult: + plugin: str + ok: bool + message: str + + +def run_git(args: list[str]) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], + capture_output=True, + text=True, + timeout=30, + ) + + +def get_changed_files(base_ref: str) -> list[str]: + result = run_git(["diff", "--name-only", base_ref, "HEAD"]) + if result.returncode != 0: + raise RuntimeError(f"git diff failed: {result.stderr.strip()}") + return [line for line in result.stdout.splitlines() if line.strip()] + + +def find_touched_plugins(changed_files: list[str]) -> set[str]: + plugins = set() + for path in changed_files: + match = PLUGIN_FILE_RE.match(path) + if match: + plugins.add(match.group(1)) + return plugins + + +def plugin_dir_exists_at_ref(plugin_name: str, ref: str) -> bool: + result = run_git(["ls-tree", "-d", "--name-only", ref, f"plugins/{plugin_name}"]) + return result.returncode == 0 and bool(result.stdout.strip()) + + +def read_version_json_at_ref(plugin_name: str, ref: str) -> str | None: + path = PLUGIN_MANIFEST_TEMPLATE.format(name=plugin_name) + result = run_git(["show", f"{ref}:{path}"]) + if result.returncode == 0: + return result.stdout + stderr = result.stderr.lower() + if "does not exist in" in stderr or "exists on disk, but not in" in stderr: + return None + raise RuntimeError(f"git show failed for {ref}:{path}: {result.stderr.strip()}") + + +def parse_version_from_json(json_text: str) -> str: + try: + data = json.loads(json_text) + except json.JSONDecodeError as exc: + raise PluginVersionError(f"invalid JSON: {exc}") from exc + version = data.get("version") if isinstance(data, dict) else None + if not isinstance(version, str) or not version.strip(): + raise PluginVersionError("missing or empty 'version' field") + return version + + +def _parse_semver(value: str) -> tuple[int, int, int]: + match = SEMVER_RE.match(value) + if not match: + raise PluginVersionError(f"'{value}' is not a valid MAJOR.MINOR.PATCH version") + major, minor, patch = match.groups() + return int(major), int(minor), int(patch) + + +def compare_semver(old: str, new: str) -> bool: + return _parse_semver(new) > _parse_semver(old) + + +def check_plugin(plugin_name: str, base_ref: str) -> CheckResult: + manifest_path = PLUGIN_MANIFEST_TEMPLATE.format(name=plugin_name) + + head_json = read_version_json_at_ref(plugin_name, "HEAD") + if head_json is None: + if not plugin_dir_exists_at_ref(plugin_name, "HEAD"): + return CheckResult(plugin_name, True, "plugin deleted in this PR — nothing to check") + return CheckResult( + plugin_name, + False, + f"{manifest_path} not found at HEAD — every plugin directory must have " + "a manifest with a version", + ) + + try: + head_version = parse_version_from_json(head_json) + except PluginVersionError as exc: + return CheckResult(plugin_name, False, f"invalid {manifest_path} at HEAD: {exc}") + + base_json = read_version_json_at_ref(plugin_name, base_ref) + if base_json is None: + return CheckResult( + plugin_name, True, f"new plugin (no version at base) — HEAD version {head_version} OK" + ) + + try: + base_version = parse_version_from_json(base_json) + except PluginVersionError: + return CheckResult( + plugin_name, + True, + f"base {manifest_path} unparsable; treating as no prior version — HEAD {head_version} OK", + ) + + try: + bumped = compare_semver(base_version, head_version) + except PluginVersionError as exc: + return CheckResult(plugin_name, False, f"invalid version comparison: {exc}") + + if not bumped: + return CheckResult( + plugin_name, + False, + f"version not bumped ({base_version} -> {head_version}); " + f"bump {manifest_path}'s 'version' to greater than {base_version}", + ) + return CheckResult(plugin_name, True, f"version bumped {base_version} -> {head_version}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Require a plugin's version to be bumped when its directory changes" + ) + parser.add_argument("--base-ref", required=True, help="Git ref to diff against (e.g. origin/main)") + args = parser.parse_args(argv) + + changed_files = get_changed_files(args.base_ref) + touched_plugins = find_touched_plugins(changed_files) + + if not touched_plugins: + print("No plugin directories touched; nothing to check.") + return 0 + + results = [check_plugin(name, args.base_ref) for name in sorted(touched_plugins)] + + for result in results: + status = "PASS" if result.ok else "FAIL" + print(f"[{status}] {result.plugin}: {result.message}") + + failures = [r for r in results if not r.ok] + if failures: + print(f"\n{len(failures)} plugin(s) failed the version check.") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_check_plugin_versions.py b/scripts/test_check_plugin_versions.py new file mode 100644 index 0000000..4700e5e --- /dev/null +++ b/scripts/test_check_plugin_versions.py @@ -0,0 +1,327 @@ +import subprocess +from unittest.mock import patch + +import pytest + +import check_plugin_versions as cpv + + +def make_completed_process(returncode=0, stdout="", stderr=""): + return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr) + + +def expect_run_git(responses): + """Patch cpv.run_git to answer with the response registered for each exact args list.""" + + def side_effect(args): + key = tuple(args) + if key not in responses: + raise AssertionError(f"Unexpected git args: {list(key)}") + return responses[key] + + return patch.object(cpv, "run_git", side_effect=side_effect) + + +def manifest_json(version): + return f'{{"name": "x", "version": "{version}"}}' + + +PLUGIN_MISSING_STDERR = ( + "fatal: path 'plugins/dev-team/.claude-plugin/plugin.json' does not exist in 'HEAD'" +) + + +# --- find_touched_plugins --------------------------------------------------- + + +def test_find_touched_plugins_file_under_plugin_dir_returns_plugin_name(): + changed = ["plugins/dev-team/skills/foo/SKILL.md"] + + result = cpv.find_touched_plugins(changed) + + assert result == {"dev-team"} + + +def test_find_touched_plugins_multiple_files_same_plugin_returns_single_name(): + changed = [ + "plugins/dev-team/skills/foo/SKILL.md", + "plugins/dev-team/.claude-plugin/plugin.json", + ] + + result = cpv.find_touched_plugins(changed) + + assert result == {"dev-team"} + + +def test_find_touched_plugins_multiple_plugins_returns_all_names(): + changed = [ + "plugins/dev-team/skills/foo/SKILL.md", + "plugins/other-plugin/.claude-plugin/plugin.json", + ] + + result = cpv.find_touched_plugins(changed) + + assert result == {"dev-team", "other-plugin"} + + +def test_find_touched_plugins_file_directly_under_plugins_root_returns_empty(): + changed = ["plugins/README.md"] + + result = cpv.find_touched_plugins(changed) + + assert result == set() + + +def test_find_touched_plugins_file_outside_plugins_dir_returns_empty(): + changed = ["README.md", "scripts/check_plugin_versions.py"] + + result = cpv.find_touched_plugins(changed) + + assert result == set() + + +# --- compare_semver ---------------------------------------------------------- + + +def test_compare_semver_head_greater_than_base_returns_true(): + assert cpv.compare_semver("1.4.0", "1.4.1") is True + + +def test_compare_semver_head_equal_to_base_returns_false(): + assert cpv.compare_semver("1.4.0", "1.4.0") is False + + +def test_compare_semver_head_less_than_base_returns_false(): + assert cpv.compare_semver("1.4.0", "1.3.9") is False + + +def test_compare_semver_invalid_head_string_raises_plugin_version_error(): + with pytest.raises(cpv.PluginVersionError): + cpv.compare_semver("1.4.0", "not-a-version") + + +def test_compare_semver_invalid_base_string_raises_plugin_version_error(): + with pytest.raises(cpv.PluginVersionError): + cpv.compare_semver("not-a-version", "1.4.0") + + +# --- parse_version_from_json -------------------------------------------------- + + +def test_parse_version_from_json_valid_json_returns_version_string(): + result = cpv.parse_version_from_json(manifest_json("1.4.0")) + + assert result == "1.4.0" + + +def test_parse_version_from_json_missing_version_field_raises_plugin_version_error(): + with pytest.raises(cpv.PluginVersionError): + cpv.parse_version_from_json('{"name": "x"}') + + +def test_parse_version_from_json_invalid_json_raises_plugin_version_error(): + with pytest.raises(cpv.PluginVersionError): + cpv.parse_version_from_json("{not valid json") + + +# --- read_version_json_at_ref ------------------------------------------------- + + +def test_read_version_json_at_ref_missing_path_returns_none(): + responses = { + ("show", "HEAD:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + returncode=128, stderr=PLUGIN_MISSING_STDERR + ), + } + + with expect_run_git(responses): + result = cpv.read_version_json_at_ref("dev-team", "HEAD") + + assert result is None + + +def test_read_version_json_at_ref_git_failure_raises_runtime_error(): + responses = { + ("show", "origin/main:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + returncode=128, stderr="fatal: invalid object name 'origin/main'." + ), + } + + with expect_run_git(responses): + with pytest.raises(RuntimeError): + cpv.read_version_json_at_ref("dev-team", "origin/main") + + +# --- check_plugin -------------------------------------------------------------- + + +def test_check_plugin_new_plugin_no_base_version_passes(): + responses = { + ("show", "HEAD:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("0.1.0") + ), + ("show", "origin/main:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + returncode=128, stderr=PLUGIN_MISSING_STDERR + ), + } + + with expect_run_git(responses): + result = cpv.check_plugin("dev-team", "origin/main") + + assert result.ok is True + + +def test_check_plugin_deleted_plugin_directory_passes(): + responses = { + ("show", "HEAD:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + returncode=128, stderr=PLUGIN_MISSING_STDERR + ), + ("ls-tree", "-d", "--name-only", "HEAD", "plugins/dev-team"): make_completed_process(stdout=""), + } + + with expect_run_git(responses): + result = cpv.check_plugin("dev-team", "origin/main") + + assert result.ok is True + + +def test_check_plugin_missing_manifest_at_head_fails(): + responses = { + ("show", "HEAD:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + returncode=128, stderr=PLUGIN_MISSING_STDERR + ), + ("ls-tree", "-d", "--name-only", "HEAD", "plugins/dev-team"): make_completed_process( + stdout="plugins/dev-team\n" + ), + } + + with expect_run_git(responses): + result = cpv.check_plugin("dev-team", "origin/main") + + assert result.ok is False + + +def test_check_plugin_version_unchanged_fails_with_bump_message(): + responses = { + ("show", "HEAD:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("1.4.0") + ), + ("show", "origin/main:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("1.4.0") + ), + } + + with expect_run_git(responses): + result = cpv.check_plugin("dev-team", "origin/main") + + assert result.ok is False + assert "1.4.0" in result.message + + +def test_check_plugin_version_lowered_fails(): + responses = { + ("show", "HEAD:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("1.3.0") + ), + ("show", "origin/main:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("1.4.0") + ), + } + + with expect_run_git(responses): + result = cpv.check_plugin("dev-team", "origin/main") + + assert result.ok is False + + +def test_check_plugin_version_bumped_higher_passes(): + responses = { + ("show", "HEAD:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("1.4.1") + ), + ("show", "origin/main:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("1.4.0") + ), + } + + with expect_run_git(responses): + result = cpv.check_plugin("dev-team", "origin/main") + + assert result.ok is True + + +def test_check_plugin_invalid_semver_at_head_fails_with_parse_error_message(): + responses = { + ("show", "HEAD:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("not-a-version") + ), + ("show", "origin/main:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("1.4.0") + ), + } + + with expect_run_git(responses): + result = cpv.check_plugin("dev-team", "origin/main") + + assert result.ok is False + assert "not-a-version" in result.message + + +def test_check_plugin_unparsable_base_json_treated_as_no_prior_version_passes(): + responses = { + ("show", "HEAD:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("1.4.0") + ), + ("show", "origin/main:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + stdout="{not valid json" + ), + } + + with expect_run_git(responses): + result = cpv.check_plugin("dev-team", "origin/main") + + assert result.ok is True + + +# --- main ------------------------------------------------------------------------ + + +def test_main_multiple_plugins_touched_reports_all_violations_not_just_first(capsys): + responses = { + ("diff", "--name-only", "origin/main", "HEAD"): make_completed_process( + stdout="plugins/dev-team/SKILL.md\nplugins/other-plugin/SKILL.md\n" + ), + ("show", "HEAD:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("1.4.0") + ), + ("show", "origin/main:plugins/dev-team/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("1.4.0") + ), + ("show", "HEAD:plugins/other-plugin/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("2.0.0") + ), + ("show", "origin/main:plugins/other-plugin/.claude-plugin/plugin.json"): make_completed_process( + stdout=manifest_json("1.0.0") + ), + } + + with expect_run_git(responses): + exit_code = cpv.main(["--base-ref", "origin/main"]) + + output = capsys.readouterr().out + assert exit_code == 1 + assert "[FAIL] dev-team" in output + assert "[PASS] other-plugin" in output + + +def test_main_no_plugins_touched_exits_zero(capsys): + responses = { + ("diff", "--name-only", "origin/main", "HEAD"): make_completed_process( + stdout="README.md\n" + ), + } + + with expect_run_git(responses): + exit_code = cpv.main(["--base-ref", "origin/main"]) + + assert exit_code == 0