code-foundry 0.37.4 → 0.38.0

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.
@@ -46,6 +46,7 @@ concurrency:
46
46
  jobs:
47
47
  format:
48
48
  name: Format
49
+ if: vars.CI_BILLING_PAUSED != 'true'
49
50
  runs-on: ${{ inputs.runner }}
50
51
  steps:
51
52
  - name: Checkout
@@ -89,6 +90,7 @@ jobs:
89
90
 
90
91
  lint:
91
92
  name: Lint
93
+ if: vars.CI_BILLING_PAUSED != 'true'
92
94
  runs-on: ${{ inputs.runner }}
93
95
  steps:
94
96
  - name: Checkout
@@ -127,6 +129,7 @@ jobs:
127
129
 
128
130
  type-check:
129
131
  name: Type-Check
132
+ if: vars.CI_BILLING_PAUSED != 'true'
130
133
  runs-on: ${{ inputs.runner }}
131
134
  steps:
132
135
  - name: Checkout
@@ -165,6 +168,7 @@ jobs:
165
168
 
166
169
  build:
167
170
  name: Build
171
+ if: vars.CI_BILLING_PAUSED != 'true'
168
172
  runs-on: ${{ inputs.runner }}
169
173
  env:
170
174
  # Framework builds may validate server-side configuration at build time.
@@ -52,6 +52,7 @@ concurrency:
52
52
  jobs:
53
53
  detect:
54
54
  name: Detect
55
+ if: vars.CI_BILLING_PAUSED != 'true'
55
56
  runs-on: ${{ inputs.runner }}
56
57
  timeout-minutes: 10
57
58
  outputs:
@@ -130,6 +131,7 @@ jobs:
130
131
  name: Analyze (Actions)
131
132
  needs: detect
132
133
  if: >-
134
+ vars.CI_BILLING_PAUSED != 'true' &&
133
135
  needs.detect.outputs.enabled == 'true' &&
134
136
  needs.detect.outputs.actions_available == 'true'
135
137
  runs-on: ${{ inputs.runner }}
@@ -188,6 +190,7 @@ jobs:
188
190
  name: Analyze (TypeScript)
189
191
  needs: detect
190
192
  if: >-
193
+ vars.CI_BILLING_PAUSED != 'true' &&
191
194
  needs.detect.outputs.enabled == 'true' &&
192
195
  needs.detect.outputs.javascript_available == 'true'
193
196
  runs-on: ${{ inputs.runner }}
@@ -246,6 +249,7 @@ jobs:
246
249
  name: Analyze (Python)
247
250
  needs: detect
248
251
  if: >-
252
+ vars.CI_BILLING_PAUSED != 'true' &&
249
253
  needs.detect.outputs.enabled == 'true' &&
250
254
  needs.detect.outputs.python_available == 'true'
251
255
  runs-on: ${{ inputs.runner }}
@@ -304,6 +308,7 @@ jobs:
304
308
  name: Analyze (Rust)
305
309
  needs: detect
306
310
  if: >-
311
+ vars.CI_BILLING_PAUSED != 'true' &&
307
312
  needs.detect.outputs.enabled == 'true' &&
308
313
  needs.detect.outputs.rust_available == 'true'
309
314
  strategy:
@@ -26,6 +26,7 @@ permissions:
26
26
  jobs:
27
27
  create-draft-pr:
28
28
  name: Create
29
+ if: vars.CI_BILLING_PAUSED != 'true'
29
30
  runs-on: ${{ inputs.runner }}
30
31
  timeout-minutes: 10
31
32
  concurrency:
@@ -18,6 +18,7 @@ permissions:
18
18
  jobs:
19
19
  draft-pr:
20
20
  name: Draft PR
21
+ if: vars.CI_BILLING_PAUSED != 'true'
21
22
  uses: ./.github/workflows/draft-pr.yml
22
23
  with:
23
24
  runner: ubuntu-slim
@@ -12,8 +12,9 @@ jobs:
12
12
  detect:
13
13
  name: OpenCode Security / Detect
14
14
  if: >-
15
- github.event_name == 'workflow_dispatch' ||
16
- startsWith(github.event.pull_request.head.ref, 'release-please--branches--main')
15
+ vars.CI_BILLING_PAUSED != 'true' &&
16
+ (github.event_name == 'workflow_dispatch' ||
17
+ startsWith(github.event.pull_request.head.ref, 'release-please--branches--main'))
17
18
  runs-on: ubuntu-slim
18
19
  outputs:
19
20
  enabled: ${{ steps.detect.outputs.enabled }}
@@ -38,7 +39,7 @@ jobs:
38
39
  scan:
39
40
  name: OpenCode Security / Scan
40
41
  needs: detect
41
- if: needs.detect.outputs.enabled == 'true' && needs.detect.outputs.token == 'true'
42
+ if: vars.CI_BILLING_PAUSED != 'true' && needs.detect.outputs.enabled == 'true' && needs.detect.outputs.token == 'true'
42
43
  uses: 0xPlayerOne/opencode-security/.github/workflows/opencode-security.yml@b3dce823322672b285fbe99b870ea984c01826cb # main
43
44
  with:
44
45
  # This is the scanner checkout ref, not the consumer PR ref. The
@@ -24,6 +24,7 @@ env:
24
24
  jobs:
25
25
  create-release-pr:
