@leverege/build-tools 2.117.0 → 2.119.0-pedro.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leverege/build-tools",
3
- "version": "2.117.0",
3
+ "version": "2.119.0-pedro.1",
4
4
  "description": "A collection of build / support tools for Leverege developers",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -19,6 +19,7 @@
19
19
  "test-all": "mocha -t 60000 --exit 'test/**/**.mjs'"
20
20
  },
21
21
  "bin": {
22
+ "branch-protect-it": "src/branch-protect-it.mjs",
22
23
  "build-cnpg-image": "src/build-cnpg-image.mjs",
23
24
  "build-tools": "src/build-tools.mjs",
24
25
  "chart-compass": "src/chart-compass.mjs",
@@ -103,7 +104,7 @@
103
104
  "googleapis": "^176.0.0",
104
105
  "handlebars": "^4.7.9",
105
106
  "ignore": "^7.0.6",
106
- "inquirer": "^14.1.0",
107
+ "inquirer": "^14.2.0",
107
108
  "js-yaml": "^4.2.0",
108
109
  "jsdoc": "^4.0.5",
109
110
  "ms": "^2.1.3",
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ import { $ } from 'zx'
3
+ import { program } from 'commander'
4
+ import chalk from 'chalk'
5
+
6
+ import { enableDebug, errorExit, log, proceed } from './Utils.mjs'
7
+
8
+ $.verbose = false
9
+
10
+ const DEFAULT_ORG = 'Leverege'
11
+ const REQUIRED_CHECK_PREFIX = 'node-ci'
12
+
13
+ program
14
+ .name( 'branch-protect-it' )
15
+ .description( chalk.green( 'Applies the standard branch-protection + merge-method bundle to a repo' ) )
16
+ .argument( '<repo>', 'Repository name (without org prefix)' )
17
+ .option( '--org <org>', 'GitHub org', DEFAULT_ORG )
18
+ .option( '--branch <branch>', "Branch to protect (defaults to the repo's actual default branch)" )
19
+ .option( '--require-approvals <n>', 'Required approving review count', '0' )
20
+ .option( '--dry-run', 'Show what would be applied without making changes' )
21
+ .option( '--check', 'Read-only: exits 0 (matches the standard bundle) or 1 (does not) — ' +
22
+ 'no prompt, no writes, no output at all by default. For scripting/dashboards (e.g. ' +
23
+ 'repo-panopticon) that only care about the exit code. Pair with --check-verbose for output.' )
24
+ .option( '--check-verbose', 'With --check, also print which specific settings are missing/wrong ' +
25
+ '(or a success line). Has no effect without --check.' )
26
+ .option( '--debug', 'Enable debug logging' )
27
+ .addHelpText( 'after', `
28
+ ${chalk.yellow( 'What this applies:' )}
29
+ - Require a pull request before merging (0+ approvals, configurable)
30
+ - Require the repo's node-ci status check to pass, and the branch to be up to date
31
+ - Block force pushes and branch deletion
32
+ - Admins can still bypass (enforce_admins: false) for emergencies
33
+ - Repo-level merge method restricted to squash-only
34
+ - Auto-delete head branches on merge (delete_branch_on_merge)
35
+ - Suggest updating out-of-date PR branches with one click (allow_update_branch)
36
+
37
+ ${chalk.yellow( 'Scope:' )}
38
+ Only supports repos on the ${chalk.green( 'node-ci' )} reusable workflow for now — it looks up
39
+ the real check-run name from the target branch's latest commit (never guesses one) and
40
+ errors out if nothing starting with "node-ci" is found there.
41
+
42
+ ${chalk.yellow( 'Examples:' )}
43
+ ${chalk.green( 'branch-protect-it secrets' )}
44
+ ${chalk.green( 'branch-protect-it secrets --dry-run' )}
45
+ ${chalk.green( 'branch-protect-it secrets --require-approvals 1' )}
46
+ ${chalk.green( 'branch-protect-it secrets --check && echo protected' )}
47
+ ${chalk.green( 'branch-protect-it secrets --check --check-verbose' )}
48
+ ` )
49
+ .parse()
50
+
51
+ const [ repo ] = program.args
52
+ const opts = program.opts()
53
+ if ( opts.debug ) { enableDebug(); $.verbose = true }
54
+
55
+ const fullRepo = `${opts.org}/${repo}`
56
+ // --check is silent by default (exit code only) — only chatty with --check-verbose.
57
+ // Every other mode (apply/dry-run) always logs normally.
58
+ const quietCheck = opts.check && !opts.checkVerbose
59
+ const checkLog = ( ...args ) => { if ( !quietCheck ) { log( ...args ) } }
60
+
61
+ // ---------- Resolve branch ----------
62
+
63
+ let branch = opts.branch
64
+ if ( !branch ) {
65
+ const { stdout } = await $`gh api -X GET repos/${fullRepo} -q .default_branch`
66
+ branch = stdout.trim()
67
+ }
68
+ checkLog( chalk.blue( `Target: ${fullRepo}@${branch}` ) )
69
+
70
+ // ---------- --check: read-only status, exit 0/1, no writes, no prompt ----------
71
+
72
+ if ( opts.check ) {
73
+ const reasons = []
74
+
75
+ let protection = null
76
+ try {
77
+ // .quiet() suppresses gh's own stderr (e.g. "Branch not protected") — that's an
78
+ // expected, handled outcome here, not something that should leak into --check's output
79
+ const { stdout } = await $`gh api -X GET repos/${fullRepo}/branches/${branch}/protection`.quiet()
80
+ protection = JSON.parse( stdout )
81
+ } catch {
82
+ reasons.push( 'no branch protection configured at all' )
83
+ }
84
+
85
+ if ( protection ) {
86
+ if ( !protection.required_pull_request_reviews ) { reasons.push( 'PR not required' ) }
87
+ if ( !protection.required_status_checks?.strict ) { reasons.push( 'status checks not required / not strict' ) }
88
+ if ( !protection.required_status_checks?.contexts?.some( c => c.startsWith( REQUIRED_CHECK_PREFIX ) ) ) {
89
+ reasons.push( `no required check starting with "${REQUIRED_CHECK_PREFIX}"` )
90
+ }
91
+ if ( protection.allow_force_pushes?.enabled !== false ) { reasons.push( 'force pushes not blocked' ) }
92
+ if ( protection.allow_deletions?.enabled !== false ) { reasons.push( 'branch deletion not blocked' ) }
93
+ if ( protection.enforce_admins?.enabled !== false ) { reasons.push( 'enforce_admins is not false (expected: admins can bypass)' ) }
94
+ }
95
+
96
+ let repoSettings = null
97
+ try {
98
+ const { stdout } = await $`gh api -X GET repos/${fullRepo} -q '{allow_squash_merge, allow_merge_commit, allow_rebase_merge, delete_branch_on_merge, allow_update_branch}'`
99
+ repoSettings = JSON.parse( stdout )
100
+ } catch ( error ) {
101
+ reasons.push( `could not read repo merge settings: ${error.stderr ?? error.message ?? error}` )
102
+ }
103
+
104
+ if ( repoSettings ) {
105
+ if ( repoSettings.allow_squash_merge !== true ) { reasons.push( 'squash merge not enabled' ) }
106
+ if ( repoSettings.allow_merge_commit !== false ) { reasons.push( 'merge commits not disabled' ) }
107
+ if ( repoSettings.allow_rebase_merge !== false ) { reasons.push( 'rebase merge not disabled' ) }
108
+ if ( repoSettings.delete_branch_on_merge !== true ) { reasons.push( 'delete_branch_on_merge not enabled' ) }
109
+ if ( repoSettings.allow_update_branch !== true ) { reasons.push( 'allow_update_branch not enabled' ) }
110
+ }
111
+
112
+ if ( reasons.length === 0 ) {
113
+ checkLog( chalk.green( `✔ ${fullRepo}@${branch} matches the standard branch-protection bundle` ) )
114
+ process.exit( 0 )
115
+ }
116
+
117
+ checkLog( chalk.red( `✘ ${fullRepo}@${branch} does not match the standard bundle:` ) )
118
+ reasons.forEach( r => checkLog( chalk.red( ` - ${r}` ) ) )
119
+ process.exit( 1 )
120
+ }
121
+
122
+ // ---------- Find the real node-ci check name — never guess it ----------
123
+
124
+ let checkName
125
+ try {
126
+ const { stdout : shaRaw } = await $`gh api -X GET repos/${fullRepo}/commits/${branch} -q .sha`
127
+ const sha = shaRaw.trim()
128
+ const { stdout : checksRaw } = await $`gh api -X GET repos/${fullRepo}/commits/${sha}/check-runs -q '.check_runs[].name'`
129
+ const checks = checksRaw.trim().split( '\n' ).filter( Boolean )
130
+ checkName = checks.find( c => c.startsWith( REQUIRED_CHECK_PREFIX ) )
131
+ } catch ( error ) {
132
+ errorExit( `Failed to look up check runs: ${error.stderr ?? error.message ?? error}` )
133
+ }
134
+
135
+ if ( !checkName ) {
136
+ errorExit(
137
+ `No check run starting with "${REQUIRED_CHECK_PREFIX}" found on ${fullRepo}@${branch}'s latest commit.\n` +
138
+ ' Has the node-ci workflow actually run on this branch yet? Push something first, ' +
139
+ 'or pass --branch if development is not the right target.'
140
+ )
141
+ }
142
+ log( chalk.green( `Found check: ${checkName}` ) )
143
+
144
+ // ---------- Build the payload ----------
145
+
146
+ const protectionPayload = {
147
+ required_status_checks : { strict : true, contexts : [ checkName ] },
148
+ enforce_admins : false,
149
+ required_pull_request_reviews : { required_approving_review_count : Number( opts.requireApprovals ) },
150
+ restrictions : null,
151
+ allow_force_pushes : false,
152
+ allow_deletions : false,
153
+ }
154
+
155
+ log( chalk.yellow( '\nWill apply branch protection:' ) )
156
+ log( JSON.stringify( protectionPayload, null, 2 ) )
157
+ log( chalk.yellow( 'Will also set merge method to squash-only ' +
158
+ '(allow_squash_merge=true, allow_merge_commit=false, allow_rebase_merge=false), ' +
159
+ 'enable auto-delete of head branches on merge (delete_branch_on_merge=true), ' +
160
+ 'and enable the "Update branch" suggestion button on out-of-date PRs (allow_update_branch=true)' ) )
161
+
162
+ if ( opts.dryRun ) {
163
+ log( chalk.green( '\n[dry-run] No changes made.' ) )
164
+ process.exit( 0 )
165
+ }
166
+
167
+ await proceed( `\nApply this to ${fullRepo}@${branch}?` )
168
+
169
+ // ---------- Apply ----------
170
+
171
+ try {
172
+ await $( { input : JSON.stringify( protectionPayload ) } )`gh api --method PUT repos/${fullRepo}/branches/${branch}/protection --input -`
173
+ await $`gh api --method PATCH repos/${fullRepo} -F allow_squash_merge=true -F allow_merge_commit=false -F allow_rebase_merge=false -F delete_branch_on_merge=true -F allow_update_branch=true`
174
+ } catch ( error ) {
175
+ errorExit( `Failed to apply settings: ${error.stderr ?? error.message ?? error}` )
176
+ }
177
+
178
+ log( chalk.green( `\n✔ Branch protection applied to ${fullRepo}@${branch}` ) )