-
Notifications
You must be signed in to change notification settings - Fork 25
Expand file tree
/
Copy pathsetup-branch-ruleset.sh
More file actions
281 lines (261 loc) · 12.1 KB
/
Copy pathsetup-branch-ruleset.sh
File metadata and controls
281 lines (261 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
#!/usr/bin/env bash
#
# One-time setup: move main's protection from classic branch protection to a
# repository ruleset with a bypass actor the release workflow can push as.
#
# TWO ROUTES. Pick by what access you have:
#
# BYPASS=deploy-key (default) REPO ADMIN is enough.
# A repo-scoped SSH deploy key with write access. Blunter than the App --
# write to the whole repo, no expiry -- but it belongs to the repository
# rather than to a person, and needs nobody above repo admin.
#
# BYPASS=app Requires an ORGANIZATION OWNER.
# An org-owned GitHub App. Better hygiene: scoped to Contents: write, the
# token expires in an hour, and it is auditable as an app. Preferred if
# you can get an owner to do STEP 1-APP.
#
# Both are recognised by release.yml, which prefers the App when both exist.
# Neither puts a personal credential in the pipeline, which is the thing the
# CircleCI setup (a maintainer's own SSH key) got wrong.
#
# WHY
# The release workflow pushes the `chore(release): publish` commit and the
# per-package tags. Classic branch protection has no bypass list — only repo
# admins skip the pull-request requirement — which is why the CircleCI release
# had to push with a maintainer's personal SSH key. Rulesets do support bypass
# actors, so migrating lets CI push with no personal credential anywhere.
#
# A ruleset cannot relax classic protection: when both exist GitHub applies
# the most restrictive of the two. The classic rule must therefore be deleted,
# which is why this script does both halves.
#
# WHY NOT THE BUILT-IN GITHUB_TOKEN
# An earlier version of this script used the "GitHub Actions" app (id 15368)
# as the bypass actor. That cannot work, and GitHub rejects it outright:
#
# HTTP 422: Actor GitHub Actions integration must be part of the ruleset
# source or owner organization
#
# App 15368 is owned by `github`, not by this organization, and a ruleset only
# accepts bypass actors belonging to the repo or its owning org. There is no
# repository setting that grants the built-in GITHUB_TOKEN a push to a
# PR-protected branch. An org-owned App is the supported route, and unlike a
# PAT it is not tied to any individual's account or expiry.
#
# WHAT CHANGES FOR HUMANS
# Nothing. The ruleset below reproduces main's current rules exactly:
# 1 approving review, code-owner review required, stale reviews dismissed on
# push, last-push approval required, no force pushes, no branch deletion.
#
# ---------------------------------------------------------------------------
# STEP 1-DEPLOY-KEY — the repo-admin route (~3 minutes, no org access)
#
# 1. Generate a keypair. Nothing but this repo will ever use it, so it does
# not belong in ~/.ssh:
# ssh-keygen -t ed25519 -N '' -C 'codecs release' -f ./codecs-release-key
#
# 2. Add the PUBLIC half as a deploy key WITH WRITE ACCESS:
# gh repo deploy-key add ./codecs-release-key.pub \
# --repo cornerstonejs/codecs --title 'codecs release' --allow-write
# (UI equivalent: Settings -> Deploy keys -> Add deploy key, tick
# "Allow write access".)
#
# 3. Add the PRIVATE half as the secret release.yml reads, then delete both
# local halves -- the repo and the secret are the only copies you need:
# gh secret set RELEASE_DEPLOY_KEY --repo cornerstonejs/codecs < ./codecs-release-key
# rm ./codecs-release-key ./codecs-release-key.pub
#
# 4. Run this script (default BYPASS=deploy-key), then STEP 3.
# gh auth login # as a repo admin
# bash tools/release/setup-branch-ruleset.sh
#
# STEP 1-APP — the org-owner route (GitHub UI, ~5 minutes)
#
# 1. https://github.com/organizations/cornerstonejs/settings/apps/new
# GitHub App name: cornerstonejs-release
# Homepage URL: https://github.com/cornerstonejs/codecs
# Webhook: UNCHECK "Active" — this App never receives events
# Repository permissions:
# Contents ......... Read and write (push the commit + tags)
# Metadata ......... Read-only (added automatically)
# Nothing else. Do NOT grant Actions, Packages, or Administration.
# "Where can this GitHub App be installed?" -> Only on this account
# Create, then note the App ID shown on the settings page.
#
# 2. Still on the App's page: "Private keys" -> "Generate a private key".
# A .pem downloads. It is shown once.
#
# 3. "Install App" (left sidebar) -> Install on cornerstonejs ->
# "Only select repositories" -> codecs -> Install.
#
# 4. Store the credentials on the repo (or the org, if you prefer to share
# the App with other repos later):
# gh variable set RELEASE_APP_ID --repo cornerstonejs/codecs --body '<App ID>'
# gh secret set RELEASE_APP_PRIVATE_KEY --repo cornerstonejs/codecs < /path/to/key.pem
# Then delete the local .pem. release.yml reads exactly these two names.
#
# 2. Run this script in app mode, then STEP 3:
# gh auth login # as the org owner
# BYPASS=app RELEASE_APP_SLUG=cornerstonejs-release \
# bash tools/release/setup-branch-ruleset.sh
#
# STEP 3 — verify, either route:
# gh api repos/cornerstonejs/codecs/rulesets
# gh api repos/cornerstonejs/codecs/branches/main/protection # expect 404
# Then re-run the failed Release workflow. Its push step logs which
# credential it used, and warns if neither is configured, so a half-done
# STEP 1 says so plainly instead of failing with "protected branch hook
# declined".
# ---------------------------------------------------------------------------
set -euo pipefail
REPO="${REPO:-cornerstonejs/codecs}"
ORG="${REPO%%/*}"
BYPASS="${BYPASS:-deploy-key}"
case "$BYPASS" in
deploy-key)
# A deploy key belongs to the repository by definition, so it satisfies
# "part of the ruleset source" with no id to resolve and no ownership
# question -- which is exactly why this route needs nothing above repo
# admin. actor_id MUST be null for this actor_type.
BYPASS_ACTOR_JSON='{ "actor_id": null, "actor_type": "DeployKey", "bypass_mode": "always" }'
echo "Bypass actor: EVERY write-enabled deploy key on $REPO"
echo
# Note the blast radius, which is the one real drawback of this route: the
# DeployKey actor takes actor_id null, so it is a category, not a specific
# key. There is no way to grant bypass to one deploy key and withhold it from
# another. Every write-enabled key on the repo, present and future, can push
# to main without review.
#
# So the write-enabled keys are listed here rather than merely counted, and
# the operator has to acknowledge the list by name before the ruleset is
# created. A leftover key from a retired CI system is the case that matters:
# it stops being an unused credential and becomes one that bypasses branch
# protection. This repo had exactly that -- a read-write `Codecs CircleCI`
# key, years after CircleCI stopped running here. A warning printed above a
# y/N prompt is too easy to scroll past for a privilege escalation that
# silent, hence the typed acknowledgement.
WRITE_KEYS=$(gh repo deploy-key list --repo "$REPO" 2>/dev/null | grep -F 'read-write' || true)
if [ -z "$WRITE_KEYS" ]; then
echo "WARNING: $REPO has no write-enabled deploy key, so the release still" >&2
echo " cannot push. Do STEP 1-DEPLOY-KEY 1-3." >&2
echo >&2
else
echo "These write-enabled deploy keys will ALL be able to push to main,"
echo "bypassing pull request review, once this ruleset exists:"
echo
printf '%s\n' "$WRITE_KEYS" | sed 's/^/ /'
echo
echo "Delete any that are not the release key, then re-run:"
echo " gh repo deploy-key delete <id> --repo $REPO"
echo
if [ "${DEPLOY_KEYS_AUDITED:-}" = "1" ]; then
echo "DEPLOY_KEYS_AUDITED=1 set; skipping the acknowledgement prompt."
else
read -r -p "Type 'audited' if every key above is meant to have that: " ack
if [ "$ack" != "audited" ]; then
echo "Aborted -- nothing was changed." >&2
exit 1
fi
fi
echo
fi
;;
app)
# The App whose installation is allowed to bypass the pull-request rule. Must
# be owned by $ORG — see "WHY NOT THE BUILT-IN GITHUB_TOKEN" above. Pass the
# slug from the App's URL
# (github.com/organizations/<org>/settings/apps/<slug>), which is the name
# lowercased with spaces as hyphens.
RELEASE_APP_SLUG="${RELEASE_APP_SLUG:-cornerstonejs-release}"
# gh's built-in --jq, not standalone jq: this script is run from a
# maintainer's own machine, where jq is not a given (release.yml can assume
# it, a laptop cannot). One call, both fields, split below.
if ! APP_INFO=$(gh api "apps/$RELEASE_APP_SLUG" --jq '"\(.id) \(.owner.login)"' 2>/dev/null); then
cat >&2 <<MSG
Could not find a GitHub App with slug '$RELEASE_APP_SLUG'.
Complete STEP 1-APP in this script's header first, then re-run with the slug:
BYPASS=app RELEASE_APP_SLUG=<slug> bash tools/release/setup-branch-ruleset.sh
The slug is the last path segment of the App's settings URL.
MSG
exit 1
fi
read -r RELEASE_APP_ID RELEASE_APP_OWNER <<<"$APP_INFO"
# Fail here rather than let the API return the 422 this script exists to
# avoid.
if [ "$RELEASE_APP_OWNER" != "$ORG" ]; then
cat >&2 <<MSG
App '$RELEASE_APP_SLUG' (id $RELEASE_APP_ID) is owned by '$RELEASE_APP_OWNER', not '$ORG'.
A repository ruleset only accepts bypass actors belonging to the repo or its
owning organization, so GitHub would reject this with:
422 Actor ... must be part of the ruleset source or owner organization
Create the App under the $ORG organization (STEP 1-APP) rather than under a
personal account. If you do not have organization owner access, use the
deploy-key route instead -- it needs only repo admin:
bash tools/release/setup-branch-ruleset.sh
MSG
exit 1
fi
BYPASS_ACTOR_JSON="{ \"actor_id\": $RELEASE_APP_ID, \"actor_type\": \"Integration\", \"bypass_mode\": \"always\" }"
echo "Bypass actor: $RELEASE_APP_SLUG (App id $RELEASE_APP_ID, owned by $RELEASE_APP_OWNER)"
# An App that is not installed on the repo yields a ruleset that looks
# correct and still cannot push. Warn rather than fail: listing installations
# needs admin:org, which the operator may deliberately not have granted.
if INSTALLS=$(gh api "orgs/$ORG/installations" --jq '.installations[].app_slug' 2>/dev/null); then
if ! printf '%s\n' "$INSTALLS" | grep -qx "$RELEASE_APP_SLUG"; then
echo "WARNING: '$RELEASE_APP_SLUG' is not installed on $ORG. Do STEP 1-APP.3." >&2
fi
else
echo "NOTE: could not list org installations (needs admin:org); skipping" >&2
echo " the install check. Confirm STEP 1-APP.3 was done." >&2
fi
echo
;;
*)
echo "BYPASS must be 'deploy-key' (repo admin) or 'app' (org owner); got '$BYPASS'." >&2
exit 1
;;
esac
echo "Current protection on $REPO main:"
gh api "repos/$REPO/branches/main/protection" || true
echo
read -r -p "Create the replacement ruleset and delete the classic protection? [y/N] " reply
case "$reply" in
[yY]) ;;
*) echo "Aborted."; exit 1 ;;
esac
echo "Creating ruleset..."
gh api -X POST "repos/$REPO/rulesets" --input - <<JSON
{
"name": "main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": { "include": ["refs/heads/main"], "exclude": [] }
},
"bypass_actors": [
$BYPASS_ACTOR_JSON
],
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": true,
"required_review_thread_resolution": false,
"allowed_merge_methods": ["merge", "squash", "rebase"]
}
}
]
}
JSON
echo
echo "Deleting classic branch protection..."
gh api -X DELETE "repos/$REPO/branches/main/protection"
echo
echo "Done. Resulting rulesets:"
gh api "repos/$REPO/rulesets"