Skip to content

Commit ae04bdd

Browse files
committed
fix(release): require full v* coverage from the tag ruleset and fail on API errors
A tag ruleset counts only with no exclusions and, where GitHub shows its bypass list, only admin bypass. A missing environment is a setup gap only on a 404; any other API failure now fails the plan instead of reading as a gap.
1 parent 1ee429a commit ae04bdd

2 files changed

Lines changed: 66 additions & 6 deletions

File tree

‎scripts/__tests__/release-plan.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,35 @@ test('each missing protection is named on its own', () => {
163163
tagRulesets: [{ ...setup.tagRulesets[0], enforcement: 'evaluate' }],
164164
};
165165
assert.match(repositorySetupGaps(evaluating).join(), /tag ruleset/);
166+
const anyBranchToggle = {
167+
...setup,
168+
npmPublish: { deployment_branch_policy: { custom_branch_policies: false } },
169+
};
170+
assert.match(repositorySetupGaps(anyBranchToggle).join(), /npm-publish environment/);
171+
const excluding = {
172+
...setup,
173+
tagRulesets: [
174+
{
175+
...setup.tagRulesets[0],
176+
conditions: { ref_name: { include: ['refs/tags/v*'], exclude: ['refs/tags/v9*'] } },
177+
},
178+
],
179+
};
180+
assert.match(repositorySetupGaps(excluding).join(), /tag ruleset/);
181+
const writerBypass = {
182+
...setup,
183+
tagRulesets: [
184+
{ ...setup.tagRulesets[0], bypass_actors: [{ actor_type: 'RepositoryRole', actor_id: 4 }] },
185+
],
186+
};
187+
assert.match(repositorySetupGaps(writerBypass).join(), /tag ruleset/);
188+
const adminBypass = {
189+
...setup,
190+
tagRulesets: [
191+
{ ...setup.tagRulesets[0], bypass_actors: [{ actor_type: 'RepositoryRole', actor_id: 5 }] },
192+
],
193+
};
194+
assert.deepEqual(repositorySetupGaps(adminBypass), []);
166195
const creatable = {
167196
...setup,
168197
tagRulesets: [{ ...setup.tagRulesets[0], rules: [{ type: 'deletion' }] }],

‎scripts/release-plan.mjs‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,20 +108,39 @@ export function repositorySetupGaps({ npmPublish, npmPublishPolicies, release, t
108108
const reviewers = release?.protection_rules?.find((rule) => rule.type === 'required_reviewers');
109109
if (!reviewers?.reviewers?.length) gaps.push('the release environment must require a reviewer');
110110
if (!tagRulesets.some(guardsReleaseTags)) {
111-
gaps.push('an active tag ruleset must restrict creating, updating, and deleting refs/tags/v*');
111+
gaps.push(
112+
'an active tag ruleset must restrict creating, updating, and deleting every refs/tags/v* tag, ' +
113+
'with no exclusions and only admins able to bypass',
114+
);
112115
}
113116
return gaps;
114117
}
115118

119+
/**
120+
* A ruleset covers every `v*` tag only with no exclusions. GitHub returns `bypass_actors` only to
121+
* admins, never to the Plan job's token, so the admin-only bypass is checked where it is visible.
122+
*/
116123
function guardsReleaseTags(ruleset) {
117124
const types = new Set(ruleset.rules.map((rule) => rule.type));
125+
const refs = ruleset.conditions?.ref_name;
118126
return (
119127
ruleset.enforcement === 'active' &&
120-
ruleset.conditions?.ref_name?.include?.includes('refs/tags/v*') &&
121-
['creation', 'update', 'deletion'].every((type) => types.has(type))
128+
(refs?.include?.includes('refs/tags/v*') || refs?.include?.includes('~ALL')) &&
129+
(refs.exclude ?? []).length === 0 &&
130+
['creation', 'update', 'deletion'].every((type) => types.has(type)) &&
131+
(ruleset.bypass_actors ?? []).every(isAdminBypass)
132+
);
133+
}
134+
135+
function isAdminBypass(actor) {
136+
return (
137+
actor.actor_type === 'OrganizationAdmin' ||
138+
(actor.actor_type === 'RepositoryRole' && actor.actor_id === REPOSITORY_ADMIN_ROLE)
122139
);
123140
}
124141

142+
const REPOSITORY_ADMIN_ROLE = 5;
143+
125144
function assertAheadOfStable(run, version) {
126145
if (run.latestStable && compareReleaseVersions(version, run.latestStable) <= 0) {
127146
throw new Error(
@@ -195,18 +214,30 @@ async function github(env, pathname) {
195214
authorization: `Bearer ${env.GITHUB_TOKEN}`,
196215
},
197216
});
198-
if (!response.ok) throw new Error(`GitHub API ${pathname} failed: ${response.status}`);
217+
if (!response.ok) {
218+
throw Object.assign(new Error(`GitHub API ${pathname} failed: ${response.status}`), {
219+
status: response.status,
220+
});
221+
}
199222
return response.json();
200223
}
201224

225+
/** A missing environment is a setup gap; any other failure must not read as one. */
226+
function absentOn404(fallback) {
227+
return (error) => {
228+
if (error.status === 404) return fallback;
229+
throw error;
230+
};
231+
}
232+
202233
async function readRepositorySetup(env) {
203-
const environment = (name) => github(env, `environments/${name}`).catch(() => null);
234+
const environment = (name) => github(env, `environments/${name}`).catch(absentOn404(null));
204235
const summaries = await github(env, 'rulesets?targets=tag');
205236
return {
206237
npmPublish: await environment('npm-publish'),
207238
npmPublishPolicies: await github(env, 'environments/npm-publish/deployment-branch-policies')
208239
.then((body) => body.branch_policies)
209-
.catch(() => []),
240+
.catch(absentOn404([])),
210241
release: await environment('release'),
211242
tagRulesets: await Promise.all(
212243
summaries.map((summary) => github(env, `rulesets/${summary.id}`)),

0 commit comments

Comments
 (0)