diff --git a/skills/release-branch-policy/SKILL.md b/skills/release-branch-policy/SKILL.md new file mode 100644 index 00000000..662015c7 --- /dev/null +++ b/skills/release-branch-policy/SKILL.md @@ -0,0 +1,116 @@ +# Skill: release-branch-policy + +## Purpose + +Standardize release branch policy across Cloud-Neutral Toolkit repos: + +- `main` is the **preview** branch (fast iteration, integrates frequently). +- `release/*` branches are **production release lines** and must be protected. +- Updates to `release/*` happen via **local cherry-pick** by release managers (process gate). + +This skill includes: +- A policy doc (this file) +- A ruleset JSON template (GitHub Rulesets API) +- A `gh` script to apply the ruleset to one or many repos +- A sync script to copy this skill into all local sub-repos +- A script to generate a cross-repo release manifest (for tag association) + +Non-goals: +- This skill does NOT create/push `release/v0.1` or tags automatically. + +## Policy + +### Branch Roles + +- `main`: preview + - Accepts PRs and merges normally. + - May be ahead of production at any time. +- `release/*`: production + - No force-push. + - Require linear history. + - Prefer "cherry-pick into release branch" as the only change mechanism (process). + - Restrict who can update release branches to release managers (enforced via GitHub Rulesets/Branch protection UI). + +### “Cherry-Pick Only” Clarification + +GitHub branch rules cannot reliably guarantee "only cherry-pick" as a technical constraint. +We treat it as a **process rule**: + +1. A change lands in `main`. +2. Release manager cherry-picks specific commits onto `release/`. +3. Release manager pushes the updated release branch. + +### “No PR / No Push” Clarification + +If you literally forbid both: +- PR merges to `release/*`, and +- any push to `release/*` + +then the branch becomes non-updatable. + +What we implement is: +- No force-push, no deletion, linear history (enforceable). +- Only release managers can update `release/*` (enforceable via "restrict updates" / bypass actors). +- "Cherry-pick only" (process rule). + +### Tags + +For milestone releases like `v0.1`: +- Use an annotated tag named `v0.1` (per-repo). +- Prefer tags on `release/` tip. + +If you need SemVer tags, follow governance: `-vX.Y.Z`. + +### Cross-Repo Tag Association + +Git tags are per-repo; GitHub does not provide a first-class "one tag links all repos" concept. + +We represent "release v0.1 across repos" by committing a **release manifest** file in the control repo, generated from local git state: +- repo name +- release branch tip SHA +- tag tip SHA + +Use: `skills/release-branch-policy/scripts/generate_release_manifest.sh v0.1` + +## Ruleset Requirements (release/*) + +Enforce at minimum: +- block deletion +- block force-push (non-fast-forward) +- require linear history + +Optional (recommended if you have stable CI): +- require status checks +- require signed commits + +## Tools + +### 1) Apply Ruleset (GitHub Rulesets) + +Script: `skills/release-branch-policy/scripts/apply_ruleset.sh` + +- Applies (create/update) a repo ruleset targeting `refs/heads/release/*` +- Uses `gh api` and a JSON payload +- Does not modify branches/tags + +### 2) Sync Skill Into All Local Sub-Repos + +Script: `skills/release-branch-policy/scripts/sync_skill_to_subrepos.sh` + +- Copies this skill folder into each local repo under `/Users/shenlan/workspaces/cloud-neutral-toolkit/*` +- Skips repos without `.git` +- Keeps existing files unless overwritten explicitly + +### 3) Generate Release Manifest (Cross-Repo Association) + +Script: `skills/release-branch-policy/scripts/generate_release_manifest.sh` + +- Generates `releases/.yaml` in the current working directory (default) +- Does not push or create refs + +## Operator Checklist + +- Confirm `main` is treated as preview across repos (docs + CI naming). +- Apply ruleset to every repo that has production releases. +- Document "cherry-pick only" in release runbooks. +- Verify bypass actors (release managers) in GitHub UI if needed. diff --git a/skills/release-branch-policy/references/ruleset.release-branches.json b/skills/release-branch-policy/references/ruleset.release-branches.json new file mode 100644 index 00000000..ac7757e5 --- /dev/null +++ b/skills/release-branch-policy/references/ruleset.release-branches.json @@ -0,0 +1,17 @@ +{ + "name": "Release Branch Protection (release/*)", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/release/*"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" } + ] +} + diff --git a/skills/release-branch-policy/scripts/apply_ruleset.sh b/skills/release-branch-policy/scripts/apply_ruleset.sh new file mode 100755 index 00000000..786aa3dd --- /dev/null +++ b/skills/release-branch-policy/scripts/apply_ruleset.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Apply GitHub Ruleset to protect release/* branches. + +Usage: + apply_ruleset.sh [ ...] + +Notes: + - Requires: gh (authenticated), jq + - Does NOT create/push branches or tags. + - Ruleset payload is in: skills/release-branch-policy/references/ruleset.release-branches.json +EOF +} + +if [[ $# -lt 1 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "missing: gh" >&2 + exit 1 +fi +if ! command -v jq >/dev/null 2>&1; then + echo "missing: jq" >&2 + exit 1 +fi + +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PAYLOAD_FILE="${SKILL_DIR}/references/ruleset.release-branches.json" + +if [[ ! -f "${PAYLOAD_FILE}" ]]; then + echo "payload not found: ${PAYLOAD_FILE}" >&2 + exit 1 +fi + +NAME="$(jq -r '.name' < "${PAYLOAD_FILE}")" + +for OWNER_REPO in "$@"; do + echo ">>> ${OWNER_REPO}" + + # Find existing ruleset by name. + existing_id="$( + gh api "repos/${OWNER_REPO}/rulesets" --jq ".[] | select(.name == \"${NAME}\") | .id" 2>/dev/null || true + )" + + if [[ -n "${existing_id}" ]]; then + echo "Updating ruleset id=${existing_id}" + gh api -X PUT "repos/${OWNER_REPO}/rulesets/${existing_id}" --input "${PAYLOAD_FILE}" >/dev/null + else + echo "Creating ruleset" + gh api -H "Accept: application/vnd.github+json" -X POST "repos/${OWNER_REPO}/rulesets" --input "${PAYLOAD_FILE}" >/dev/null + fi +done diff --git a/skills/release-branch-policy/scripts/generate_release_manifest.sh b/skills/release-branch-policy/scripts/generate_release_manifest.sh new file mode 100755 index 00000000..d2ca661e --- /dev/null +++ b/skills/release-branch-policy/scripts/generate_release_manifest.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Generate a cross-repo release manifest (read-only) from local git state. + +Usage: + generate_release_manifest.sh [--base ] [--out ] + +Examples: + generate_release_manifest.sh v0.1 + generate_release_manifest.sh v0.1 --out releases/v0.1.yaml + generate_release_manifest.sh v0.1 --base /Users/shenlan/workspaces/cloud-neutral-toolkit + +Notes: + - This script does NOT create/push branches or tags. + - It inspects local refs only; if your local remotes are stale, run 'git fetch --all --tags' per repo first. + - "Cross-repo association" is represented by this manifest file (repo -> release branch tip + tag tip). +EOF +} + +if [[ $# -lt 1 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +VERSION="$1" +shift || true + +BASE="/Users/shenlan/workspaces/cloud-neutral-toolkit" +OUT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --base) + BASE="${2:-}" + shift 2 + ;; + --out) + OUT="${2:-}" + shift 2 + ;; + *) + echo "unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "${OUT}" ]]; then + mkdir -p "releases" + OUT="releases/${VERSION}.yaml" +fi + +if [[ ! -d "${BASE}" ]]; then + echo "missing base dir: ${BASE}" >&2 + exit 1 +fi + +REL_BRANCH="release/${VERSION}" + +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT + +{ + echo "version: ${VERSION}" + echo "generated_at_utc: \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"" + echo "base_dir: \"${BASE}\"" + echo "release_branch: \"${REL_BRANCH}\"" + echo "repos:" +} >"$tmp" + +for d in "${BASE}"/*; do + [[ -d "$d" ]] || continue + [[ -d "$d/.git" ]] || continue + + name="$(basename "$d")" + remote_url="$(cd "$d" && git config --get remote.origin.url 2>/dev/null || true)" + + rel_ref="" + rel_sha="" + if (cd "$d" && git show-ref --verify --quiet "refs/remotes/origin/${REL_BRANCH}"); then + rel_ref="refs/remotes/origin/${REL_BRANCH}" + rel_sha="$(cd "$d" && git rev-parse "refs/remotes/origin/${REL_BRANCH}")" + elif (cd "$d" && git show-ref --verify --quiet "refs/heads/${REL_BRANCH}"); then + rel_ref="refs/heads/${REL_BRANCH}" + rel_sha="$(cd "$d" && git rev-parse "refs/heads/${REL_BRANCH}")" + fi + + tag_sha="" + if (cd "$d" && git show-ref --tags --quiet --verify "refs/tags/${VERSION}"); then + tag_sha="$(cd "$d" && git rev-parse "${VERSION}^{}" 2>/dev/null || git rev-parse "${VERSION}" 2>/dev/null || true)" + fi + + { + echo " - name: \"${name}\"" + echo " path: \"${d}\"" + if [[ -n "${remote_url}" ]]; then + echo " remote: \"${remote_url}\"" + else + echo " remote: \"\"" + fi + echo " release:" + echo " branch: \"${REL_BRANCH}\"" + echo " ref: \"${rel_ref}\"" + echo " sha: \"${rel_sha}\"" + echo " tag:" + echo " name: \"${VERSION}\"" + echo " sha: \"${tag_sha}\"" + } >>"$tmp" +done + +mv "$tmp" "$OUT" +echo "wrote: ${OUT}" diff --git a/skills/release-branch-policy/scripts/sync_skill_to_subrepos.sh b/skills/release-branch-policy/scripts/sync_skill_to_subrepos.sh new file mode 100755 index 00000000..73f32e64 --- /dev/null +++ b/skills/release-branch-policy/scripts/sync_skill_to_subrepos.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Copy this skill into all local Cloud-Neutral Toolkit sub-repos. + +Usage: + sync_skill_to_subrepos.sh + +Copies: + skills/release-branch-policy -> /skills/release-branch-policy + +Notes: + - Local path root: /Users/shenlan/workspaces/cloud-neutral-toolkit + - Skips directories without .git +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +SRC_SKILL="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC_REPO_ROOT="$(cd "${SRC_SKILL}/../.." && pwd)" + +realpath_py() { + python3 - "$1" <<'PY' +import os, sys +print(os.path.realpath(sys.argv[1])) +PY +} + +BASE="/Users/shenlan/workspaces/cloud-neutral-toolkit" +if [[ ! -d "${BASE}" ]]; then + echo "missing base dir: ${BASE}" >&2 + exit 1 +fi + +for d in "${BASE}"/*; do + [[ -d "$d" ]] || continue + [[ -d "$d/.git" ]] || continue + + # Don't delete our own source while syncing. + if [[ "$(realpath_py "$d")" == "$(realpath_py "$SRC_REPO_ROOT")" ]]; then + echo ">>> skipping source repo $d" + continue + fi + + mkdir -p "$d/skills" + echo ">>> syncing to $d" + rm -rf "$d/skills/release-branch-policy" + cp -R "${SRC_SKILL}" "$d/skills/release-branch-policy" +done