diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 24de0e9..d39ca1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: - name: Build .skill artifact run: | - bash scripts/build-skill.sh + bash skills/last30days/scripts/build-skill.sh test -f dist/last30days.skill - name: Create GitHub release diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..4c93884 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,26 @@ +name: Validate + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + plugin-contract: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install 3.12 + + - name: Run plugin contract tests + run: uv run pytest tests/test_plugin_contract.py tests/test_version_consistency.py diff --git a/tests/test_plugin_contract.py b/tests/test_plugin_contract.py new file mode 100644 index 0000000..52708cb --- /dev/null +++ b/tests/test_plugin_contract.py @@ -0,0 +1,65 @@ +import json +import re +import tomllib +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SKILL_ROOT = ROOT / "skills" / "last30days" + + +def _json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _skill_version() -> str: + text = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8") + match = re.search(r'^version:\s*"([^"]+)"\s*$', text, re.MULTILINE) + if not match: + raise AssertionError("SKILL.md version frontmatter not found") + return match.group(1) + + +class TestPluginContract(unittest.TestCase): + def test_codex_manifest_points_at_skills_tree(self) -> None: + manifest = _json(ROOT / ".codex-plugin" / "plugin.json") + + self.assertEqual("last30days", manifest["name"]) + self.assertEqual("./skills/", manifest["skills"]) + self.assertTrue(SKILL_ROOT.joinpath("SKILL.md").is_file()) + self.assertTrue(SKILL_ROOT.joinpath("scripts", "last30days.py").is_file()) + + def test_versions_match_across_manifests(self) -> None: + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + version = pyproject["project"]["version"] + + self.assertEqual(version, _skill_version()) + self.assertEqual(version, _json(ROOT / ".codex-plugin" / "plugin.json")["version"]) + self.assertEqual(version, _json(ROOT / ".claude-plugin" / "plugin.json")["version"]) + + marketplace = _json(ROOT / ".claude-plugin" / "marketplace.json") + plugins = marketplace.get("plugins") or [] + self.assertEqual(1, len(plugins)) + self.assertEqual(version, plugins[0]["version"]) + + def test_claude_marketplace_has_current_schema_shape(self) -> None: + marketplace = _json(ROOT / ".claude-plugin" / "marketplace.json") + + self.assertNotIn("$schema", marketplace) + self.assertNotIn("description", marketplace) + self.assertIn("metadata", marketplace) + self.assertIn("description", marketplace["metadata"]) + + def test_workflows_do_not_reference_removed_root_scripts_dir(self) -> None: + offenders = [] + for path in sorted((ROOT / ".github" / "workflows").glob("*.yml")): + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if "scripts/" in line and "skills/last30days/scripts/" not in line: + offenders.append(f"{path.relative_to(ROOT)}:{line_number}: {line.strip()}") + + self.assertEqual([], offenders) + + +if __name__ == "__main__": + unittest.main()