code-foundry 0.37.5 → 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.
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/codeql.yml +5 -0
- package/.github/workflows/draft-pr.yml +1 -0
- package/.github/workflows/draft-pr_self-ci.yml +1 -0
- package/.github/workflows/opencode-security_self-ci.yml +4 -3
- package/.github/workflows/release-pr.yml +1 -0
- package/.github/workflows/release-pr_self-ci.yml +1 -1
- package/.github/workflows/release.yml +9 -3
- package/.github/workflows/release_self-ci.yml +8 -0
- package/.github/workflows/security.yml +8 -5
- package/.github/workflows/test.yml +4 -3
- package/.github/workflows/validation.yml +6 -6
- package/.github/workflows/validation_self-ci.yml +2 -0
- package/CHANGELOG.md +13 -0
- package/docs/WORKFLOWS.md +43 -0
- package/package.json +1 -1
- package/src/cli.mjs +13 -1
- package/src/commands/ci.mjs +268 -0
- package/src/commands/sync.mjs +1 -1
package/.github/workflows/ci.yml
CHANGED
|
@@ -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:
|
|
@@ -12,8 +12,9 @@ jobs:
|
|
|
12
12
|
detect:
|
|
13
13
|
name: OpenCode Security / Detect
|
|
14
14
|
if: >-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
@@ -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
|
-
${{
|
|
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
|
-
${{
|
|
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,18 @@
|
|
|
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
|
+
|
|
3
16
|
## [0.37.5](https://github.com/0xPlayerOne/code-foundry/compare/v0.37.4...v0.37.5) (2026-08-23)
|
|
4
17
|
|
|
5
18
|
|
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
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
|
+
}
|
package/src/commands/sync.mjs
CHANGED
|
@@ -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(/^
|
|
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 = {
|