Repository navigation
Expand file tree
/
Copy pathrulesets.js
More file actions
209 lines (177 loc) · 6.34 KB
/
Copy pathrulesets.js
File metadata and controls
209 lines (177 loc) · 6.34 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
import { OutcomeError } from './outcome.js'
const MAIN = 'aip-machine-main'
const PROTECT = 'aip-machine-protect'
const DEFAULT_BRANCH = '~DEFAULT_BRANCH'
// The check aip-machine-main requires, from GitHub Actions (scripts/setup/
// rulesets/machine-main.json holds the same pair).
const VALIDATE_CONTEXT = 'validate-desired-state / validate'
const GITHUB_ACTIONS_APP_ID = 15368
// The Apps that run `submit`, and so the only actors allowed to bypass the
// pull-request rule of aip-machine-main (design B `#rulesets`).
const SUBMIT_APPS = [
'ai-platform-orchestrator',
'ai-platform-orchestrator-infradev'
]
function changed(message) {
return new OutcomeError('rulesets-changed', message)
}
async function submitAppIds(github) {
const ids = []
for (const slug of SUBMIT_APPS) {
try {
const { data } = await github.rest.apps.getBySlug({ app_slug: slug })
ids.push(data.id)
} catch (error) {
// An App that is not there bypasses nothing, so nothing is expected.
if (error.status !== 404) {
throw error
}
}
}
return ids
}
// GitHub leaves bypass_actors out of a ruleset for a caller without write
// access to it, as the App's read-only token is. An admin checks the bypass
// lists by hand with scripts/setup/repo-settings.sh --verify instead.
function hasBypassList(ruleset) {
return Array.isArray(ruleset.bypass_actors)
}
function ruleOf(ruleset, type) {
return ruleset.rules?.find((rule) => rule.type === type)
}
// Both rulesets guard the default branch and nothing else: a ruleset that also
// names another branch, or excludes the default one, protects nothing.
function checkScope(name, ruleset) {
const ref = ruleset.conditions?.ref_name
const defaultOnly =
ruleset.target === 'branch' &&
ref?.include?.length === 1 &&
ref.include[0] === DEFAULT_BRANCH &&
(ref.exclude ?? []).length === 0
if (!defaultOnly) {
throw changed(`${name} no longer applies to the default branch alone`)
}
}
function requiresValidation(ruleset) {
const checks =
ruleOf(ruleset, 'required_status_checks')?.parameters
?.required_status_checks ?? []
return checks.some(
(check) =>
check.context === VALIDATE_CONTEXT &&
check.integration_id === GITHUB_ACTIONS_APP_ID
)
}
function checkMain(ruleset, appIds) {
checkScope(MAIN, ruleset)
if (hasBypassList(ruleset)) {
const bypass = ruleset.bypass_actors
const stray = bypass.find(
(actor) =>
actor.actor_type !== 'Integration' ||
actor.bypass_mode !== 'always' ||
!appIds.includes(actor.actor_id)
)
if (stray || bypass.length === 0) {
throw changed(
`${MAIN} has a bypass list that is not the submit Apps alone: ${JSON.stringify(
bypass.map(
({ actor_type: type, actor_id: id, bypass_mode: mode }) => ({
type,
id,
mode
})
)
)}`
)
}
}
const review = ruleOf(ruleset, 'pull_request')?.parameters
if (!(
review?.required_approving_review_count >= 1 &&
review.require_code_owner_review
)) {
throw changed(
`${MAIN} no longer requires a pull request with a code-owner review`
)
}
if (!requiresValidation(ruleset)) {
throw changed(
`${MAIN} no longer requires a status check: ${VALIDATE_CONTEXT} from GitHub Actions`
)
}
}
function checkProtect(ruleset) {
checkScope(PROTECT, ruleset)
if (hasBypassList(ruleset) && ruleset.bypass_actors.length > 0) {
throw changed(`${PROTECT} has a bypass list; it must have none`)
}
if (!ruleOf(ruleset, 'deletion') || !ruleOf(ruleset, 'non_fast_forward')) {
throw changed(`${PROTECT} no longer blocks force pushes and deletion`)
}
}
/**
* The apply's ruleset check (mitigation 2, plan §15): the state repository
* has the active repository rulesets `aip-machine-main` and
* `aip-machine-protect`, the first bypassed by the submit Apps alone and
* still requiring a pull request with a code-owner review and the check, the
* second by no one and still blocking force pushes and deletion, both
* guarding the default branch, which must still be main. Anything
* else stops the apply before it runs as the deploy identity, because a
* repository admin who can relax these can commit to main without review
* (accepted residual risk, §15; this makes it noticed, not impossible).
*
* The bypass lists are checked only where the token can see them: GitHub
* returns `bypass_actors` to callers with write access to the ruleset alone,
* so with the App's read-only token that part is left to an admin's manual
* `repo-settings.sh --verify`.
*
* @param {object} github - Client with Administration: read; the App's.
* @param {{owner: string, repo: string}} repository - The state repository.
* @param {{owner: string, repo: string}} repository - The state repository.
* @returns {Promise<void>} Resolves when the rulesets are as expected.
* @throws {OutcomeError} `rulesets-changed` when they are not.
*/
async function verifyRulesets(github, { owner, repo }) {
if (!github) {
throw new OutcomeError('failed', 'the ruleset check needs an App token')
}
const rulesets = await github.paginate(github.rest.repos.getRepoRulesets, {
owner,
repo,
includes_parents: false,
per_page: 100
})
const found = {}
for (const name of [MAIN, PROTECT]) {
found[name] = rulesets.find(
(ruleset) => ruleset.name === name && ruleset.source_type === 'Repository'
)
if (!found[name]) {
throw changed(`${name} is missing from ${repo}`)
}
}
const detail = {}
for (const name of [MAIN, PROTECT]) {
const { data } = await github.rest.repos.getRepoRuleset({
owner,
repo,
ruleset_id: found[name].id
})
if (data.enforcement !== 'active') {
throw changed(`${name} is not active in ${repo}`)
}
detail[name] = data
}
checkMain(detail[MAIN], await submitAppIds(github))
checkProtect(detail[PROTECT])
// ~DEFAULT_BRANCH guards whatever the default branch is, and the apply
// works on main alone, so both rulesets only protect it while it is main.
const { data: repository } = await github.rest.repos.get({ owner, repo })
if (repository.default_branch !== 'main') {
throw changed(
`the default branch is ${repository.default_branch}, so ${MAIN} and ${PROTECT} no longer guard main in ${repo}`
)
}
}
export { verifyRulesets }