Skip to content

Commit 5b6a75a

Browse files
author
Haitao Pan
committed
feat: Add release branch policy skill including scripts for ruleset application, release manifest generation, and skill synchronization.
1 parent 646e539 commit 5b6a75a

File tree

5 files changed

+361
-0
lines changed

5 files changed

+361
-0
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Skill: release-branch-policy
2+
3+
## Purpose
4+
5+
Standardize release branch policy across Cloud-Neutral Toolkit repos:
6+
7+
- `main` is the **preview** branch (fast iteration, integrates frequently).
8+
- `release/*` branches are **production release lines** and must be protected.
9+
- Updates to `release/*` happen via **local cherry-pick** by release managers (process gate).
10+
11+
This skill includes:
12+
- A policy doc (this file)
13+
- A ruleset JSON template (GitHub Rulesets API)
14+
- A `gh` script to apply the ruleset to one or many repos
15+
- A sync script to copy this skill into all local sub-repos
16+
- A script to generate a cross-repo release manifest (for tag association)
17+
18+
Non-goals:
19+
- This skill does NOT create/push `release/v0.1` or tags automatically.
20+
21+
## Policy
22+
23+
### Branch Roles
24+
25+
- `main`: preview
26+
- Accepts PRs and merges normally.
27+
- May be ahead of production at any time.
28+
- `release/*`: production
29+
- No force-push.
30+
- Require linear history.
31+
- Prefer "cherry-pick into release branch" as the only change mechanism (process).
32+
- Restrict who can update release branches to release managers (enforced via GitHub Rulesets/Branch protection UI).
33+
34+
### “Cherry-Pick Only” Clarification
35+
36+
GitHub branch rules cannot reliably guarantee "only cherry-pick" as a technical constraint.
37+
We treat it as a **process rule**:
38+
39+
1. A change lands in `main`.
40+
2. Release manager cherry-picks specific commits onto `release/<version>`.
41+
3. Release manager pushes the updated release branch.
42+
43+
### “No PR / No Push” Clarification
44+
45+
If you literally forbid both:
46+
- PR merges to `release/*`, and
47+
- any push to `release/*`
48+
49+
then the branch becomes non-updatable.
50+
51+
What we implement is:
52+
- No force-push, no deletion, linear history (enforceable).
53+
- Only release managers can update `release/*` (enforceable via "restrict updates" / bypass actors).
54+
- "Cherry-pick only" (process rule).
55+
56+
### Tags
57+
58+
For milestone releases like `v0.1`:
59+
- Use an annotated tag named `v0.1` (per-repo).
60+
- Prefer tags on `release/<version>` tip.
61+
62+
If you need SemVer tags, follow governance: `<repo>-vX.Y.Z`.
63+
64+
### Cross-Repo Tag Association
65+
66+
Git tags are per-repo; GitHub does not provide a first-class "one tag links all repos" concept.
67+
68+
We represent "release v0.1 across repos" by committing a **release manifest** file in the control repo, generated from local git state:
69+
- repo name
70+
- release branch tip SHA
71+
- tag tip SHA
72+
73+
Use: `skills/release-branch-policy/scripts/generate_release_manifest.sh v0.1`
74+
75+
## Ruleset Requirements (release/*)
76+
77+
Enforce at minimum:
78+
- block deletion
79+
- block force-push (non-fast-forward)
80+
- require linear history
81+
82+
Optional (recommended if you have stable CI):
83+
- require status checks
84+
- require signed commits
85+
86+
## Tools
87+
88+
### 1) Apply Ruleset (GitHub Rulesets)
89+
90+
Script: `skills/release-branch-policy/scripts/apply_ruleset.sh`
91+
92+
- Applies (create/update) a repo ruleset targeting `refs/heads/release/*`
93+
- Uses `gh api` and a JSON payload
94+
- Does not modify branches/tags
95+
96+
### 2) Sync Skill Into All Local Sub-Repos
97+
98+
Script: `skills/release-branch-policy/scripts/sync_skill_to_subrepos.sh`
99+
100+
- Copies this skill folder into each local repo under `/Users/shenlan/workspaces/cloud-neutral-toolkit/*`
101+
- Skips repos without `.git`
102+
- Keeps existing files unless overwritten explicitly
103+
104+
### 3) Generate Release Manifest (Cross-Repo Association)
105+
106+
Script: `skills/release-branch-policy/scripts/generate_release_manifest.sh`
107+
108+
- Generates `releases/<version>.yaml` in the current working directory (default)
109+
- Does not push or create refs
110+
111+
## Operator Checklist
112+
113+
- Confirm `main` is treated as preview across repos (docs + CI naming).
114+
- Apply ruleset to every repo that has production releases.
115+
- Document "cherry-pick only" in release runbooks.
116+
- Verify bypass actors (release managers) in GitHub UI if needed.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "Release Branch Protection (release/*)",
3+
"target": "branch",
4+
"enforcement": "active",
5+
"conditions": {
6+
"ref_name": {
7+
"include": ["refs/heads/release/*"],
8+
"exclude": []
9+
}
10+
},
11+
"rules": [
12+
{ "type": "deletion" },
13+
{ "type": "non_fast_forward" },
14+
{ "type": "required_linear_history" }
15+
]
16+
}
17+
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
usage() {
5+
cat <<'EOF'
6+
Apply GitHub Ruleset to protect release/* branches.
7+
8+
Usage:
9+
apply_ruleset.sh <owner/repo> [<owner/repo> ...]
10+
11+
Notes:
12+
- Requires: gh (authenticated), jq
13+
- Does NOT create/push branches or tags.
14+
- Ruleset payload is in: skills/release-branch-policy/references/ruleset.release-branches.json
15+
EOF
16+
}
17+
18+
if [[ $# -lt 1 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
19+
usage
20+
exit 0
21+
fi
22+
23+
if ! command -v gh >/dev/null 2>&1; then
24+
echo "missing: gh" >&2
25+
exit 1
26+
fi
27+
if ! command -v jq >/dev/null 2>&1; then
28+
echo "missing: jq" >&2
29+
exit 1
30+
fi
31+
32+
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
33+
PAYLOAD_FILE="${SKILL_DIR}/references/ruleset.release-branches.json"
34+
35+
if [[ ! -f "${PAYLOAD_FILE}" ]]; then
36+
echo "payload not found: ${PAYLOAD_FILE}" >&2
37+
exit 1
38+
fi
39+
40+
NAME="$(jq -r '.name' < "${PAYLOAD_FILE}")"
41+
42+
for OWNER_REPO in "$@"; do
43+
echo ">>> ${OWNER_REPO}"
44+
45+
# Find existing ruleset by name.
46+
existing_id="$(
47+
gh api "repos/${OWNER_REPO}/rulesets" --jq ".[] | select(.name == \"${NAME}\") | .id" 2>/dev/null || true
48+
)"
49+
50+
if [[ -n "${existing_id}" ]]; then
51+
echo "Updating ruleset id=${existing_id}"
52+
gh api -X PUT "repos/${OWNER_REPO}/rulesets/${existing_id}" --input "${PAYLOAD_FILE}" >/dev/null
53+
else
54+
echo "Creating ruleset"
55+
gh api -H "Accept: application/vnd.github+json" -X POST "repos/${OWNER_REPO}/rulesets" --input "${PAYLOAD_FILE}" >/dev/null
56+
fi
57+
done
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
usage() {
5+
cat <<'EOF'
6+
Generate a cross-repo release manifest (read-only) from local git state.
7+
8+
Usage:
9+
generate_release_manifest.sh <version> [--base <dir>] [--out <file>]
10+
11+
Examples:
12+
generate_release_manifest.sh v0.1
13+
generate_release_manifest.sh v0.1 --out releases/v0.1.yaml
14+
generate_release_manifest.sh v0.1 --base /Users/shenlan/workspaces/cloud-neutral-toolkit
15+
16+
Notes:
17+
- This script does NOT create/push branches or tags.
18+
- It inspects local refs only; if your local remotes are stale, run 'git fetch --all --tags' per repo first.
19+
- "Cross-repo association" is represented by this manifest file (repo -> release branch tip + tag tip).
20+
EOF
21+
}
22+
23+
if [[ $# -lt 1 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
24+
usage
25+
exit 0
26+
fi
27+
28+
VERSION="$1"
29+
shift || true
30+
31+
BASE="/Users/shenlan/workspaces/cloud-neutral-toolkit"
32+
OUT=""
33+
34+
while [[ $# -gt 0 ]]; do
35+
case "$1" in
36+
--base)
37+
BASE="${2:-}"
38+
shift 2
39+
;;
40+
--out)
41+
OUT="${2:-}"
42+
shift 2
43+
;;
44+
*)
45+
echo "unknown arg: $1" >&2
46+
usage >&2
47+
exit 2
48+
;;
49+
esac
50+
done
51+
52+
if [[ -z "${OUT}" ]]; then
53+
mkdir -p "releases"
54+
OUT="releases/${VERSION}.yaml"
55+
fi
56+
57+
if [[ ! -d "${BASE}" ]]; then
58+
echo "missing base dir: ${BASE}" >&2
59+
exit 1
60+
fi
61+
62+
REL_BRANCH="release/${VERSION}"
63+
64+
tmp="$(mktemp)"
65+
trap 'rm -f "$tmp"' EXIT
66+
67+
{
68+
echo "version: ${VERSION}"
69+
echo "generated_at_utc: \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\""
70+
echo "base_dir: \"${BASE}\""
71+
echo "release_branch: \"${REL_BRANCH}\""
72+
echo "repos:"
73+
} >"$tmp"
74+
75+
for d in "${BASE}"/*; do
76+
[[ -d "$d" ]] || continue
77+
[[ -d "$d/.git" ]] || continue
78+
79+
name="$(basename "$d")"
80+
remote_url="$(cd "$d" && git config --get remote.origin.url 2>/dev/null || true)"
81+
82+
rel_ref=""
83+
rel_sha=""
84+
if (cd "$d" && git show-ref --verify --quiet "refs/remotes/origin/${REL_BRANCH}"); then
85+
rel_ref="refs/remotes/origin/${REL_BRANCH}"
86+
rel_sha="$(cd "$d" && git rev-parse "refs/remotes/origin/${REL_BRANCH}")"
87+
elif (cd "$d" && git show-ref --verify --quiet "refs/heads/${REL_BRANCH}"); then
88+
rel_ref="refs/heads/${REL_BRANCH}"
89+
rel_sha="$(cd "$d" && git rev-parse "refs/heads/${REL_BRANCH}")"
90+
fi
91+
92+
tag_sha=""
93+
if (cd "$d" && git show-ref --tags --quiet --verify "refs/tags/${VERSION}"); then
94+
tag_sha="$(cd "$d" && git rev-parse "${VERSION}^{}" 2>/dev/null || git rev-parse "${VERSION}" 2>/dev/null || true)"
95+
fi
96+
97+
{
98+
echo " - name: \"${name}\""
99+
echo " path: \"${d}\""
100+
if [[ -n "${remote_url}" ]]; then
101+
echo " remote: \"${remote_url}\""
102+
else
103+
echo " remote: \"\""
104+
fi
105+
echo " release:"
106+
echo " branch: \"${REL_BRANCH}\""
107+
echo " ref: \"${rel_ref}\""
108+
echo " sha: \"${rel_sha}\""
109+
echo " tag:"
110+
echo " name: \"${VERSION}\""
111+
echo " sha: \"${tag_sha}\""
112+
} >>"$tmp"
113+
done
114+
115+
mv "$tmp" "$OUT"
116+
echo "wrote: ${OUT}"
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
usage() {
5+
cat <<'EOF'
6+
Copy this skill into all local Cloud-Neutral Toolkit sub-repos.
7+
8+
Usage:
9+
sync_skill_to_subrepos.sh
10+
11+
Copies:
12+
skills/release-branch-policy -> <repo>/skills/release-branch-policy
13+
14+
Notes:
15+
- Local path root: /Users/shenlan/workspaces/cloud-neutral-toolkit
16+
- Skips directories without .git
17+
EOF
18+
}
19+
20+
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
21+
usage
22+
exit 0
23+
fi
24+
25+
SRC_SKILL="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
26+
SRC_REPO_ROOT="$(cd "${SRC_SKILL}/../.." && pwd)"
27+
28+
realpath_py() {
29+
python3 - "$1" <<'PY'
30+
import os, sys
31+
print(os.path.realpath(sys.argv[1]))
32+
PY
33+
}
34+
35+
BASE="/Users/shenlan/workspaces/cloud-neutral-toolkit"
36+
if [[ ! -d "${BASE}" ]]; then
37+
echo "missing base dir: ${BASE}" >&2
38+
exit 1
39+
fi
40+
41+
for d in "${BASE}"/*; do
42+
[[ -d "$d" ]] || continue
43+
[[ -d "$d/.git" ]] || continue
44+
45+
# Don't delete our own source while syncing.
46+
if [[ "$(realpath_py "$d")" == "$(realpath_py "$SRC_REPO_ROOT")" ]]; then
47+
echo ">>> skipping source repo $d"
48+
continue
49+
fi
50+
51+
mkdir -p "$d/skills"
52+
echo ">>> syncing to $d"
53+
rm -rf "$d/skills/release-branch-policy"
54+
cp -R "${SRC_SKILL}" "$d/skills/release-branch-policy"
55+
done

0 commit comments

Comments
 (0)