26
26
  name: Create
27
+ if: vars.CI_BILLING_PAUSED != 'true'
27
28
  runs-on: ${{ inputs.runner }}
28
29
  timeout-minutes: 10
29
30
  concurrency:
@@ -11,7 +11,7 @@ permissions:
11
11
 
12
12
  jobs:
13
13
  release-pr:
14
- if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
14
+ if: vars.CI_BILLING_PAUSED != 'true' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
15
15
  name: Release PR
16
16
  uses: ./.github/workflows/release-pr.yml
17
17
  with:
@@ -18,6 +18,11 @@ on:
18
18
  required: false
19
19
  type: string
20
20
  default: v0.31.12
21
+ billing-pause-bypass:
22
+ description: Allow this explicitly dispatched release to run while other CI remains paused.
23
+ required: false
24
+ type: boolean
25
+ default: false
21
26
  secrets:
22
27
  CODE_FOUNDRY_TOKEN:
23
28
  required: false
@@ -45,6 +50,7 @@ env:
45
50
  jobs:
46
51
  release:
47
52
  name: Version
53
+ if: vars.CI_BILLING_PAUSED != 'true' || inputs['billing-pause-bypass'] == true
48
54
  runs-on: ${{ inputs.runner }}
49
55
  timeout-minutes: 20
50
56
  concurrency:
@@ -313,7 +319,7 @@ jobs:
313
319
  # creating a release. Reconciliation is only meaningful after a release
314
320
  # commit/tag is actually created; otherwise the promotion's source changes
315
321
  # are incorrectly rejected as non-metadata commits.
316
- if: needs.release.result == 'success' && needs.release.outputs.release_created == 'true'
322
+ if: (vars.CI_BILLING_PAUSED != 'true' || inputs['billing-pause-bypass'] == true) && needs.release.result == 'success' && needs.release.outputs.release_created == 'true'
317
323
  runs-on: ${{ inputs.runner }}
318
324
  timeout-minutes: 15
319
325
  permissions:
@@ -401,7 +407,7 @@ jobs:
401
407
  post-release:
402
408
  name: Post Hook
403
409
  needs: release
404
- if: needs.release.outputs.release_created == 'true'
410
+ if: (vars.CI_BILLING_PAUSED != 'true' || inputs['billing-pause-bypass'] == true) && needs.release.outputs.release_created == 'true'
405
411
  runs-on: ${{ inputs.runner }}
406
412
  timeout-minutes: 15
407
413
  concurrency:
@@ -460,7 +466,7 @@ jobs:
460
466
  npm:
461
467
  name: Publish npm
462
468
  needs: release
463
- if: needs.release.outputs.release_created == 'true' && needs.release.outputs.npm_publish == 'true'
469
+ if: (vars.CI_BILLING_PAUSED != 'true' || inputs['billing-pause-bypass'] == true) && needs.release.outputs.release_created == 'true' && needs.release.outputs.npm_publish == 'true'
464
470
  runs-on: ${{ inputs.runner }}
465
471
  timeout-minutes: 15
466
472
  permissions:
@@ -4,6 +4,12 @@ on:
4
4
  push:
5
5
  branches: [main]
6
6
  workflow_dispatch:
7
+ inputs:
8
+ release-while-paused:
9
+ description: Run only the release pipeline while the shared CI billing pause remains active.
10
+ required: false
11
+ type: boolean
12
+ default: false
7
13
 
8
14
  permissions:
9
15
  actions: write
@@ -15,8 +21,10 @@ permissions:
15
21
  jobs:
16
22
  release:
17
23
  name: Release
24
+ if: vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)
18
25
  uses: ./.github/workflows/release.yml
19
26
  with:
27
+ billing-pause-bypass: ${{ github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true }}
20
28
  runner: ubuntu-slim
21
29
  runtime-repository: 0xPlayerOne/code-foundry
22
30
  runtime-ref: ${{ github.sha }}
@@ -36,6 +36,7 @@ concurrency:
36
36
  jobs:
37
37
  profile:
38
38
  name: Profile
39
+ if: vars.CI_BILLING_PAUSED != 'true'
39
40
  runs-on: ${{ inputs.runner }}
40
41
  timeout-minutes: 10
41
42
  outputs:
@@ -102,7 +103,7 @@ jobs:
102
103
  dependency-audit-javascript:
103
104
  name: Dependency Audit (JavaScript)
104
105
  needs: profile
105
- if: ${{ needs.profile.result == 'success' && needs.profile.outputs.javascript == 'true' }}
106
+ if: ${{ vars.CI_BILLING_PAUSED != 'true' && needs.profile.result == 'success' && needs.profile.outputs.javascript == 'true' }}
106
107
  runs-on: ${{ inputs.runner }}
107
108
  timeout-minutes: 20
108
109
  steps:
@@ -157,7 +158,7 @@ jobs:
157
158
  dependency-audit-rust:
158
159
  name: Dependency Audit (Rust)
159
160
  needs: profile
160
- if: ${{ needs.profile.result == 'success' && needs.profile.outputs.rust == 'true' }}
161
+ if: ${{ vars.CI_BILLING_PAUSED != 'true' && needs.profile.result == 'success' && needs.profile.outputs.rust == 'true' }}
161
162
  runs-on: ${{ inputs.runner }}
162
163
  timeout-minutes: 20
