From c04bbab14770287740c504ce7655f782d4b454b3 Mon Sep 17 00:00:00 2001 From: GangGreenTemperTatum <104169244+GangGreenTemperTatum@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:15:57 -0400 Subject: [PATCH] Add capability-scoped release automation --- .github/workflows/sync.yml | 64 +++++++++- scripts/capability_release_plan.py | 172 ++++++++++++++++++++++++++ tests/test_capability_release_plan.py | 106 ++++++++++++++++ 3 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 scripts/capability_release_plan.py create mode 100644 tests/test_capability_release_plan.py diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index b6c1694..2a748a3 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -163,9 +163,67 @@ jobs: [[ "${FORCE}" == "true" ]] && cmd+=(--force) "${cmd[@]}" + release-capabilities: + name: "Create capability releases" + needs: [validate, sync-dev, sync-staging, sync-prod] + if: >- + github.event_name == 'push' && + needs.validate.result == 'success' && + needs.sync-dev.result == 'success' && + needs.sync-staging.result == 'success' && + needs.sync-prod.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Plan capability releases + env: + BASE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} + run: | + set -euo pipefail + python3 scripts/capability_release_plan.py "${BASE_SHA}" "${HEAD_SHA}" > release-plan.json + cat release-plan.json + + - name: Create GitHub releases + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + count=$(jq '.releases | length' release-plan.json) + if [[ "${count}" -eq 0 ]]; then + echo "No capability version changes detected." + exit 0 + fi + + jq -c '.releases[]' release-plan.json | while read -r release; do + tag=$(jq -r '.tag' <<< "${release}") + title=$(jq -r '.title' <<< "${release}") + previous_tag=$(jq -r '.previous_tag // ""' <<< "${release}") + + args=( + release create "${tag}" + --repo "${GITHUB_REPOSITORY}" + --target "${GITHUB_SHA}" + --title "${title}" + --generate-notes + --latest=false + ) + if [[ -n "${previous_tag}" ]]; then + args+=(--notes-start-tag "${previous_tag}") + fi + + gh "${args[@]}" + done + summary: name: Sync summary - needs: [validate, sync-dev, sync-staging, sync-prod] + needs: [validate, sync-dev, sync-staging, sync-prod, release-capabilities] if: always() runs-on: ubuntu-latest steps: @@ -175,6 +233,7 @@ jobs: DEV: ${{ needs.sync-dev.result }} STAGING: ${{ needs.sync-staging.result }} PROD: ${{ needs.sync-prod.result }} + RELEASES: ${{ needs.release-capabilities.result }} run: | { echo "### Sync Summary" @@ -182,9 +241,10 @@ jobs: echo "- **Dev:** ${DEV}" echo "- **Staging:** ${STAGING}" echo "- **Prod:** ${PROD}" + echo "- **Capability releases:** ${RELEASES}" } >> "$GITHUB_STEP_SUMMARY" - if [[ "${VALIDATE}" == "failure" || "${DEV}" == "failure" || "${STAGING}" == "failure" || "${PROD}" == "failure" ]]; then + if [[ "${VALIDATE}" == "failure" || "${DEV}" == "failure" || "${STAGING}" == "failure" || "${PROD}" == "failure" || "${RELEASES}" == "failure" ]]; then echo "::error::One or more jobs failed" exit 1 fi diff --git a/scripts/capability_release_plan.py b/scripts/capability_release_plan.py new file mode 100644 index 0000000..ab97a7f --- /dev/null +++ b/scripts/capability_release_plan.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Plan GitHub Releases for changed capability manifest versions. + +The capabilities repository is a monorepo: each capability owns its own +``capability.yaml`` version. This script maps a git commit range to the set of +capability-scoped tags that should become GitHub Releases. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import asdict, dataclass + + +MANIFEST_RE = re.compile(r"^capabilities/([^/]+)/capability\.yaml$") +FIELD_RE = re.compile(r"^(?Pname|version):\s*(?P.+?)\s*(?:#.*)?$") + + +@dataclass(frozen=True) +class CapabilityRelease: + capability: str + version: str + tag: str + title: str + previous_tag: str | None = None + + +def run_git(args: list[str], *, check: bool = True) -> str: + result = subprocess.run( + ["git", *args], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if check and result.returncode != 0: + raise RuntimeError( + f"git {' '.join(args)} failed: {result.stderr.strip() or result.stdout.strip()}" + ) + return result.stdout + + +def clean_yaml_scalar(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def parse_manifest(text: str) -> tuple[str | None, str | None]: + name: str | None = None + version: str | None = None + for raw_line in text.splitlines(): + match = FIELD_RE.match(raw_line) + if not match: + continue + key = match.group("key") + value = clean_yaml_scalar(match.group("value")) + if key == "name": + name = value + elif key == "version": + version = value + return name, version + + +def read_file_at(ref: str, path: str) -> str | None: + result = subprocess.run( + ["git", "show", f"{ref}:{path}"], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + if result.returncode != 0: + return None + return result.stdout + + +def changed_manifest_paths(base: str, head: str) -> list[str]: + output = run_git(["diff", "--name-only", base, head, "--", "capabilities"]) + paths = [] + for line in output.splitlines(): + if MANIFEST_RE.match(line): + paths.append(line) + return sorted(set(paths)) + + +def tag_exists(tag: str) -> bool: + return bool(run_git(["tag", "--list", tag]).strip()) + + +def previous_capability_tag(capability: str, current_tag: str) -> str | None: + pattern = f"capability/{capability}/v*" + output = run_git( + [ + "for-each-ref", + "--sort=-creatordate", + "--format=%(refname:short)", + f"refs/tags/{pattern}", + ] + ) + for tag in output.splitlines(): + if tag and tag != current_tag: + return tag + return None + + +def build_release_plan(base: str, head: str) -> list[CapabilityRelease]: + releases: list[CapabilityRelease] = [] + + for path in changed_manifest_paths(base, head): + head_text = read_file_at(head, path) + if head_text is None: + continue + + manifest_name, new_version = parse_manifest(head_text) + capability = manifest_name or MANIFEST_RE.match(path).group(1) # type: ignore[union-attr] + if not new_version: + continue + + base_text = read_file_at(base, path) + _old_name, old_version = ( + parse_manifest(base_text) if base_text else (None, None) + ) + if old_version == new_version: + continue + + tag = f"capability/{capability}/v{new_version}" + if tag_exists(tag): + continue + + releases.append( + CapabilityRelease( + capability=capability, + version=new_version, + tag=tag, + title=f"{capability} v{new_version}", + previous_tag=previous_capability_tag(capability, tag), + ) + ) + + return releases + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Build a GitHub Release plan for capability version changes." + ) + parser.add_argument("base", help="Base commit/ref for the comparison") + parser.add_argument("head", help="Head commit/ref for the comparison") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + releases = build_release_plan(args.base, args.head) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return 1 + + json.dump({"releases": [asdict(release) for release in releases]}, sys.stdout) + print() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_capability_release_plan.py b/tests/test_capability_release_plan.py new file mode 100644 index 0000000..f82e214 --- /dev/null +++ b/tests/test_capability_release_plan.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] / "scripts" / "capability_release_plan.py" +) +SPEC = importlib.util.spec_from_file_location("capability_release_plan", MODULE_PATH) +assert SPEC is not None +planner = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = planner +SPEC.loader.exec_module(planner) + + +def git(*args: str) -> str: + result = subprocess.run( + ["git", *args], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return result.stdout.strip() + + +def write_manifest(root: Path, *, version: str, description: str = "Demo") -> None: + cap_dir = root / "capabilities" / "demo" + cap_dir.mkdir(parents=True, exist_ok=True) + (cap_dir / "capability.yaml").write_text( + "\n".join( + [ + "schema: 1", + "name: demo", + f'version: "{version}"', + f"description: {description}", + "author:", + " name: Dreadnode", + "", + ] + ) + ) + + +def commit_all(message: str) -> str: + git("add", ".") + git("commit", "-m", message) + return git("rev-parse", "HEAD") + + +def init_repo(tmp_path: Path) -> None: + git("init") + git("config", "user.email", "test@example.com") + git("config", "user.name", "Test User") + write_manifest(tmp_path, version="1.0.0") + commit_all("initial capability") + + +def test_plans_release_for_capability_version_bump(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + init_repo(tmp_path) + git("tag", "capability/demo/v1.0.0") + base = git("rev-parse", "HEAD") + + write_manifest(tmp_path, version="1.1.0") + head = commit_all("bump demo capability") + + releases = planner.build_release_plan(base, head) + + assert len(releases) == 1 + assert releases[0].capability == "demo" + assert releases[0].version == "1.1.0" + assert releases[0].tag == "capability/demo/v1.1.0" + assert releases[0].title == "demo v1.1.0" + assert releases[0].previous_tag == "capability/demo/v1.0.0" + + +def test_skips_manifest_changes_without_version_bump( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.chdir(tmp_path) + init_repo(tmp_path) + base = git("rev-parse", "HEAD") + + write_manifest(tmp_path, version="1.0.0", description="Updated demo") + head = commit_all("update description") + + assert planner.build_release_plan(base, head) == [] + + +def test_skips_release_when_target_tag_already_exists( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.chdir(tmp_path) + init_repo(tmp_path) + base = git("rev-parse", "HEAD") + + write_manifest(tmp_path, version="1.1.0") + head = commit_all("bump demo capability") + git("tag", "capability/demo/v1.1.0") + + assert planner.build_release_plan(base, head) == []