feat: Implement release branch policy skill with ruleset, scripts, and documentation, and update gitignore to exclude /accountsvc.
This commit is contained in:
parent
b23973c6e5
commit
e20d219143
2
.gitignore
vendored
2
.gitignore
vendored
@ -64,7 +64,7 @@ out/
|
||||
target/
|
||||
xcontrol-account
|
||||
account-export.yaml
|
||||
accountsvc
|
||||
/accountsvc
|
||||
|
||||
# Security tooling reports
|
||||
.gitleaks/
|
||||
|
||||
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