code-foundry 1.33.3 → 1.35.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/cloudflare-deploy.yml +48 -67
- package/.github/workflows/codeql.yml +51 -4
- package/.github/workflows/opencode-security_self-ci.yml +7 -1
- package/CHANGELOG.md +21 -0
- package/docs/CONFIGURATION.md +18 -0
- package/docs/WORKFLOWS.md +13 -1
- package/package.json +1 -1
- package/src/commands/ci.mjs +42 -0
- package/src/commands/sync.mjs +33 -1
- package/src/runtime-core.mjs +116 -3
|
@@ -77,31 +77,63 @@ concurrency:
|
|
|
77
77
|
cancel-in-progress: false
|
|
78
78
|
|
|
79
79
|
jobs:
|
|
80
|
-
|
|
81
|
-
name:
|
|
80
|
+
deploy:
|
|
81
|
+
name: Cloudflare Deploy / ${{ inputs.mode }}
|
|
82
|
+
# The Turbo impact check lives here as steps instead of a separate job:
|
|
83
|
+
# one checkout and one dependency install serve both the query and the
|
|
84
|
+
# deploy. Every deployment step is skipped when the check reports the
|
|
85
|
+
# package unaffected, and the check defaults to deploying whenever it
|
|
86
|
+
# cannot run.
|
|
82
87
|
if: >-
|
|
83
|
-
inputs['turbo-filter'] != '' &&
|
|
84
|
-
(github.event_name == 'pull_request' || github.event_name == 'push') &&
|
|
85
88
|
vars.CI_BILLING_PAUSED != 'true' &&
|
|
86
89
|
(inputs['draft-protection'] != true || github.event_name != 'pull_request' || github.event.pull_request.draft == false) &&
|
|
87
90
|
(github.event_name != 'pull_request' || inputs.mode != 'preview' || !startsWith(github.event.pull_request.head.ref, 'release-please--branches--main')) &&
|
|
88
91
|
github.event_name != 'pull_request_target' &&
|
|
89
92
|
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
|
90
93
|
runs-on: ${{ inputs.runner }}
|
|
91
|
-
timeout-minutes:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
timeout-minutes: 30
|
|
95
|
+
# The explicit deployment record below is the sole GitHub deployment record.
|
|
96
|
+
# Keep the environment for approvals, secrets, and branch protection without
|
|
97
|
+
# creating a second, competing PR timeline entry.
|
|
98
|
+
environment:
|
|
99
|
+
name: ${{ inputs.environment || (inputs.mode == 'production' && 'Production' || 'Preview') }}
|
|
100
|
+
deployment: false
|
|
101
|
+
url: ${{ steps.deploy.outputs.url }}
|
|
94
102
|
outputs:
|
|
95
|
-
|
|
103
|
+
url: ${{ steps.deploy.outputs.url }}
|
|
104
|
+
version-id: ${{ steps.deploy.outputs.version-id }}
|
|
105
|
+
github-deployment-id: ${{ steps.start.outputs.id }}
|
|
96
106
|
steps:
|
|
97
|
-
- name: Checkout
|
|
107
|
+
- name: Checkout
|
|
98
108
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
99
109
|
with:
|
|
100
|
-
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
101
|
-
fetch-depth: 0
|
|
102
110
|
persist-credentials: false
|
|
111
|
+
# The Turbo affected query compares the head tree against the base
|
|
112
|
+
# commit, so the checkout needs history; blob:none keeps it light.
|
|
113
|
+
fetch-depth: 0
|
|
114
|
+
filter: blob:none
|
|
115
|
+
- name: Validate deployment inputs
|
|
116
|
+
env:
|
|
117
|
+
MODE: ${{ inputs.mode }}
|
|
118
|
+
WRANGLER_VERSION: ${{ inputs.wrangler-version }}
|
|
119
|
+
run: |
|
|
120
|
+
set -euo pipefail
|
|
121
|
+
[[ "$MODE" == preview || "$MODE" == production ]]
|
|
122
|
+
[[ "$WRANGLER_VERSION" == local || "$WRANGLER_VERSION" == latest || "$WRANGLER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]
|
|
123
|
+
- name: Set up Bun
|
|
124
|
+
if: inputs.build-script != '' || inputs['turbo-filter'] != ''
|
|
125
|
+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
|
126
|
+
with:
|
|
127
|
+
bun-version: ${{ inputs.bun-version }}
|
|
128
|
+
cache: true
|
|
129
|
+
cache-dependency-path: ${{ inputs.install-working-directory }}/bun.lock
|
|
130
|
+
- name: Install dependencies
|
|
131
|
+
if: inputs.build-script != '' || inputs['turbo-filter'] != ''
|
|
132
|
+
working-directory: ${{ inputs.install-working-directory }}
|
|
133
|
+
run: bun install --frozen-lockfile
|
|
103
134
|
- name: Resolve Turbo package
|
|
104
135
|
id: resolve
|
|
136
|
+
if: inputs['turbo-filter'] != ''
|
|
105
137
|
working-directory: ${{ inputs.install-working-directory }}
|
|
106
138
|
env:
|
|
107
139
|
TURBO_FILTER: ${{ inputs.turbo-filter }}
|
|
@@ -150,20 +182,9 @@ jobs:
|
|
|
150
182
|
}
|
|
151
183
|
}
|
|
152
184
|
NODE
|
|
153
|
-
- name: Set up Bun
|
|
154
|
-
if: steps.resolve.outputs.turbo_enabled == 'true'
|
|
155
|
-
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
|
156
|
-
with:
|
|
157
|
-
bun-version: ${{ inputs.bun-version }}
|
|
158
|
-
cache: true
|
|
159
|
-
cache-dependency-path: ${{ inputs.install-working-directory }}/bun.lock
|
|
160
|
-
- name: Install dependencies for Turbo
|
|
161
|
-
if: steps.resolve.outputs.turbo_enabled == 'true'
|
|
162
|
-
working-directory: ${{ inputs.install-working-directory }}
|
|
163
|
-
run: bun install --frozen-lockfile --ignore-scripts
|
|
164
185
|
- name: Check affected build task
|
|
165
|
-
if: steps.resolve.outputs.turbo_enabled == 'true'
|
|
166
186
|
id: check
|
|
187
|
+
if: inputs['turbo-filter'] != '' && steps.resolve.outputs.turbo_enabled == 'true'
|
|
167
188
|
working-directory: ${{ inputs.install-working-directory }}
|
|
168
189
|
env:
|
|
169
190
|
TURBO_PACKAGE: ${{ steps.resolve.outputs.turbo_package }}
|
|
@@ -193,49 +214,8 @@ jobs:
|
|
|
193
214
|
console.log(`${packageName}: ${affected ? 'affected' : 'unaffected'}`)
|
|
194
215
|
appendFileSync(process.env.GITHUB_OUTPUT, `should_deploy=${affected}\n`)
|
|
195
216
|
NODE
|
|
196
|
-
|
|
197
|
-
deploy:
|
|
198
|
-
name: Cloudflare Deploy / ${{ inputs.mode }}
|
|
199
|
-
needs: affected
|
|
200
|
-
if: >-
|
|
201
|
-
always() &&
|
|
202
|
-
vars.CI_BILLING_PAUSED != 'true' &&
|
|
203
|
-
(inputs['draft-protection'] != true || github.event_name != 'pull_request' || github.event.pull_request.draft == false) &&
|
|
204
|
-
(github.event_name != 'pull_request' || inputs.mode != 'preview' || !startsWith(github.event.pull_request.head.ref, 'release-please--branches--main')) &&
|
|
205
|
-
github.event_name != 'pull_request_target' &&
|
|
206
|
-
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
|
|
207
|
-
(
|
|
208
|
-
inputs['turbo-filter'] == '' ||
|
|
209
|
-
needs.affected.result == 'skipped' ||
|
|
210
|
-
needs.affected.outputs.should_deploy == 'true'
|
|
211
|
-
)
|
|
212
|
-
runs-on: ${{ inputs.runner }}
|
|
213
|
-
timeout-minutes: 20
|
|
214
|
-
# The explicit deployment record below is the sole GitHub deployment record.
|
|
215
|
-
# Keep the environment for approvals, secrets, and branch protection without
|
|
216
|
-
# creating a second, competing PR timeline entry.
|
|
217
|
-
environment:
|
|
218
|
-
name: ${{ inputs.environment || (inputs.mode == 'production' && 'Production' || 'Preview') }}
|
|
219
|
-
deployment: false
|
|
220
|
-
url: ${{ steps.deploy.outputs.url }}
|
|
221
|
-
outputs:
|
|
222
|
-
url: ${{ steps.deploy.outputs.url }}
|
|
223
|
-
version-id: ${{ steps.deploy.outputs.version-id }}
|
|
224
|
-
github-deployment-id: ${{ steps.start.outputs.id }}
|
|
225
|
-
steps:
|
|
226
|
-
- name: Checkout
|
|
227
|
-
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
228
|
-
with:
|
|
229
|
-
persist-credentials: false
|
|
230
|
-
- name: Validate deployment inputs
|
|
231
|
-
env:
|
|
232
|
-
MODE: ${{ inputs.mode }}
|
|
233
|
-
WRANGLER_VERSION: ${{ inputs.wrangler-version }}
|
|
234
|
-
run: |
|
|
235
|
-
set -euo pipefail
|
|
236
|
-
[[ "$MODE" == preview || "$MODE" == production ]]
|
|
237
|
-
[[ "$WRANGLER_VERSION" == local || "$WRANGLER_VERSION" == latest || "$WRANGLER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]
|
|
238
217
|
- name: Record pending deployment
|
|
218
|
+
if: steps.check.outputs.should_deploy != 'false'
|
|
239
219
|
id: start
|
|
240
220
|
env:
|
|
241
221
|
GH_TOKEN: ${{ github.token }}
|
|
@@ -263,7 +243,7 @@ jobs:
|
|
|
263
243
|
working-directory: ${{ inputs.install-working-directory }}
|
|
264
244
|
run: bun install --frozen-lockfile
|
|
265
245
|
- name: Build Worker
|
|
266
|
-
if: inputs.build-script != ''
|
|
246
|
+
if: inputs.build-script != '' && steps.check.outputs.should_deploy != 'false'
|
|
267
247
|
working-directory: ${{ inputs.working-directory }}
|
|
268
248
|
env:
|
|
269
249
|
BUILD_SCRIPT: ${{ inputs.build-script }}
|
|
@@ -273,6 +253,7 @@ jobs:
|
|
|
273
253
|
run: bun run "$BUILD_SCRIPT"
|
|
274
254
|
- name: Deploy to Cloudflare
|
|
275
255
|
id: deploy
|
|
256
|
+
if: steps.check.outputs.should_deploy != 'false'
|
|
276
257
|
working-directory: ${{ inputs.working-directory }}
|
|
277
258
|
env:
|
|
278
259
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
@@ -318,7 +299,7 @@ jobs:
|
|
|
318
299
|
appendFileSync(process.env.GITHUB_OUTPUT, `url=${url.href}\nversion-id=${record.version_id}\n`)
|
|
319
300
|
NODE
|
|
320
301
|
- name: Complete deployment status
|
|
321
|
-
if: always() && steps.start.outputs.id != ''
|
|
302
|
+
if: always() && steps.check.outputs.should_deploy != 'false' && steps.start.outputs.id != ''
|
|
322
303
|
env:
|
|
323
304
|
GH_TOKEN: ${{ github.token }}
|
|
324
305
|
DEPLOYMENT_ID: ${{ steps.start.outputs.id }}
|
|
@@ -125,10 +125,47 @@ jobs:
|
|
|
125
125
|
mkdir -p .github/actions
|
|
126
126
|
mv .github/.code-foundry "$RUNNER_TEMP/code-foundry"
|
|
127
127
|
cp -R "$RUNNER_TEMP/code-foundry/.github/actions/." .github/actions/
|
|
128
|
+
- name: List pull request files
|
|
129
|
+
id: changed
|
|
130
|
+
if: github.event_name == 'pull_request'
|
|
131
|
+
env:
|
|
132
|
+
GH_TOKEN: ${{ github.token }}
|
|
133
|
+
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
|
|
134
|
+
run: |
|
|
135
|
+
set -euo pipefail
|
|
136
|
+
# Newline-separated changed paths for the runtime's change
|
|
137
|
+
# detection. A failed listing leaves the file absent and the
|
|
138
|
+
# runtime fails open toward analyzing every language and shard.
|
|
139
|
+
gh api "repos/$GITHUB_REPOSITORY/pulls/$PULL_REQUEST_NUMBER/files?per_page=100" \
|
|
140
|
+
--paginate --jq '.[].filename' > "$RUNNER_TEMP/changed-files.txt" || true
|
|
141
|
+
echo "file=$RUNNER_TEMP/changed-files.txt" >> "$GITHUB_OUTPUT"
|
|
142
|
+
- name: Detect code scanning merge gate
|
|
143
|
+
id: gate
|
|
144
|
+
if: github.event_name == 'pull_request'
|
|
145
|
+
env:
|
|
146
|
+
GH_TOKEN: ${{ github.token }}
|
|
147
|
+
run: |
|
|
148
|
+
set -euo pipefail
|
|
149
|
+
# The code-scanning merge gate waits for results in every tracked
|
|
150
|
+
# category, so change detection must never skip an upload while it
|
|
151
|
+
# is active. Any ruleset API failure also disables change
|
|
152
|
+
# detection: full analysis is the safe default.
|
|
153
|
+
gated=false
|
|
154
|
+
for id in $(gh api "repos/$GITHUB_REPOSITORY/rulesets" --paginate \
|
|
155
|
+
-q '.[] | select(.enforcement == "active") | .id' 2>/dev/null); do
|
|
156
|
+
if gh api "repos/$GITHUB_REPOSITORY/rulesets/$id" \
|
|
157
|
+
-q 'any(.rules[]?; .type == "code_scanning")' 2>/dev/null | grep -q true; then
|
|
158
|
+
gated=true
|
|
159
|
+
break
|
|
160
|
+
fi
|
|
161
|
+
done
|
|
162
|
+
echo "gated=$gated" >> "$GITHUB_OUTPUT"
|
|
128
163
|
- id: languages
|
|
129
164
|
name: Detect languages
|
|
130
165
|
env:
|
|
131
166
|
CODEQL_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
|
|
167
|
+
FOUNDRY_CODEQL_CHANGED_FILES_FILE: ${{ steps.changed.outputs.file }}
|
|
168
|
+
FOUNDRY_CODEQL_MERGE_GATE: ${{ steps.gate.outputs.gated }}
|
|
132
169
|
REPO_FOUNDRY_CODE_SECURITY: ${{ steps.security.outputs.status }}
|
|
133
170
|
REPO_FOUNDRY_PRIVATE: ${{ github.event.repository.private }}
|
|
134
171
|
REPO_FOUNDRY_VISIBILITY: ${{ github.event.repository.visibility }}
|
|
@@ -166,14 +203,24 @@ jobs:
|
|
|
166
203
|
changed: entry.changed === true,
|
|
167
204
|
}));
|
|
168
205
|
const rust = languages.find((entry) => entry.language === "rust");
|
|
169
|
-
|
|
206
|
+
// The runtime emits per-shard change flags when the event is a
|
|
207
|
+
// pull request with a usable changed-file list; fall back to the
|
|
208
|
+
// shard list of the caller with the language-level flag
|
|
209
|
+
// otherwise. No single quotes: this script runs through node -e.
|
|
210
|
+
const rustShards =
|
|
211
|
+
rust && Array.isArray(rust.shards) && rust.shards.length
|
|
212
|
+
? rust.shards
|
|
213
|
+
: (rust
|
|
214
|
+
? shards.map((shard) => ({ shard: String(shard), changed: rust.changed === true }))
|
|
215
|
+
: []);
|
|
216
|
+
for (const entry of rustShards) {
|
|
170
217
|
matrix.push({
|
|
171
|
-
display:
|
|
218
|
+
display: entry.shard === "all" ? "Rust" : "Rust (" + entry.shard + ")",
|
|
172
219
|
language: "rust",
|
|
173
220
|
category: "/language:rust",
|
|
174
221
|
build_mode: "none",
|
|
175
|
-
changed:
|
|
176
|
-
shard: String(shard),
|
|
222
|
+
changed: entry.changed === true,
|
|
223
|
+
shard: String(entry.shard),
|
|
177
224
|
});
|
|
178
225
|
}
|
|
179
226
|
if (!matrix.length) {
|
|
@@ -14,7 +14,13 @@ permissions:
|
|
|
14
14
|
jobs:
|
|
15
15
|
detect:
|
|
16
16
|
name: OpenCode Security / Detect
|
|
17
|
-
|
|
17
|
+
# The OPENCODE_SECURITY repository or organization variable is the only
|
|
18
|
+
# enablement control, so gate the detector itself on it: a disabled
|
|
19
|
+
# repository must not allocate a runner just to report that it is off.
|
|
20
|
+
if: >-
|
|
21
|
+
vars.CI_BILLING_PAUSED != 'true' &&
|
|
22
|
+
vars.OPENCODE_SECURITY == 'true' &&
|
|
23
|
+
(github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false))
|
|
18
24
|
runs-on: ubuntu-slim
|
|
19
25
|
timeout-minutes: 10
|
|
20
26
|
outputs:
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.35.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.34.0...v1.35.0) (2026-09-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* fold the Turbo impact check into the cloudflare deploy job ([#655](https://github.com/0xPlayerOne/code-foundry/issues/655)) ([b72f5f2](https://github.com/0xPlayerOne/code-foundry/commit/b72f5f26f045103755bb2795f2fc3a84e7625b3c))
|
|
9
|
+
* scope CodeQL analysis to changed languages and Rust shards on pull requests ([#654](https://github.com/0xPlayerOne/code-foundry/issues/654)) ([d5ce743](https://github.com/0xPlayerOne/code-foundry/commit/d5ce74377601c2efb3be4831320cb62cdfbd999b))
|
|
10
|
+
* skip the security detector entirely while OpenCode Security is disabled ([#653](https://github.com/0xPlayerOne/code-foundry/issues/653)) ([9e4271e](https://github.com/0xPlayerOne/code-foundry/commit/9e4271e597d65b4f63c571baab5507eacaab1ce5))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Maintenance
|
|
14
|
+
|
|
15
|
+
* raise the packed artifact budget to 286 kB ([#657](https://github.com/0xPlayerOne/code-foundry/issues/657)) ([4512af7](https://github.com/0xPlayerOne/code-foundry/commit/4512af75fc18a36292f495c5e7f51791ba38f3d6))
|
|
16
|
+
|
|
17
|
+
## [1.34.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.33.3...v1.34.0) (2026-09-29)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Features
|
|
21
|
+
|
|
22
|
+
* billing-paused-dependabot ([#651](https://github.com/0xPlayerOne/code-foundry/issues/651)) ([eda05b5](https://github.com/0xPlayerOne/code-foundry/commit/eda05b5cfa28fc7314ec33a94e539a1660549511))
|
|
23
|
+
|
|
3
24
|
## [1.33.3](https://github.com/0xPlayerOne/code-foundry/compare/v1.33.2...v1.33.3) (2026-09-29)
|
|
4
25
|
|
|
5
26
|
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -45,6 +45,7 @@ to require an existing mise configuration.
|
|
|
45
45
|
| `runtime_ref` | tag or commit | Runtime version used by generated callers. |
|
|
46
46
|
| `features` | `all` or a list | See [Feature selection](#feature-selection). |
|
|
47
47
|
| `draft_protection` | `true`, `false` | Skip generated runner-heavy gates for draft PRs when false. |
|
|
48
|
+
| `billing_paused` | `true`, `false` | Disable Dependabot version updates while CI billing is paused. |
|
|
48
49
|
| `codeql` | `auto`, `true`, `false` | Enable CodeQL when the repository and GitHub plan support it. |
|
|
49
50
|
| `dependency_review` | `auto`, `true`, `false` | Enable Dependency Review when supported. |
|
|
50
51
|
| `runner` and `*_runner` | GitHub runner labels | Override the default runner per workflow. |
|
|
@@ -62,6 +63,14 @@ intentionally runs generated gates for draft PRs. Cloudflare reusable-workflow
|
|
|
62
63
|
callers use their equivalent `draft-protection` input. These opt-outs affect
|
|
63
64
|
CI/deployment gates only and do not disable Draft Guard or draft-PR automation.
|
|
64
65
|
|
|
66
|
+
The `CI_BILLING_PAUSED` repository variable gates every generated workflow job,
|
|
67
|
+
but Dependabot's update runs execute under the `dynamic` event and never see
|
|
68
|
+
that variable. Set `billing_paused: true` alongside the variable and re-run
|
|
69
|
+
`sync` so the rendered `dependabot.yml` sets `open-pull-requests-limit: 0` and
|
|
70
|
+
drops the update cadence to monthly for every ecosystem. Removing the flag and
|
|
71
|
+
syncing again restores the active template. `npx code-foundry ci status` reports
|
|
72
|
+
when a paused repository still has Dependabot updates active.
|
|
73
|
+
|
|
65
74
|
## Feature selection
|
|
66
75
|
|
|
67
76
|
Use `features: all` or a comma/space-separated list. The canonical validation
|
|
@@ -271,6 +280,15 @@ every tracked category. Two practical consequences:
|
|
|
271
280
|
categories in the shard list keeps the gate green but re-runs their analysis
|
|
272
281
|
on every pull request and is not a substitute for cleanup.
|
|
273
282
|
|
|
283
|
+
On pull requests without a code-scanning merge gate, analyzers run only for
|
|
284
|
+
languages with changed files, and Rust shards run only when their scope (or a
|
|
285
|
+
workspace-wide `Cargo` manifest or toolchain file) changed; unchanged matrix
|
|
286
|
+
entries report `Not applicable` instead of analyzing. Repositories whose
|
|
287
|
+
rulesets enforce a code-scanning requirement are detected automatically and
|
|
288
|
+
analyze everything, because that merge gate waits for results in every tracked
|
|
289
|
+
category. Push, schedule, and merge-queue events always analyze the complete
|
|
290
|
+
set so the default branch keeps one full, comparable baseline.
|
|
291
|
+
|
|
274
292
|
## Cloudflare Workers
|
|
275
293
|
|
|
276
294
|
Repositories that deploy to Cloudflare Workers can use the opt-in verified
|
package/docs/WORKFLOWS.md
CHANGED
|
@@ -126,7 +126,19 @@ Custom workflows are repository-owned and are not rewritten by sync. Add
|
|
|
126
126
|
honor the shared billing pause. The optional OpenCode Security scan honors a
|
|
127
127
|
second toggle: the `OPENCODE_SECURITY` repository or organization variable
|
|
128
128
|
(`true`/`false`) is its only enablement control, so individual repositories can
|
|
129
|
-
opt in or out without a code change.
|
|
129
|
+
opt in or out without a code change. When the variable is not `true`, no
|
|
130
|
+
Security job runs at all — a disabled repository allocates no runner for it.
|
|
131
|
+
Repository variables override the organization value, so an organization-level
|
|
132
|
+
variable toggles every consumer in one place while a single repository can
|
|
133
|
+
still opt out (or in) independently.
|
|
134
|
+
|
|
135
|
+
Dependabot is the one billable runner source the variable cannot gate: its
|
|
136
|
+
update runs execute under the `dynamic` event, outside every job-level guard.
|
|
137
|
+
While paused, set `billing_paused: true` in `.github/code-foundry.yml` and run a
|
|
138
|
+
sync so the rendered `dependabot.yml` disables version updates
|
|
139
|
+
(`open-pull-requests-limit: 0`, monthly cadence). Remove the flag and sync to
|
|
140
|
+
restore updates when billing resumes. `npx code-foundry ci status` reports the
|
|
141
|
+
Dependabot state, and `pause` warns when updates are still active.
|
|
130
142
|
|
|
131
143
|
## Standard workflow responsibilities
|
|
132
144
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-foundry",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.35.0",
|
|
4
4
|
"description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
|
|
5
5
|
"homepage": "https://github.com/0xPlayerOne/code-foundry#readme",
|
|
6
6
|
"bugs": {
|
package/src/commands/ci.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { readConfig } from '../lib/config.mjs'
|
|
4
6
|
|
|
5
7
|
export const CI_BILLING_PAUSED_VARIABLE = 'CI_BILLING_PAUSED'
|
|
6
8
|
export const CI_BILLING_BACKUP_VARIABLE = 'CI_BILLING_GATE_BACKUP'
|
|
@@ -26,11 +28,13 @@ export function manageCiBilling(root, action) {
|
|
|
26
28
|
const backup = backupText ? parseBackup(backupText) : null
|
|
27
29
|
|
|
28
30
|
if (action === 'status') {
|
|
31
|
+
const dependabot = dependabotPauseState(root)
|
|
29
32
|
const result = {
|
|
30
33
|
repository,
|
|
31
34
|
paused,
|
|
32
35
|
requiredCheck: CI_BILLING_REQUIRED_CHECK,
|
|
33
36
|
backupPresent: Boolean(backup),
|
|
37
|
+
dependabot,
|
|
34
38
|
}
|
|
35
39
|
console.log(JSON.stringify(result, null, 2))
|
|
36
40
|
return result
|
|
@@ -87,12 +91,25 @@ export function manageCiBilling(root, action) {
|
|
|
87
91
|
setVariable(repository, CI_BILLING_PAUSED_VARIABLE, 'true')
|
|
88
92
|
const cancelledRuns = cancelActiveRuns(repository)
|
|
89
93
|
for (const change of changes) updateRuleset(repository, change.ruleset)
|
|
94
|
+
const dependabot = dependabotPauseState(root)
|
|
90
95
|
const result = {
|
|
91
96
|
repository,
|
|
92
97
|
paused: true,
|
|
93
98
|
changed: true,
|
|
94
99
|
cancelledRuns,
|
|
95
100
|
rulesets: changes.map((change) => change.ruleset.name),
|
|
101
|
+
// Dependabot update runs execute under the `dynamic` event, so the
|
|
102
|
+
// repository variable alone never stops them. Surface the gap every
|
|
103
|
+
// time so a paused repository cannot keep burning minutes silently.
|
|
104
|
+
dependabot,
|
|
105
|
+
...(dependabot.active
|
|
106
|
+
? {
|
|
107
|
+
dependabotWarning:
|
|
108
|
+
'Dependabot version updates are still active and are not gated by ' +
|
|
109
|
+
`${CI_BILLING_PAUSED_VARIABLE}. Set billing_paused: true in .github/code-foundry.yml ` +
|
|
110
|
+
'and run a sync so dependabot.yml renders open-pull-requests-limit: 0.',
|
|
111
|
+
}
|
|
112
|
+
: {}),
|
|
96
113
|
}
|
|
97
114
|
console.log(JSON.stringify(result, null, 2))
|
|
98
115
|
return result
|
|
@@ -133,6 +150,31 @@ export function manageCiBilling(root, action) {
|
|
|
133
150
|
return result
|
|
134
151
|
}
|
|
135
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Inspect whether Dependabot version updates are still active in the working
|
|
155
|
+
* tree. The repository variable gates workflow jobs but not Dependabot's own
|
|
156
|
+
* `dynamic` update runs, so a paused repository also needs dependabot.yml to
|
|
157
|
+
* render `open-pull-requests-limit: 0` (the `billing_paused` config does this
|
|
158
|
+
* during sync). An ecosystem without an explicit limit of 0 counts as active
|
|
159
|
+
* because Dependabot defaults the limit to 5.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} root
|
|
162
|
+
* @returns {{ configured: boolean, active: boolean, file: string }}
|
|
163
|
+
*/
|
|
164
|
+
export function dependabotPauseState(root) {
|
|
165
|
+
const file = '.github/dependabot.yml'
|
|
166
|
+
const path = `${root.replace(/\/$/, '')}/${file}`
|
|
167
|
+
if (!existsSync(path)) return { configured: false, active: false, file }
|
|
168
|
+
const source = readFileSync(path, 'utf8')
|
|
169
|
+
const configured =
|
|
170
|
+
readConfig(`${root.replace(/\/$/, '')}/.github/code-foundry.yml`).billing_paused === 'true'
|
|
171
|
+
const blocks = source
|
|
172
|
+
.split(/(?=^ - package-ecosystem: )/m)
|
|
173
|
+
.filter((block) => block.startsWith(' - package-ecosystem: '))
|
|
174
|
+
const active = blocks.some((block) => !/^ open-pull-requests-limit: 0(\s|#|$)/m.test(block))
|
|
175
|
+
return { configured, active, file }
|
|
176
|
+
}
|
|
177
|
+
|
|
136
178
|
/** @param {any} ruleset */
|
|
137
179
|
export function buildPausedRuleset(ruleset) {
|
|
138
180
|
const requiredRuleIndexes = (ruleset.rules ?? []).flatMap(
|
package/src/commands/sync.mjs
CHANGED
|
@@ -823,12 +823,21 @@ function removeWorkflowBlock(content, blockId) {
|
|
|
823
823
|
* part of the configured language set, respectively; weekly updater runs
|
|
824
824
|
* fail when an ecosystem has no manifests to read, so unconfigured
|
|
825
825
|
* ecosystems are dropped instead of left to error.
|
|
826
|
+
*
|
|
827
|
+
* The billing pause must also cover Dependabot: its update runs execute as
|
|
828
|
+
* Actions workflows under the `dynamic` event, so the `CI_BILLING_PAUSED`
|
|
829
|
+
* repository variable that gates every workflow job cannot stop them. When
|
|
830
|
+
* `billing_paused: true` is configured, version updates are disabled by
|
|
831
|
+
* rendering `open-pull-requests-limit: 0` and dropping the cadence to
|
|
832
|
+
* monthly; `schedule` is a required key, so the ecosystem blocks stay
|
|
833
|
+
* structurally valid and a later sync with the flag removed restores the
|
|
834
|
+
* template verbatim.
|
|
826
835
|
* @param {string} content
|
|
827
836
|
* @param {Record<string,string>} config
|
|
828
837
|
* @param {string} languages
|
|
829
838
|
* @returns {string}
|
|
830
839
|
*/
|
|
831
|
-
function renderDependabot(content, config, languages) {
|
|
840
|
+
export function renderDependabot(content, config, languages) {
|
|
832
841
|
let rendered = content
|
|
833
842
|
if (!includesValue(languages, 'rust')) {
|
|
834
843
|
rendered = stripDependabotEcosystem(rendered, 'cargo')
|
|
@@ -839,10 +848,33 @@ function renderDependabot(content, config, languages) {
|
|
|
839
848
|
if (!includesValue(languages, 'python')) {
|
|
840
849
|
rendered = stripDependabotEcosystem(rendered, 'pip')
|
|
841
850
|
}
|
|
851
|
+
if (configured(config.billing_paused, 'false') === 'true') {
|
|
852
|
+
rendered = pauseDependabot(rendered)
|
|
853
|
+
}
|
|
842
854
|
if (isStagingRelease(config.git_workflow)) return rendered
|
|
843
855
|
return rendered.replaceAll('target-branch: staging', 'target-branch: main')
|
|
844
856
|
}
|
|
845
857
|
|
|
858
|
+
/**
|
|
859
|
+
* Disable Dependabot version updates for every remaining ecosystem. The
|
|
860
|
+
* transformations are regex-based so they hold for any ecosystem count and
|
|
861
|
+
* any future template default; an unchanged file after both replacements
|
|
862
|
+
* means the template stopped defining schedules or limits, which is a loud
|
|
863
|
+
* template drift the next sync test will catch.
|
|
864
|
+
* @param {string} content
|
|
865
|
+
* @returns {string}
|
|
866
|
+
*/
|
|
867
|
+
function pauseDependabot(content) {
|
|
868
|
+
const limited = content.replace(
|
|
869
|
+
/^(\s*)open-pull-requests-limit:\s*\d+\s*$/gm,
|
|
870
|
+
`$1open-pull-requests-limit: 0 # billing_paused: version updates disabled`
|
|
871
|
+
)
|
|
872
|
+
return limited.replace(
|
|
873
|
+
/^(\s*)interval:\s*weekly\s*$/gm,
|
|
874
|
+
`$1interval: monthly # billing_paused: reduced cadence`
|
|
875
|
+
)
|
|
876
|
+
}
|
|
877
|
+
|
|
846
878
|
/**
|
|
847
879
|
* Remove one package-ecosystem update block from a dependabot template.
|
|
848
880
|
* Blocks are split on their leading list marker so removal never disturbs
|
package/src/runtime-core.mjs
CHANGED
|
@@ -1011,8 +1011,13 @@ function codeql() {
|
|
|
1011
1011
|
available.push('javascript-typescript')
|
|
1012
1012
|
if (hasLanguage('python')) available.push('python')
|
|
1013
1013
|
if (hasLanguage('rust')) available.push('rust')
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1014
|
+
// On pull requests, analyzers whose language has no changed files skip
|
|
1015
|
+
// their analysis steps, and Rust shards skip individually by scope. Every
|
|
1016
|
+
// other event analyzes the complete set so the default branch keeps one
|
|
1017
|
+
// full, comparable code-scanning baseline; a missing or unreadable
|
|
1018
|
+
// changed-file list also fails open toward analyzing everything.
|
|
1019
|
+
const changedPaths = pullRequestChangedPaths()
|
|
1020
|
+
/** @type {CodeqlLanguageEntry[]} */
|
|
1016
1021
|
const languagesJson = available.map((language) => ({
|
|
1017
1022
|
language,
|
|
1018
1023
|
name:
|
|
@@ -1020,8 +1025,17 @@ function codeql() {
|
|
|
1020
1025
|
? 'TypeScript'
|
|
1021
1026
|
: language[0].toUpperCase() + language.slice(1),
|
|
1022
1027
|
'build-mode': 'none',
|
|
1023
|
-
changed:
|
|
1028
|
+
changed: changedPaths === null || languageChanged(language, changedPaths),
|
|
1024
1029
|
}))
|
|
1030
|
+
const rustEntry = languagesJson.find((item) => item.language === 'rust')
|
|
1031
|
+
if (rustEntry && changedPaths !== null) {
|
|
1032
|
+
const shards = configuredRustShards().map((shard) => ({
|
|
1033
|
+
shard,
|
|
1034
|
+
changed: shardChanged(shard, changedPaths),
|
|
1035
|
+
}))
|
|
1036
|
+
rustEntry.shards = shards
|
|
1037
|
+
rustEntry.changed = shards.some((entry) => entry.changed)
|
|
1038
|
+
}
|
|
1025
1039
|
writeOutput('languages', languagesJson)
|
|
1026
1040
|
for (const language of ['actions', 'javascript-typescript', 'python', 'rust']) {
|
|
1027
1041
|
const entry = languagesJson.find((item) => item.language === language)
|
|
@@ -1032,6 +1046,105 @@ function codeql() {
|
|
|
1032
1046
|
}
|
|
1033
1047
|
}
|
|
1034
1048
|
|
|
1049
|
+
/**
|
|
1050
|
+
* Changed file paths for the current pull request, read from the list the
|
|
1051
|
+
* caller materializes with the pull request files API. Returns null when
|
|
1052
|
+
* change detection does not apply — any event other than pull_request, a
|
|
1053
|
+
* repository whose rulesets enforce the code-scanning merge gate (that gate
|
|
1054
|
+
* waits for results in every tracked category, so skipped uploads deadlock
|
|
1055
|
+
* merges), or a missing, unreadable, or truncated list — so callers fail
|
|
1056
|
+
* open toward analyzing everything.
|
|
1057
|
+
* @returns {Set<string> | null}
|
|
1058
|
+
*/
|
|
1059
|
+
function pullRequestChangedPaths() {
|
|
1060
|
+
if ((process.env.FOUNDRY_EVENT_NAME ?? '') !== 'pull_request') return null
|
|
1061
|
+
if (process.env.FOUNDRY_CODEQL_MERGE_GATE === 'true') return null
|
|
1062
|
+
const file = process.env.FOUNDRY_CODEQL_CHANGED_FILES_FILE ?? ''
|
|
1063
|
+
if (!file) return null
|
|
1064
|
+
let contents = ''
|
|
1065
|
+
try {
|
|
1066
|
+
contents = readFileSync(file, 'utf8')
|
|
1067
|
+
} catch {
|
|
1068
|
+
return null
|
|
1069
|
+
}
|
|
1070
|
+
const paths = contents
|
|
1071
|
+
.split('\n')
|
|
1072
|
+
.map((line) => line.trim())
|
|
1073
|
+
.filter(Boolean)
|
|
1074
|
+
// The pull request files endpoint stops at 3000 entries; a list that large
|
|
1075
|
+
// cannot prove any language unchanged.
|
|
1076
|
+
if (paths.length >= 3000) return null
|
|
1077
|
+
return new Set(paths)
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* One detected CodeQL language for the analysis matrix. `shards` carries
|
|
1082
|
+
* per-scope change flags for Rust on pull requests; the matrix builder in
|
|
1083
|
+
* codeql.yml falls back to the language-level flag when it is absent.
|
|
1084
|
+
* @typedef {{
|
|
1085
|
+
* language: string,
|
|
1086
|
+
* name: string,
|
|
1087
|
+
* 'build-mode': string,
|
|
1088
|
+
* changed: boolean,
|
|
1089
|
+
* shards?: Array<{ shard: string, changed: boolean }>,
|
|
1090
|
+
* }} CodeqlLanguageEntry
|
|
1091
|
+
*/
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Path predicates per CodeQL language. Manifests and lockfiles count as
|
|
1095
|
+
* language changes: dependency bumps alter what the analyzer sees.
|
|
1096
|
+
* @type {Record<string, RegExp>}
|
|
1097
|
+
*/
|
|
1098
|
+
const CODEQL_LANGUAGE_PATHS = {
|
|
1099
|
+
actions: /^\.github\/(workflows|actions)\//,
|
|
1100
|
+
'javascript-typescript':
|
|
1101
|
+
/\.(js|jsx|ts|tsx|mjs|cjs|mts|cts)$|(^|\/)(package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lock|bun\.lockb|tsconfig[^/]*\.json|jsconfig[^/]*\.json)$/,
|
|
1102
|
+
python: /\.py$|(^|\/)(pyproject\.toml|poetry\.lock|setup\.py|setup\.cfg|requirements[^/]*\.txt)$/,
|
|
1103
|
+
rust: /\.rs$|(^|\/)(Cargo\.(toml|lock)|rust-toolchain(\.toml)?)$/,
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** @param {string} language @param {Set<string>} changedPaths */
|
|
1107
|
+
function languageChanged(language, changedPaths) {
|
|
1108
|
+
const pattern = CODEQL_LANGUAGE_PATHS[language]
|
|
1109
|
+
if (!pattern) return true
|
|
1110
|
+
for (const path of changedPaths) if (pattern.test(path)) return true
|
|
1111
|
+
return false
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** @returns {string[]} */
|
|
1115
|
+
function configuredRustShards() {
|
|
1116
|
+
const raw = configured(config.codeql_rust_shards, '["all"]')
|
|
1117
|
+
try {
|
|
1118
|
+
const parsed = JSON.parse(raw)
|
|
1119
|
+
return Array.isArray(parsed) && parsed.length ? parsed.map(String) : ['all']
|
|
1120
|
+
} catch {
|
|
1121
|
+
return ['all']
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* A scoped shard runs when a changed path falls inside one of its scope
|
|
1127
|
+
* prefixes, or when a workspace-wide manifest changed: the analysis config
|
|
1128
|
+
* appends the Cargo manifests and toolchain to every shard, so those files
|
|
1129
|
+
* re-arm all shards at once. The `all` shard never skips.
|
|
1130
|
+
* @param {string} shard @param {Set<string>} changedPaths
|
|
1131
|
+
*/
|
|
1132
|
+
function shardChanged(shard, changedPaths) {
|
|
1133
|
+
if (shard === 'all') return true
|
|
1134
|
+
const scopes = shard
|
|
1135
|
+
.split(',')
|
|
1136
|
+
.map((scope) => scope.trim())
|
|
1137
|
+
.filter(Boolean)
|
|
1138
|
+
for (const path of changedPaths) {
|
|
1139
|
+
if (/^(Cargo\.toml|Cargo\.lock|rust-toolchain(\.toml)?)$/.test(path)) return true
|
|
1140
|
+
for (const scope of scopes) {
|
|
1141
|
+
const prefix = scope.endsWith('/') ? scope : `${scope}/`
|
|
1142
|
+
if (path === scope || path.startsWith(prefix)) return true
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return false
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1035
1148
|
/** @param {string} command */
|
|
1036
1149
|
function printProfile(command) {
|
|
1037
1150
|
if (command === 'get') {
|