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.
@@ -77,31 +77,63 @@ concurrency:
77
77
  cancel-in-progress: false
78
78
 
79
79
  jobs:
80
- affected:
81
- name: Check Turbo deployment impact
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: 10
92
- permissions:
93
- contents: read
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
- should_deploy: ${{ steps.check.outputs.should_deploy || steps.resolve.outputs.should_deploy }}
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 full history
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
- for (const shard of rust ? shards : []) {
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: String(shard) === "all" ? "Rust" : "Rust (" + shard + ")",
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: rust.changed === true,
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
- if: vars.CI_BILLING_PAUSED != 'true' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false))
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
 
@@ -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.33.3",
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": {
@@ -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(
@@ -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
@@ -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
- // Upload every configured language on every analysis run so GitHub can
1015
- // compare pull requests against the base branch's code-scanning config.
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: true,
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') {