diff --git a/.github/workflows/auto-tag-release.yml b/.github/workflows/auto-tag-release.yml index b2e51bc..be3487f 100644 --- a/.github/workflows/auto-tag-release.yml +++ b/.github/workflows/auto-tag-release.yml @@ -47,6 +47,35 @@ jobs: - name: Check Capacium manifests are synced run: python3 scripts/sync-capacium-manifests.py --check + - name: Hard gate release version matches capability manifests + run: | + python3 - <<'PY' + from pathlib import Path + import re + import sys + import yaml + + pyproject = open("pyproject.toml", encoding="utf-8").read() + match = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', pyproject, re.MULTILINE) + release_version = match.group(1) if match else "" + manifests = [Path("capability.yaml"), *sorted(Path("skills").glob("skillweave-*/capability.yaml"))] + mismatches = [] + for path in manifests: + capability = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + capability_version = str(capability.get("version", "")) + if capability_version != release_version: + mismatches.append((str(path), capability_version or "missing")) + + if mismatches: + print("HARD GATE REJECTED: pyproject.toml and capability manifest versions differ.") + print(f"pyproject.toml: {release_version or 'missing'}") + for path, version in mismatches: + print(f"{path}: {version}") + sys.exit(1) + + print(f"Release version matches {len(manifests)} capability manifest(s): {release_version}") + PY + - name: Analyze version id: analyze run: | @@ -144,6 +173,34 @@ jobs: fi echo "Release name follows convention: $RELEASE_NAME" + - name: Hard gate tag version matches capability manifests + run: | + TAG="${{ needs.create-tag.outputs.tag }}" + TAG_VERSION="${TAG#v}" + python3 - < list[dict[str, str]]: for path in self.skill_manifest_paths(): manifest = self.load_manifest(path) name = manifest.get("name") or path.parent.name - own_version = manifest.get("version", version) capabilities.append( { "name": name, "source": f"./skills/{path.parent.name}", - "version": own_version, + "version": version, } ) return capabilities diff --git a/src/skillweave/github_integration/release_gate.py b/src/skillweave/github_integration/release_gate.py index 3577a4f..20531b7 100644 --- a/src/skillweave/github_integration/release_gate.py +++ b/src/skillweave/github_integration/release_gate.py @@ -7,6 +7,7 @@ import re import json +import yaml from pathlib import Path from dataclasses import dataclass, field from datetime import datetime @@ -246,6 +247,59 @@ def check_pyproject_toml(self) -> GateCheck: required=True, ) + def check_release_version_matches_capabilities(self, current_version: str) -> GateCheck: + manifest_paths = [ + self.repo_root / "capability.yaml", + *sorted((self.repo_root / "skills").glob("skillweave-*/capability.yaml")), + ] + missing = [path for path in manifest_paths if not path.exists()] + if missing: + preview = ", ".join(str(path.relative_to(self.repo_root)) for path in missing[:5]) + return GateCheck( + id="release-version-capabilities-match", + name="Release version matches capability manifests", + passed=False, + detail=f"Missing capability manifest(s): {preview}", + required=True, + ) + + release_version = current_version.lstrip("v") + mismatches = [] + try: + for path in manifest_paths: + manifest = yaml.safe_load(path.read_text()) or {} + manifest_version = str(manifest.get("version", "")).lstrip("v") + if manifest_version != release_version: + rel_path = path.relative_to(self.repo_root) + mismatches.append(f"{rel_path}={manifest_version or 'missing'}") + except Exception as exc: + return GateCheck( + id="release-version-capabilities-match", + name="Release version matches capability manifests", + passed=False, + detail=f"Could not read capability manifest: {exc}", + required=True, + ) + + if mismatches: + preview = ", ".join(mismatches[:5]) + remainder = "" if len(mismatches) <= 5 else f" (+{len(mismatches) - 5} more)" + return GateCheck( + id="release-version-capabilities-match", + name="Release version matches capability manifests", + passed=False, + detail=f"Release version {release_version} differs from: {preview}{remainder}", + required=True, + ) + + return GateCheck( + id="release-version-capabilities-match", + name="Release version matches capability manifests", + passed=True, + detail=f"{len(manifest_paths)} capability manifest(s) match release version {release_version}", + required=True, + ) + def check_capacium_manifests(self) -> GateCheck: try: syncer = CapaciumManifestSync(repo_root=str(self.repo_root)) @@ -294,6 +348,7 @@ def evaluate( result.checks.append(self.check_changelog(current_version)) result.checks.append(self.check_tests()) result.checks.append(self.check_pyproject_toml()) + result.checks.append(self.check_release_version_matches_capabilities(current_version)) result.checks.append(self.check_capacium_manifests()) result.checks.extend(self.check_required_files(required_files)) result.checks.append(self.check_wip_markers(wip_scan_paths)) diff --git a/tests/test_github_integration.py b/tests/test_github_integration.py index c2b320b..23705c1 100644 --- a/tests/test_github_integration.py +++ b/tests/test_github_integration.py @@ -593,6 +593,20 @@ def test_check_capacium_manifests_detects_drift(self, temp_project_full): assert check.passed is False assert "out of sync" in check.detail + def test_check_release_version_matches_all_capability_manifests(self, temp_project_full): + gate = ReleaseReadinessGate(repo_root=str(temp_project_full)) + check = gate.check_release_version_matches_capabilities("0.2.0") + assert check.passed is True + assert "2 capability manifest" in check.detail + + def test_check_release_version_blocks_skill_manifest_mismatch(self, temp_project_full): + drifted = temp_project_full / "skills" / "skillweave-blueprint" / "capability.yaml" + drifted.write_text(drifted.read_text().replace("version: 0.2.0", "version: 0.1.0")) + gate = ReleaseReadinessGate(repo_root=str(temp_project_full)) + check = gate.check_release_version_matches_capabilities("0.2.0") + assert check.passed is False + assert "skillweave-blueprint/capability.yaml" in check.detail + def test_evaluate_passes(self, temp_project_full): gate = ReleaseReadinessGate(repo_root=str(temp_project_full)) result = gate.evaluate(current_version="0.2.0", latest_tag="v0.1.0")