163
164
  steps:
@@ -226,7 +227,8 @@ jobs:
226
227
  name: Dependency Audit (Python) / ${{ matrix.requirement }}
227
228
  needs: profile
228
229
  if: >-
229
- ${{ needs.profile.result == 'success' &&
230
+ ${{ vars.CI_BILLING_PAUSED != 'true' &&
231
+ needs.profile.result == 'success' &&
230
232
  needs.profile.outputs.python == 'true' &&
231
233
  needs.profile.outputs.python_requirements != '["none"]' }}
232
234
  strategy:
@@ -285,7 +287,8 @@ jobs:
285
287
  name: Dependency Audit (Python)
286
288
  needs: [profile, dependency-audit-python]
287
289
  if: >-
288
- ${{ always() &&
290
+ ${{ vars.CI_BILLING_PAUSED != 'true' &&
291
+ always() &&
289
292
  needs.profile.result == 'success' &&
290
293
  needs.profile.outputs.python == 'true' &&
291
294
  needs.profile.outputs.python_requirements != '["none"]' }}
@@ -309,7 +312,7 @@ jobs:
309
312
  dependency-review:
310
313
  name: Dependency Review
311
314
  needs: profile
312
- if: ${{ github.event_name == 'pull_request' && needs.profile.outputs.dependency_review == 'true' }}
315
+ if: ${{ vars.CI_BILLING_PAUSED != 'true' && github.event_name == 'pull_request' && needs.profile.outputs.dependency_review == 'true' }}
313
316
  runs-on: ${{ inputs.runner }}
314
317
  timeout-minutes: 10
315
318
  steps:
@@ -54,6 +54,7 @@ concurrency:
54
54
  jobs:
55
55
  unit:
56
56
  name: Unit
57
+ if: vars.CI_BILLING_PAUSED != 'true'
57
58
  runs-on: ${{ inputs.unit-runner }}
58
59
  timeout-minutes: 30
59
60
  steps:
@@ -118,7 +119,7 @@ jobs:
118
119
 
119
120
  integration:
120
121
  name: Integration
121
- if: inputs.unit-only != true
122
+ if: vars.CI_BILLING_PAUSED != 'true' && inputs.unit-only != true
122
123
  runs-on: ${{ inputs.runner }}
123
124
  timeout-minutes: 30
124
125
  steps:
@@ -167,7 +168,7 @@ jobs:
167
168
 
168
169
  e2e:
169
170
  name: E2E
170
- if: inputs.unit-only != true
171
+ if: vars.CI_BILLING_PAUSED != 'true' && inputs.unit-only != true
171
172
  runs-on: ${{ inputs.runner }}
172
173
  timeout-minutes: 45
173
174
  steps:
@@ -228,7 +229,7 @@ jobs:
228
229
 
229
230
  smoke:
230
231
  name: Smoke
231
- if: inputs.unit-only != true
232
+ if: vars.CI_BILLING_PAUSED != 'true' && inputs.unit-only != true
232
233
  runs-on: ${{ inputs.runner }}
233
234
  timeout-minutes: 15
234
235
  steps:
@@ -75,7 +75,7 @@ permissions:
75
75
  jobs:
76
76
  ci:
77
77
  name: CI
78
- if: inputs.mode == 'fast' || inputs.mode == 'audit'
78
+ if: vars.CI_BILLING_PAUSED != 'true' && (inputs.mode == 'fast' || inputs.mode == 'audit')
79
79
  uses: ./.github/workflows/ci.yml
80
80
  with:
81
81
  runtime-repository: ${{ inputs.runtime-repository }}
@@ -87,7 +87,7 @@ jobs:
87
87
 
88
88
  test:
89
89
  name: Test
90
- if: inputs.mode == 'fast' || inputs.mode == 'audit'
90
+ if: vars.CI_BILLING_PAUSED != 'true' && (inputs.mode == 'fast' || inputs.mode == 'audit')
91
91
  uses: ./.github/workflows/test.yml
92
92
  with:
93
93
  runtime-repository: ${{ inputs.runtime-repository }}
@@ -100,7 +100,7 @@ jobs:
100
100
 
101
101
  security:
102
102
  name: Security
103
- if: inputs.mode == 'audit'
103
+ if: vars.CI_BILLING_PAUSED != 'true' && inputs.mode == 'audit'
104
104
  uses: ./.github/workflows/security.yml
105
105
  with:
106
106
  runtime-repository: ${{ inputs.runtime-repository }}
@@ -109,7 +109,7 @@ jobs:
109
109
 
110
110
  codeql:
111
111
  name: CodeQL
112
- if: inputs.mode == 'audit'
112
+ if: vars.CI_BILLING_PAUSED != 'true' && inputs.mode == 'audit'
113
113
  uses: ./.github/workflows/codeql.yml
114
114
  with:
115
115
  runtime-repository: ${{ inputs.runtime-repository }}
@@ -121,7 +121,7 @@ jobs:
121
121
 
122
122
  release-policy:
123
123
  name: Release Policy
124
- if: inputs.mode == 'release'
124
+ if: vars.CI_BILLING_PAUSED != 'true' && inputs.mode == 'release'
125
125
  runs-on: ubuntu-slim
126
126
  timeout-minutes: 10
127
127
  steps:
@@ -156,7 +156,7 @@ jobs:
156
156
  gate:
157
157
  name: Gate
158
158
  needs: [ci, test, security, codeql, release-policy]
159
- if: always()
159
+ if: vars.CI_BILLING_PAUSED != 'true' && always()
160
160
  runs-on: ubuntu-slim
161
161
  timeout-minutes: 10
162
162
  env:
@@ -25,6 +25,7 @@ concurrency:
25
25
  jobs:
26
26
  mode:
27
27
  name: Mode
28
+ if: vars.CI_BILLING_PAUSED != 'true'
28
29
  runs-on: ubuntu-slim
29
30
  timeout-minutes: 10
30
31
  permissions:
@@ -57,6 +58,7 @@ jobs:
57
58
  validation:
58
59
  name: Validation
59
60
  needs: mode
61
+ if: vars.CI_BILLING_PAUSED != 'true'
60
62
  # Reusable workflows can only maintain or reduce the caller job's scopes.
61
63
  # The audit tier needs security-events: write for CodeQL uploads.
62
64
  permissions:
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.38.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.37.5...v0.38.0) (2026-08-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * **ci:** add reversible billing pause ([#402](https://github.com/0xPlayerOne/code-foundry/issues/402)) ([aa17b11](https://github.com/0xPlayerOne/code-foundry/commit/aa17b11037dccced17dd1173d9d788ac83ba50b5))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **ci:** guard every workflow during billing pause ([#405](https://github.com/0xPlayerOne/code-foundry/issues/405)) ([fc3930f](https://github.com/0xPlayerOne/code-foundry/commit/fc3930fcb50e9182bb0f5e97ea523a6805f3fa95))
14
+ * **release:** allow explicit release while CI is paused ([#407](https://github.com/0xPlayerOne/code-foundry/issues/407)) ([3d31cf1](https://github.com/0xPlayerOne/code-foundry/commit/3d31cf154255225ecdd9bca94f8620ad8c2b3473))
15
+
16
+ ## [0.37.5](https://github.com/0xPlayerOne/code-foundry/compare/v0.37.4...v0.37.5) (2026-08-23)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **sync:** omit Cargo Dependabot outside Rust repos ([#398](https://github.com/0xPlayerOne/code-foundry/issues/398)) ([395c4f3](https://github.com/0xPlayerOne/code-foundry/commit/395c4f34887bbb3e9f67378325b70c21d467adf0))
22
+
3
23
  ## [0.37.4](https://github.com/0xPlayerOne/code-foundry/compare/v0.37.3...v0.37.4) (2026-08-23)
4
24
 
5
25
 
package/docs/WORKFLOWS.md CHANGED
@@ -26,6 +26,49 @@ automation listens to `main` pushes.
26
26
  Custom deployment, indexing, search, Slither, or other workflows are
27
27
  repository-owned extensions and should keep their own triggers and permissions.
28
28
 
29
+ ## Billing pause
30
+
31
+ Code Foundry consumers can stop all generated jobs from allocating GitHub-hosted
32
+ runners during a billing outage:
33
+
34
+ ```bash
35
+ npx code-foundry ci pause
36
+ npx code-foundry ci status
37
+ npx code-foundry ci resume
38
+ ```
39
+
40
+ The commands use the repository variable `CI_BILLING_PAUSED`. Every job in the
41
+ generated callers and reusable workflows checks this variable before GitHub
42
+ allocates a runner, so a direct reusable-workflow call cannot bypass the pause.
43
+ `pause` also backs up and removes only `Validation / Gate` from active branch
44
+ rulesets so pull requests do not wait forever for a deliberately disabled
45
+ workflow, and cancels queued or in-progress workflow runs that were created
46
+ before the flag changed.
47
+ `resume` restores that exact check before re-enabling jobs. Pull-request,
48
+ deletion, non-fast-forward, review, and other ruleset protections remain active.
49
+ The operation fails closed when the gate or its backup is ambiguous.
50
+
51
+ Release Please and package publication are deliberately included in the pause.
52
+ Changes merged to `main` while paused remain unreleased until the release
53
+ workflow receives a normal run after CI resumes, or a maintainer explicitly
54
+ dispatches its release-only bypass. The bypass does not enable validation,
55
+ security, CodeQL, draft-PR, or promotion jobs:
56
+
57
+ ```bash
58
+ gh workflow run release_self-ci.yml --ref main -f release-while-paused=true
59
+ ```
60
+
61
+ The first dispatch creates and, when an automation token is available, merges
62
+ the Release Please version PR. After that PR is merged, dispatch the same
63
+ command once more to create the tag, GitHub Release, and configured package
64
+ publication. Without an automation token, review and merge the version PR
65
+ manually between the two dispatches. `CI_BILLING_PAUSED` remains `true`
66
+ throughout the bounded release flow.
67
+
68
+ Custom workflows are repository-owned and are not rewritten by sync. Add
69
+ `if: vars.CI_BILLING_PAUSED != 'true'` to each custom root job that should
70
+ honor the shared billing pause.
71
+
29
72
  ## Standard workflow responsibilities
30
73
 
31
74
  | Workflow | Responsibility |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "0.37.4",
3
+ "version": "0.38.0",
4
4
  "description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
package/src/cli.mjs CHANGED
@@ -8,10 +8,11 @@ import { doctor } from './commands/doctor.mjs'
8
8
  import { dispatchPostReleaseHook, reconcileRelease, releaseRecoveryPlan, validateReleasePullRequestDiffs } from './commands/release.mjs'
9
9
  import { discoverRepositories, upgradeFleet } from './commands/fleet.mjs'
10
10
  import { syncRepository } from './commands/sync.mjs'
11
+ import { manageCiBilling } from './commands/ci.mjs'
11
12
 
12
13
  const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
13
14
 
14
- /** @typedef {{ target: string, root: string, dryRun: boolean, force: boolean, github: boolean, createPr: boolean, exclude: string[], base: string, head: string, tag: string, workflow: string, mode: string, version: string, releaseSubcommand?: string, fleetSubcommand?: string }} Options */
15
+ /** @typedef {{ target: string, root: string, dryRun: boolean, force: boolean, github: boolean, createPr: boolean, exclude: string[], base: string, head: string, tag: string, workflow: string, mode: string, version: string, releaseSubcommand?: string, fleetSubcommand?: string, ciSubcommand?: 'pause'|'resume'|'status' }} Options */
15
16
  /** @typedef {{ command: string, options: Options }} ParsedArgs */
16
17
 
17
18
  const usage = `code-foundry — initialize and maintain agent-ready repositories
@@ -20,6 +21,7 @@ Usage:
20
21
  npx code-foundry init [--target PATH]
21
22
  npx code-foundry sync [--target PATH]
22
23
  npx code-foundry doctor [--target PATH]
24
+ npx code-foundry ci pause|resume|status [--target PATH]
23
25
  npx code-foundry release reconcile [--github] [--base BRANCH] [--head BRANCH]
24
26
  npx code-foundry release hook --tag TAG --workflow WORKFLOW
25
27
  npx code-foundry fleet status [--root PATH]
@@ -68,6 +70,10 @@ function parseArgs(argv) {
68
70
  const subcommand = argv.shift() ?? ''
69
71
  if (subcommand !== 'status' && subcommand !== 'upgrade') fail(`unknown fleet command: ${subcommand ?? '(missing)'}; use fleet status or fleet upgrade`)
70
72
  options.fleetSubcommand = subcommand
73
+ } else if (command === 'ci') {
74
+ const subcommand = argv.shift() ?? ''
75
+ if (!['pause', 'resume', 'status'].includes(subcommand)) fail(`unknown ci command: ${subcommand || '(missing)'}; use ci pause, ci resume, or ci status`)
76
+ options.ciSubcommand = /** @type {'pause'|'resume'|'status'} */ (subcommand)
71
77
  }
72
78
 
73
79
  while (argv.length) {
@@ -138,6 +144,12 @@ function main() {
138
144
  } catch (error) {
139
145
  fail(error instanceof Error ? error.message : String(error))
140
146
  }
147
+ } else if (command === 'ci') {
148
+ try {
149
+ manageCiBilling(target, /** @type {'pause'|'resume'|'status'} */ (options.ciSubcommand))
150
+ } catch (error) {
151
+ fail(error instanceof Error ? error.message : String(error))
152
+ }
141
153
  }
142
154
  else fail(`unknown command: ${command}`)
143
155
  }
@@ -0,0 +1,268 @@
1
+ // @ts-check
2
+
3
+ import { spawnSync } from 'node:child_process'
4
+
5
+ export const CI_BILLING_PAUSED_VARIABLE = 'CI_BILLING_PAUSED'
6
+ export const CI_BILLING_BACKUP_VARIABLE = 'CI_BILLING_GATE_BACKUP'
7
+ export const CI_BILLING_REQUIRED_CHECK = 'Validation / Gate'
8
+
9
+ /**
10
+ * Pause, resume, or inspect billable GitHub Actions for a consumer repository.
11
+ * The repository variable stops guarded jobs from allocating runners. While
12
+ * paused, the managed validation check is removed from active branch rulesets
13
+ * so pull requests do not deadlock on a check that cannot run.
14
+ *
15
+ * @param {string} root
16
+ * @param {'pause'|'resume'|'status'} action
17
+ * @returns {Record<string, unknown>}
18
+ */
19
+ export function manageCiBilling(root, action) {
20
+ const repository = remoteRepository(root)
21
+ if (!repository) throw new Error('Unable to determine GitHub repository from the origin remote.')
22
+ requireGh()
23
+
24
+ const paused = readVariable(repository, CI_BILLING_PAUSED_VARIABLE) === 'true'
25
+ const backupText = readVariable(repository, CI_BILLING_BACKUP_VARIABLE)
26
+ const backup = backupText ? parseBackup(backupText) : null
27
+
28
+ if (action === 'status') {
29
+ const result = {
30
+ repository,
31
+ paused,
32
+ requiredCheck: CI_BILLING_REQUIRED_CHECK,
33
+ backupPresent: Boolean(backup),
34
+ }
35
+ console.log(JSON.stringify(result, null, 2))
36
+ return result
37
+ }
38
+
39
+ if (action === 'pause') {
40
+ const rulesets = activeBranchRulesets(repository)
41
+ const candidates = rulesets.filter((ruleset) => requiredChecks(ruleset).some(isManagedCheck))
42
+ const inherited = candidates.filter((ruleset) => ruleset.source_type !== 'Repository' || ruleset.source !== repository)
43
+ if (inherited.length) {
44
+ throw new Error(`${CI_BILLING_REQUIRED_CHECK} is enforced by an inherited ruleset that this repository cannot safely edit.`)
45
+ }
46
+ if (!candidates.length) {
47
+ if (paused && backup) {
48
+ const cancelledRuns = cancelActiveRuns(repository)
49
+ const result = { repository, paused: true, changed: false, cancelledRuns, rulesets: backup.rulesets.map((/** @type {any} */ ruleset) => ruleset.rulesetName) }
50
+ console.log(JSON.stringify(result, null, 2))
51
+ return result
52
+ }
53
+ throw new Error(`No active branch ruleset requires ${CI_BILLING_REQUIRED_CHECK}; refusing to pause without a restorable managed gate.`)
54
+ }
55
+ if (backup && !paused) {
56
+ throw new Error(`${CI_BILLING_BACKUP_VARIABLE} already exists while CI is active; inspect or remove the stale backup before pausing.`)
57
+ }
58
+ if (backup && candidates.some((ruleset) => !backup.rulesets.some((/** @type {any} */ saved) => saved.rulesetId === ruleset.id))) {
59
+ throw new Error(`${CI_BILLING_BACKUP_VARIABLE} does not cover every managed ruleset; refusing to overwrite the recovery state.`)
60
+ }
61
+
62
+ const changes = candidates.map(buildPausedRuleset)
63
+ const nextBackup = backup ?? { version: 1, rulesets: changes.map((change) => change.backup) }
64
+ setVariable(repository, CI_BILLING_BACKUP_VARIABLE, JSON.stringify(nextBackup))
65
+ setVariable(repository, CI_BILLING_PAUSED_VARIABLE, 'true')
66
+ const cancelledRuns = cancelActiveRuns(repository)
67
+ for (const change of changes) updateRuleset(repository, change.ruleset)
68
+ const result = { repository, paused: true, changed: true, cancelledRuns, rulesets: changes.map((change) => change.ruleset.name) }
69
+ console.log(JSON.stringify(result, null, 2))
70
+ return result
71
+ }
72
+
73
+ if (!backup) {
74
+ if (!paused) {
75
+ const result = { repository, paused: false, changed: false }
76
+ console.log(JSON.stringify(result, null, 2))
77
+ return result
78
+ }
79
+ throw new Error(`${CI_BILLING_PAUSED_VARIABLE} is true but ${CI_BILLING_BACKUP_VARIABLE} is missing; refusing an unsafe resume.`)
80
+ }
81
+
82
+ let changed = paused
83
+ for (const saved of backup.rulesets) {
84
+ const current = apiJson(repository, `repos/${repository}/rulesets/${saved.rulesetId}`)
85
+ if (!current || current.name !== saved.rulesetName) {
86
+ throw new Error(`The backed-up ruleset ${saved.rulesetName} (${saved.rulesetId}) is unavailable; refusing an unsafe resume.`)
87
+ }
88
+ const restored = buildResumedRuleset(current, saved)
89
+ if (restored.changed) updateRuleset(repository, restored.ruleset)
90
+ changed ||= restored.changed
91
+ }
92
+ setVariable(repository, CI_BILLING_PAUSED_VARIABLE, 'false')
93
+ deleteVariable(repository, CI_BILLING_BACKUP_VARIABLE)
94
+ const result = { repository, paused: false, changed, rulesets: backup.rulesets.map((/** @type {any} */ ruleset) => ruleset.rulesetName) }
95
+ console.log(JSON.stringify(result, null, 2))
96
+ return result
97
+ }
98
+
99
+ /** @param {any} ruleset */
100
+ export function buildPausedRuleset(ruleset) {
101
+ const requiredRuleIndexes = (ruleset.rules ?? []).flatMap((/** @type {any} */ rule, /** @type {number} */ index) =>
102
+ rule?.type === 'required_status_checks' && (rule.parameters?.required_status_checks ?? []).some(isManagedCheck) ? [index] : [],
103
+ )
104
+ if (requiredRuleIndexes.length > 1) throw new Error(`${CI_BILLING_REQUIRED_CHECK} appears in multiple rules; refusing an ambiguous pause.`)
105
+ const requiredRuleIndex = requiredRuleIndexes[0]
106
+ const requiredRule = ruleset.rules?.[requiredRuleIndex]
107
+ const matches = (requiredRule?.parameters?.required_status_checks ?? []).filter(isManagedCheck)
108
+ if (!matches.length) throw new Error(`${CI_BILLING_REQUIRED_CHECK} is not present in the selected ruleset.`)
109
+ const remaining = (requiredRule.parameters?.required_status_checks ?? []).filter((/** @type {any} */ check) => !isManagedCheck(check))
110
+ const rules = (ruleset.rules ?? []).flatMap((/** @type {any} */ rule, /** @type {number} */ index) => {
111
+ if (index !== requiredRuleIndex) return [rule]
112
+ if (!remaining.length) return []
113
+ return [{ ...rule, parameters: { ...rule.parameters, required_status_checks: remaining } }]
114
+ })
115
+ return {
116
+ ruleset: { ...ruleset, rules },
117
+ backup: {
118
+ rulesetId: ruleset.id,
119
+ rulesetName: ruleset.name,
120
+ checks: matches,
121
+ parameters: {
122
+ strict_required_status_checks_policy: Boolean(requiredRule?.parameters?.strict_required_status_checks_policy),
123
+ do_not_enforce_on_create: Boolean(requiredRule?.parameters?.do_not_enforce_on_create),
124
+ },
125
+ },
126
+ }
127
+ }
128
+
129
+ /** @param {any} ruleset @param {any} backup */
130
+ export function buildResumedRuleset(ruleset, backup) {
131
+ if (requiredChecks(ruleset).some(isManagedCheck)) return { ruleset, changed: false }
132
+ const rules = [...(ruleset.rules ?? [])]
133
+ const index = rules.findIndex((rule) => rule?.type === 'required_status_checks')
134
+ if (index === -1) {
135
+ rules.push({
136
+ type: 'required_status_checks',
137
+ parameters: { ...backup.parameters, required_status_checks: backup.checks },
138
+ })
139
+ } else {
140
+ const rule = rules[index]
141
+ rules[index] = {
142
+ ...rule,
143
+ parameters: {
144
+ ...rule.parameters,
145
+ required_status_checks: [...requiredChecks({ rules: [rule] }), ...backup.checks],
146
+ },
147
+ }
148
+ }
149
+ return { ruleset: { ...ruleset, rules }, changed: true }
150
+ }
151
+
152
+ /** @param {any} backup */
153
+ function validateBackup(backup) {
154
+ return backup?.version === 1 &&
155
+ Array.isArray(backup.rulesets) &&
156
+ backup.rulesets.length > 0 &&
157
+ backup.rulesets.every((/** @type {any} */ ruleset) =>
158
+ Number.isInteger(ruleset?.rulesetId) &&
159
+ typeof ruleset.rulesetName === 'string' &&
160
+ Array.isArray(ruleset.checks) &&
161
+ ruleset.checks.length > 0 &&
162
+ ruleset.checks.every(isManagedCheck) &&
163
+ ruleset.parameters && typeof ruleset.parameters === 'object',
164
+ )
165
+ }
166
+
167
+ /** @param {string} value */
168
+ function parseBackup(value) {
169
+ let backup
170
+ try { backup = JSON.parse(value) }
171
+ catch { throw new Error(`${CI_BILLING_BACKUP_VARIABLE} is not valid JSON; refusing to change CI state.`) }
172
+ if (!validateBackup(backup)) throw new Error(`${CI_BILLING_BACKUP_VARIABLE} is invalid; refusing to change CI state.`)
173
+ return backup
174
+ }
175
+
176
+ /** @param {any} check */
177
+ function isManagedCheck(check) { return check?.context === CI_BILLING_REQUIRED_CHECK }
178
+
179
+ /** @param {any} ruleset @returns {any[]} */
180
+ function requiredChecks(ruleset) {
181
+ return (ruleset?.rules ?? []).flatMap((/** @type {any} */ rule) => rule?.type === 'required_status_checks' ? rule.parameters?.required_status_checks ?? [] : [])
182
+ }
183
+
184
+ /** @param {string} repository @returns {any[]} */
185
+ function activeBranchRulesets(repository) {
186
+ const summaries = apiJson(repository, `repos/${repository}/rulesets`)
187
+ if (!Array.isArray(summaries)) throw new Error('Unable to read repository rulesets.')
188
+ return summaries
189
+ .map((ruleset) => apiJson(repository, `repos/${repository}/rulesets/${ruleset.id}`))
190
+ .filter((/** @type {any} */ ruleset) => ruleset && ruleset.enforcement === 'active' && ruleset.target === 'branch')
191
+ }
192
+
193
+ /** @param {string} repository @param {any} ruleset */
194
+ function updateRuleset(repository, ruleset) {
195
+ const payload = {
196
+ name: ruleset.name,
197
+ target: ruleset.target,
198
+ enforcement: ruleset.enforcement,
199
+ bypass_actors: ruleset.bypass_actors ?? [],
200
+ conditions: ruleset.conditions,
201
+ rules: ruleset.rules,
202
+ }
203
+ const result = spawnSync('gh', ['api', '--method', 'PUT', `repos/${repository}/rulesets/${ruleset.id}`, '--input', '-'], {
204
+ encoding: 'utf8',
205
+ input: JSON.stringify(payload),
206
+ })
207
+ if (result.status !== 0) throw new Error(`Unable to update ruleset ${ruleset.name}: ${result.stderr.trim() || 'GitHub API request failed'}`)
208
+ }
209
+
210
+ /** @param {string} repository @returns {number[]} */
211
+ function cancelActiveRuns(repository) {
212
+ const ids = new Set()
213
+ for (const status of ['queued', 'in_progress', 'requested', 'waiting', 'pending']) {
214
+ const response = apiJson(repository, `repos/${repository}/actions/runs?status=${status}&per_page=100`)
215
+ for (const run of response?.workflow_runs ?? []) {
216
+ if (Number.isInteger(run?.id)) ids.add(run.id)
217
+ }
218
+ }
219
+ for (const id of ids) {
220
+ const result = spawnSync('gh', ['api', '--method', 'POST', `repos/${repository}/actions/runs/${id}/cancel`], { encoding: 'utf8' })
221
+ if (result.status !== 0 && !/409|cannot be cancelled/i.test(result.stderr)) {
222
+ throw new Error(`Unable to cancel workflow run ${id}: ${result.stderr.trim() || 'GitHub API request failed'}`)
223
+ }
224
+ }
225
+ return [...ids]
226
+ }
227
+
228
+ /** @param {string} repository @param {string} name @returns {string|null} */
229
+ function readVariable(repository, name) {
230
+ const result = spawnSync('gh', ['api', `repos/${repository}/actions/variables/${name}`], { encoding: 'utf8' })
231
+ if (result.status !== 0) {
232
+ if (/404|not found/i.test(result.stderr)) return null
233
+ throw new Error(`Unable to read repository variable ${name}: ${result.stderr.trim() || 'GitHub API request failed'}`)
234
+ }
235
+ try { return JSON.parse(result.stdout)?.value ?? null }
236
+ catch { throw new Error(`GitHub returned invalid JSON for repository variable ${name}.`) }
237
+ }
238
+
239
+ /** @param {string} repository @param {string} name @param {string} value */
240
+ function setVariable(repository, name, value) {
241
+ const result = spawnSync('gh', ['variable', 'set', name, '--repo', repository, '--body', value], { encoding: 'utf8' })
242
+ if (result.status !== 0) throw new Error(`Unable to set repository variable ${name}: ${result.stderr.trim() || 'GitHub CLI request failed'}`)
243
+ }
244
+
245
+ /** @param {string} repository @param {string} name */
246
+ function deleteVariable(repository, name) {
247
+ const result = spawnSync('gh', ['variable', 'delete', name, '--repo', repository], { encoding: 'utf8' })
248
+ if (result.status !== 0) throw new Error(`Unable to delete repository variable ${name}: ${result.stderr.trim() || 'GitHub CLI request failed'}`)
249
+ }
250
+
251
+ /** @param {string} repository @param {string} endpoint @returns {any} */
252
+ function apiJson(repository, endpoint) {
253
+ const result = spawnSync('gh', ['api', endpoint], { encoding: 'utf8' })
254
+ if (result.status !== 0) throw new Error(`Unable to read ${endpoint} for ${repository}: ${result.stderr.trim() || 'GitHub API request failed'}`)
255
+ try { return JSON.parse(result.stdout) }
256
+ catch { throw new Error(`GitHub returned invalid JSON for ${endpoint}.`) }
257
+ }
258
+
259
+ /** @param {string} root @returns {string} */
260
+ function remoteRepository(root) {
261
+ const result = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8' })
262
+ const value = result.status === 0 ? result.stdout.trim() : ''
263
+ return value.replace(/^git@github\.com:/, '').replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '')
264
+ }
265
+
266
+ function requireGh() {
267
+ if (spawnSync('gh', ['--version'], { stdio: 'ignore' }).status !== 0) throw new Error('CI billing controls require the `gh` CLI.')
268
+ }
@@ -146,7 +146,7 @@ export function syncRepository(options) {
146
146
  content = Buffer.from(renderWorkflow(content.toString('utf8'), config, runtimeRepository, runtimeRef, rustCodeql))
147
147
  }
148
148
  if (file === '.github/dependabot.yml') {
149
- content = Buffer.from(renderDependabot(content.toString('utf8'), config))
149
+ content = Buffer.from(renderDependabot(content.toString('utf8'), config, languages))
150
150
  }
151
151
  if (['AGENTS.md', '.github/CONTRIBUTING.md', '.github/SECURITY.md'].includes(file)) {
152
152
  content = Buffer.from(renderContributionDocs(content.toString('utf8'), file, config))
@@ -365,7 +365,7 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
365
365
  // reusable-workflow input. Keep it on the configured default runner so
366
366
  // consumers that cannot use ubuntu-slim do not fail before validation.
367
367
  if (config.runner) {
368
- rendered = rendered.replace(/^ mode:\n name: Mode\n runs-on:\s+.*$/m, ` mode:\n name: Mode\n runs-on: ${config.runner}`)
368
+ rendered = rendered.replace(/^(\s+runs-on:)\s+.*$/m, `$1 ${config.runner}`)
369
369
  }
370
370
  /** @type {Record<string, string|undefined>} */
371
371
  const runnerInputs = {
@@ -393,14 +393,23 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
393
393
 
394
394
  /**
395
395
  * Dependabot updates land on the repository's integration branch. Direct
396
- * repositories have no staging branch, so every update targets main.
396
+ * repositories have no staging branch, so every update targets main. Cargo
397
+ * updates are emitted only when Rust is part of the configured language set.
397
398
  * @param {string} content
398
399
  * @param {Record<string,string>} config
400
+ * @param {string} languages
399
401
  * @returns {string}
400
402
  */
401
- function renderDependabot(content, config) {
402
- if (isStagingRelease(config.git_workflow)) return content
403
- return content.replaceAll('target-branch: staging', 'target-branch: main')
403
+ function renderDependabot(content, config, languages) {
404
+ let rendered = content
405
+ if (!includesValue(languages, 'rust')) {
406
+ rendered = rendered
407
+ .split(/(?=^ - package-ecosystem: )/m)
408
+ .filter((block) => !block.startsWith(' - package-ecosystem: cargo\n'))
409
+ .join('')
410
+ }
411
+ if (isStagingRelease(config.git_workflow)) return rendered
412
+ return rendered.replaceAll('target-branch: staging', 'target-branch: main')
404
413
  }
405
414
 
406
415
  /**