@@ -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+ */
116123function 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+
125144function 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+
202233async 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