Skip to content

Commit 51c52bb

Browse files
lionuncleclaude
andcommitted
Add CI workflow and main-branch ruleset definition
Sets up everything needed to protect main once the repo flips to public. CI workflow (.github/workflows/ci.yml) runs on every PR: - python (3.9-3.12) — verifies cli.py imports + editable install works on every supported Python version. Catches the 3.9 PEP 604 regression the audit pass fixed and any future drift. - version-sync — verifies pyproject.toml, npm/package.json, and src/rpr/__init__.py agree. - json-lint — validates examples/config.json and npm/package.json. - shellcheck — lints install.sh, scripts/bump.sh, and the new setup-branch-protection.sh. - npm-pack — exercises the npm prepack hook to make sure the package still builds. Branch protection (scripts/setup-branch-protection.sh) is an idempotent script that creates or updates a repository ruleset enforcing: - No deletion or force-push to main - Linear history (squash/rebase merges only) - All changes through pull request, conversations resolved - All eight CI checks must pass before merge - Org admins (dedev-llc maintainers) may bypass in an emergency GitHub gates rulesets behind a paid plan for private repos on the free tier — verified by testing both 'gh api branches/main/protection' and 'gh api rulesets', both 403 with 'Upgrade to GitHub Pro'. So the script can't run today; it's staged for the maintainer to invoke once the repo goes public. CONTRIBUTING.md has the one-line instructions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 338ba8e commit 51c52bb

3 files changed

Lines changed: 252 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
name: ci
2+
3+
# Runs on every PR targeting main, plus pushes to main itself. The job names
4+
# below are referenced by name in scripts/setup-branch-protection.sh — if you
5+
# rename a job, update the ruleset script too.
6+
7+
on:
8+
pull_request:
9+
branches: [main]
10+
push:
11+
branches: [main]
12+
13+
permissions:
14+
contents: read
15+
16+
concurrency:
17+
group: ci-${{ github.ref }}
18+
cancel-in-progress: true
19+
20+
jobs:
21+
python:
22+
name: python (${{ matrix.python-version }})
23+
runs-on: ubuntu-latest
24+
strategy:
25+
fail-fast: false
26+
matrix:
27+
python-version: ["3.9", "3.10", "3.11", "3.12"]
28+
steps:
29+
- uses: actions/checkout@v4
30+
31+
- uses: actions/setup-python@v5
32+
with:
33+
python-version: ${{ matrix.python-version }}
34+
35+
- name: Verify cli.py imports and --help works
36+
run: python src/rpr/cli.py --help > /dev/null
37+
38+
- name: Editable install + module entry point
39+
run: |
40+
pip install -e .
41+
python -c "import rpr; print('rpr', rpr.__version__)"
42+
rpr --help > /dev/null
43+
python -m rpr --help > /dev/null
44+
45+
version-sync:
46+
runs-on: ubuntu-latest
47+
steps:
48+
- uses: actions/checkout@v4
49+
50+
- uses: actions/setup-python@v5
51+
with:
52+
python-version: "3.12"
53+
54+
- uses: actions/setup-node@v4
55+
with:
56+
node-version: "20"
57+
58+
- name: Verify pyproject.toml, npm/package.json and __init__.py agree
59+
run: |
60+
set -euo pipefail
61+
py_version=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')
62+
npm_version=$(node -p "require('./npm/package.json').version")
63+
init_version=$(python3 -c 'import re,pathlib; m=re.search(r"__version__\s*=\s*\"([^\"]+)\"", pathlib.Path("src/rpr/__init__.py").read_text()); print(m.group(1) if m else "MISSING")')
64+
65+
echo "pyproject.toml = $py_version"
66+
echo "npm/package.json = $npm_version"
67+
echo "src/rpr/__init__.py = $init_version"
68+
69+
if [ "$py_version" != "$npm_version" ] || [ "$py_version" != "$init_version" ]; then
70+
echo "::error::Version mismatch — use scripts/bump.sh to update all three files in lockstep"
71+
exit 1
72+
fi
73+
74+
json-lint:
75+
runs-on: ubuntu-latest
76+
steps:
77+
- uses: actions/checkout@v4
78+
- name: Validate JSON files
79+
run: |
80+
set -euo pipefail
81+
for f in examples/config.json npm/package.json; do
82+
python3 -c "import json,sys; json.load(open('$f'))"
83+
echo "ok: $f"
84+
done
85+
86+
shellcheck:
87+
runs-on: ubuntu-latest
88+
steps:
89+
- uses: actions/checkout@v4
90+
- name: Run shellcheck on shell scripts
91+
run: |
92+
set -euo pipefail
93+
sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck
94+
shellcheck install.sh scripts/bump.sh scripts/setup-branch-protection.sh
95+
96+
npm-pack:
97+
runs-on: ubuntu-latest
98+
steps:
99+
- uses: actions/checkout@v4
100+
- uses: actions/setup-node@v4
101+
with:
102+
node-version: "20"
103+
- name: npm pack --dry-run (exercises prepack hook)
104+
working-directory: npm
105+
run: npm pack --dry-run

