chore(skills): add release-branch-policy skill and scripts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
343d1a0d5c
commit
4755198a9d
116
skills/release-branch-policy/SKILL.md
Normal file
116
skills/release-branch-policy/SKILL.md
Normal file
@ -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/<version>`.
|
||||||
|
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/<version>` tip.
|
||||||
|
|
||||||
|
If you need SemVer tags, follow governance: `<repo>-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/<version>.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.
|
||||||
@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
57
skills/release-branch-policy/scripts/apply_ruleset.sh
Executable file
57
skills/release-branch-policy/scripts/apply_ruleset.sh
Executable file
@ -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 <owner/repo> [<owner/repo> ...]
|
||||||
|
|
||||||
|
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
|
||||||
116
skills/release-branch-policy/scripts/generate_release_manifest.sh
Executable file
116
skills/release-branch-policy/scripts/generate_release_manifest.sh
Executable file
@ -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 <version> [--base <dir>] [--out <file>]
|
||||||
|
|
||||||
|
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}"
|
||||||
55
skills/release-branch-policy/scripts/sync_skill_to_subrepos.sh
Executable file
55
skills/release-branch-policy/scripts/sync_skill_to_subrepos.sh
Executable file
@ -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 -> <repo>/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
|
||||||
Loading…
Reference in New Issue
Block a user