Skip to content
Merged
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
64 changes: 62 additions & 2 deletions .github/workflows/sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -175,16 +233,18 @@ 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"
echo "- **Validate:** ${VALIDATE}"
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
172 changes: 172 additions & 0 deletions scripts/capability_release_plan.py
Original file line number Diff line number Diff line change
@@ -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"^(?P<key>name|version):\s*(?P<value>.+?)\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())
106 changes: 106 additions & 0 deletions tests/test_capability_release_plan.py
Original file line number Diff line number Diff line change
@@ -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) == []
Loading