CONTRIBUTING.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,25 @@ gh pr create --fill # review, merge — workflow publishes to PyPI, n
8181

8282
The workflow's preflight check fails fast if the three version files drift, so always use `scripts/bump.sh` rather than editing them by hand. See the **Publishing** section of [README.md](README.md#publishing-maintainer-notes) for the secrets and one-time setup the workflow depends on.
8383

84+
## Branch protection (maintainers only)
85+
86+
The `main` branch is protected by a ruleset defined in [`scripts/setup-branch-protection.sh`](scripts/setup-branch-protection.sh). The ruleset enforces:
87+
88+
- No deletion of `main`, no force-pushes
89+
- Linear history (squash or rebase merges only)
90+
- All changes go through a pull request
91+
- All CI checks in [`.github/workflows/ci.yml`](.github/workflows/ci.yml) must pass before merge
92+
- Conversations resolved before merge
93+
- Org admins (i.e. maintainers) may bypass in an emergency
94+
95+
GitHub gates rulesets behind a paid plan for **private** repos. While the repo is private, the script can't apply the ruleset (CI still runs on PRs, but the rules aren't enforced). After flipping the repo to public:
96+
97+
```bash
98+
scripts/setup-branch-protection.sh
99+
```
100+
101+
The script is idempotent — re-run it any time you add or remove CI jobs (just update the `REQUIRED_CHECKS` array first).
102+
84103
## Questions
85104

86105
Open an issue with the `question` label, or start a discussion if discussions are enabled on the repo.

scripts/setup-branch-protection.sh

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env bash
2+
# setup-branch-protection.sh — apply the main-branch ruleset to dedev-llc/rpr.
3+
#
4+
# Idempotent: re-running updates the existing ruleset rather than duplicating.
5+
#
6+
# WHEN TO RUN:
7+
# - After flipping the repo from private → public (rulesets are paywalled
8+
# on private repos with the free GitHub plan).
9+
# - After adding/removing CI checks in .github/workflows/ci.yml — update
10+
# the REQUIRED_CHECKS array below to match the new job names.
11+
#
12+
# REQUIREMENTS:
13+
# - gh CLI authenticated as a dedev-llc org admin
14+
# - The CI workflow (.github/workflows/ci.yml) must already exist on main,
15+
# otherwise the required status checks will block all PRs forever (no run
16+
# can ever produce them).
17+
#
18+
# WHAT THE RULESET ENFORCES:
19+
# - No deletion of main
20+
# - No force pushes (non-fast-forward) to main
21+
# - Linear history (no merge commits inside PRs — squash/rebase only)
22+
# - All changes go through a pull request
23+
# - PR conversations must be resolved before merge
24+
# - Stale review approvals are dismissed when new commits are pushed
25+
# - All required CI checks must pass before merge
26+
# - Org admins can bypass in an emergency (configurable below)
27+
28+
set -euo pipefail
29+
30+
REPO="dedev-llc/rpr"
31+
RULESET_NAME="main-protection"
32+
33+
# Required CI status check job names — must match the `name:` (or job key when
34+
# no name is set) of the jobs in .github/workflows/ci.yml. For matrix jobs,
35+
# include each variant explicitly.
36+
REQUIRED_CHECKS=(
37+
"python (3.9)"
38+
"python (3.10)"
39+
"python (3.11)"
40+
"python (3.12)"
41+
"version-sync"
42+
"json-lint"
43+
"shellcheck"
44+
"npm-pack"
45+
)
46+
47+
# Build the JSON array of required status checks.
48+
contexts_json=$(printf '%s\n' "${REQUIRED_CHECKS[@]}" \
49+
| python3 -c '
50+
import json, sys
51+
checks = [{"context": line.strip()} for line in sys.stdin if line.strip()]
52+
print(json.dumps(checks))
53+
')
54+
55+
# Build the full ruleset payload. Heredoc + envsubst-style substitution would
56+
# be cleaner but we want zero dependencies — using python for JSON assembly.
57+
ruleset_json=$(CONTEXTS="$contexts_json" python3 - <<'PY'
58+
import json, os
59+
payload = {
60+
"name": "main-protection",
61+
"target": "branch",
62+
"enforcement": "active",
63+
"conditions": {
64+
"ref_name": {
65+
"include": ["~DEFAULT_BRANCH"],
66+
"exclude": [],
67+
}
68+
},
69+
"rules": [
70+
{"type": "deletion"},
71+
{"type": "non_fast_forward"},
72+
{"type": "required_linear_history"},
73+
{
74+
"type": "pull_request",
75+
"parameters": {
76+
"required_approving_review_count": 0,
77+
"dismiss_stale_reviews_on_push": True,
78+
"require_code_owner_review": False,
79+
"require_last_push_approval": False,
80+
"required_review_thread_resolution": True,
81+
"allowed_merge_methods": ["squash", "rebase"],
82+
},
83+
},
84+
{
85+
"type": "required_status_checks",
86+
"parameters": {
87+
"strict_required_status_checks_policy": False,
88+
"required_status_checks": json.loads(os.environ["CONTEXTS"]),
89+
},
90+
},
91+
],
92+
"bypass_actors": [
93+
# OrganizationAdmin = anyone with org admin role on dedev-llc.
94+
# bypass_mode "always" means they can bypass without going through
95+
# the normal flow (e.g. emergency direct push). Change to "pull_request"
96+
# to require admins to still go through a PR but skip the checks.
97+
{
98+
"actor_id": 1,
99+
"actor_type": "OrganizationAdmin",
100+
"bypass_mode": "always",
101+
}
102+
],
103+
}
104+
print(json.dumps(payload, indent=2))
105+
PY
106+
)
107+
108+
echo "Target repo: $REPO"
109+
echo "Ruleset name: $RULESET_NAME"
110+
echo "Required checks:"
111+
printf ' - %s\n' "${REQUIRED_CHECKS[@]}"
112+
echo
113+
114+
# Look for an existing ruleset with the same name.
115+
existing_id=$(gh api "repos/$REPO/rulesets" --jq ".[] | select(.name == \"$RULESET_NAME\") | .id" 2>/dev/null || true)
116+
117+
if [ -n "$existing_id" ]; then
118+
echo "Updating existing ruleset (id=$existing_id)..."
119+
printf '%s' "$ruleset_json" | gh api -X PUT "repos/$REPO/rulesets/$existing_id" --input - > /dev/null
120+
echo "✓ Ruleset updated"
121+
else
122+
echo "Creating new ruleset..."
123+
printf '%s' "$ruleset_json" | gh api -X POST "repos/$REPO/rulesets" --input - > /dev/null
124+
echo "✓ Ruleset created"
125+
fi
126+
127+
echo
128+
echo "View at: https://github.com/$REPO/rules"

0 commit comments

Comments
 (0)