code-foundry 1.53.5 → 1.53.7

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.
@@ -35,6 +35,15 @@ on:
35
35
  default: 1
36
36
  # Retained for caller compatibility. Rust shards share the single
37
37
  # analysis matrix, so no separate throttle applies.
38
+ plan:
39
+ description: >-
40
+ JSON object of the Detect job's outputs, computed by the caller's
41
+ planning job (`validation plan`). When set, Detect is skipped and
42
+ analysis reads the plan instead, saving one runner hop. Empty runs
43
+ Detect here as before.
44
+ required: false
45
+ type: string
46
+ default: ''
38
47
  permissions:
39
48
  actions: read
40
49
  contents: read
@@ -60,7 +69,7 @@ concurrency:
60
69
  jobs:
61
70
  detect:
62
71
  name: Detect
63
- if: vars.CI_BILLING_PAUSED != 'true'
72
+ if: vars.CI_BILLING_PAUSED != 'true' && inputs.plan == ''
64
73
  runs-on: ${{ inputs.runner }}
65
74
  timeout-minutes: 10
66
75
  outputs:
@@ -238,11 +247,16 @@ jobs:
238
247
  analyze:
239
248
  name: Analyze (${{ matrix.entry.display }})
240
249
  needs: detect
241
- if: vars.CI_BILLING_PAUSED != 'true' && needs.detect.outputs.enabled == 'true'
250
+ # A planned run skips Detect and reads the same outputs from the plan;
251
+ # an unplanned run still requires Detect to have succeeded.
252
+ if: >-
253
+ !cancelled() && vars.CI_BILLING_PAUSED != 'true' &&
254
+ ((inputs.plan == '' && needs.detect.result == 'success' && needs.detect.outputs.enabled == 'true') ||
255
+ (inputs.plan != '' && needs.detect.result == 'skipped' && fromJSON(inputs.plan || '{}').enabled == 'true'))
242
256
  strategy:
243
257
  fail-fast: false
244
258
  matrix:
245
- entry: ${{ fromJson(needs.detect.outputs.matrix || '[]') }}
259
+ entry: ${{ fromJson((inputs.plan != '' && fromJSON(inputs.plan || '{}').matrix) || needs.detect.outputs.matrix || '[]') }}
246
260
  runs-on: ${{ inputs.runner }}
247
261
  timeout-minutes: 30
248
262
  steps:
@@ -336,7 +350,7 @@ jobs:
336
350
  uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
337
351
  with:
338
352
  languages: rust
339
- build-mode: ${{ needs.detect.outputs.rust_build_mode }}
353
+ build-mode: ${{ (inputs.plan != '' && fromJSON(inputs.plan || '{}').rust_build_mode) || needs.detect.outputs.rust_build_mode }}
340
354
  config-file: ${{ steps.scope.outputs.config_file }}
341
355
  threads: ${{ inputs.rust-threads }}
342
356
  - name: Analyze Rust
@@ -26,6 +26,15 @@ on:
26
26
  required: false
27
27
  type: string
28
28
  default: task-result
29
+ idle-lanes:
30
+ description: >-
31
+ Comma-separated lanes the caller's planner found no entrypoint for
32
+ (integration, e2e, smoke, performance, eval). Their jobs are
33
+ skipped instead of allocating a runner for a no-op. Empty runs every
34
+ lane.
35
+ required: false
36
+ type: string
37
+ default: ''
29
38
 
30
39
  permissions:
31
40
  contents: read
@@ -51,7 +60,7 @@ concurrency:
51
60
  jobs:
52
61
  eval:
53
62
  name: Eval
54
- if: vars.CI_BILLING_PAUSED != 'true'
63
+ if: vars.CI_BILLING_PAUSED != 'true' && !contains(format(',{0},', inputs.idle-lanes), ',eval,')
55
64
  runs-on: ${{ inputs.runner }}
56
65
  timeout-minutes: 30
57
66
  steps:
@@ -18,6 +18,16 @@ on:
18
18
  required: false
19
19
  type: string
20
20
  default: ubuntu-slim
21
+ plan:
22
+ description: >-
23
+ JSON object of the Profile job's outputs, computed by the caller's
24
+ planning job (`validation plan`). When set, Profile is skipped,
25
+ audits read the plan instead, and Dependency Review runs as its own
26
+ job beside them, saving one runner hop. Empty runs Profile here as
27
+ before.
28
+ required: false
29
+ type: string
30
+ default: ''
21
31
  permissions:
22
32
  contents: read
23
33
 
@@ -39,7 +49,7 @@ concurrency:
39
49
  jobs:
40
50
  profile:
41
51
  name: Profile
42
- if: vars.CI_BILLING_PAUSED != 'true'
52
+ if: vars.CI_BILLING_PAUSED != 'true' && inputs.plan == ''
43
53
  runs-on: ${{ inputs.runner }}
44
54
  timeout-minutes: 10
45
55
  outputs:
@@ -142,12 +152,16 @@ jobs:
142
152
  needs: profile
143
153
  # Avoid expanding an empty matrix; skip only the explicitly empty result.
144
154
  # Missing or malformed output still fails closed during matrix expansion.
145
- if: vars.CI_BILLING_PAUSED != 'true' && needs.profile.result == 'success' && needs.profile.outputs.audit_matrix != '[]'
155
+ # A planned run skips Profile and reads the same matrix from the plan.
156
+ if: >-
157
+ !cancelled() && vars.CI_BILLING_PAUSED != 'true' &&
158
+ ((inputs.plan == '' && needs.profile.result == 'success' && needs.profile.outputs.audit_matrix != '[]') ||
159
+ (inputs.plan != '' && needs.profile.result == 'skipped' && fromJSON(inputs.plan || '{}').audit_matrix != '[]'))
146
160
  strategy:
147
161
  fail-fast: false
148
162
  max-parallel: 8
149
163
  matrix:
150
- entry: ${{ fromJson(needs.profile.outputs.audit_matrix || '[]') }}
164
+ entry: ${{ fromJson((inputs.plan != '' && fromJSON(inputs.plan || '{}').audit_matrix) || needs.profile.outputs.audit_matrix || '[]') }}
151
165
  runs-on: ${{ inputs.runner }}
152
166
  timeout-minutes: 20
153
167
  env:
@@ -386,3 +400,15 @@ jobs:
386
400
  - name: Audit Python dependencies
387
401
  if: matrix.entry.language == 'python'
388
402
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" security audit python
403
+ # Planned runs only: with no Profile job, the first audit job runs
404
+ # Dependency Review, whatever the audit's own outcome, so the review
405
+ # costs no runner hop. The planner leaves a run with a review but no
406
+ # audit unplanned, so Profile still runs it there.
407
+ - name: Dependency Review
408
+ if: >-
409
+ !cancelled() && inputs.plan != '' && strategy.job-index == 0 &&
410
+ github.event_name == 'pull_request' && fromJSON(inputs.plan || '{}').dependency_review == 'true'
411
+ uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5
412
+ with:
413
+ fail-on-severity: high
414
+ license-check: true
@@ -53,6 +53,15 @@ on:
53
53
  required: false
54
54
  type: string
55
55
  default: '1'
56
+ idle-lanes:
57
+ description: >-
58
+ Comma-separated lanes the caller's planner found no entrypoint for
59
+ (integration, e2e, smoke, performance, eval). Their jobs are
60
+ skipped instead of allocating a runner for a no-op. Empty runs every
61
+ lane.
62
+ required: false
63
+ type: string
64
+ default: ''
56
65
  secrets:
57
66
  TURBO_TOKEN:
58
67
  required: false
@@ -171,7 +180,7 @@ jobs:
171
180
 
172
181
  performance:
173
182
  name: Performance
174
- if: vars.CI_BILLING_PAUSED != 'true'
183
+ if: vars.CI_BILLING_PAUSED != 'true' && !contains(format(',{0},', inputs.idle-lanes), ',performance,')
175
184
  runs-on: ${{ inputs.performance-runner }}
176
185
  timeout-minutes: 30
177
186
  steps:
@@ -260,7 +269,9 @@ jobs:
260
269
 
261
270
  integration:
262
271
  name: Integration
263
- if: vars.CI_BILLING_PAUSED != 'true' && inputs.unit-only != true && inputs.integration-total-shards != '2'
272
+ if: >-
273
+ vars.CI_BILLING_PAUSED != 'true' && inputs.unit-only != true && inputs.integration-total-shards != '2' &&
274
+ !contains(format(',{0},', inputs.idle-lanes), ',integration,')
264
275
  runs-on: ${{ inputs.runner }}
265
276
  timeout-minutes: 30
266
277
  steps:
@@ -337,7 +348,9 @@ jobs:
337
348
 
338
349
  integration-sharded:
339
350
  name: Integration shard
340
- if: vars.CI_BILLING_PAUSED != 'true' && inputs.unit-only != true && inputs.integration-total-shards == '2'
351
+ if: >-
352
+ vars.CI_BILLING_PAUSED != 'true' && inputs.unit-only != true && inputs.integration-total-shards == '2' &&
353
+ !contains(format(',{0},', inputs.idle-lanes), ',integration,')
341
354
  runs-on: ${{ inputs.runner }}
342
355
  timeout-minutes: 30
343
356
  strategy:
@@ -417,7 +430,9 @@ jobs:
417
430
 
418
431
  e2e:
419
432
  name: E2E
420
- if: vars.CI_BILLING_PAUSED != 'true' && inputs.unit-only != true
433
+ if: >-
434
+ vars.CI_BILLING_PAUSED != 'true' && inputs.unit-only != true &&
435
+ !contains(format(',{0},', inputs.idle-lanes), ',e2e,')
421
436
  runs-on: ${{ inputs.runner }}
422
437
  timeout-minutes: 45
423
438
  # Shards duplicate the whole job, but each shard owns a runner-local test
@@ -514,7 +529,9 @@ jobs:
514
529
 
515
530
  smoke:
516
531
  name: Smoke
517
- if: vars.CI_BILLING_PAUSED != 'true' && inputs.unit-only != true
532
+ if: >-
533
+ vars.CI_BILLING_PAUSED != 'true' && inputs.unit-only != true &&
534
+ !contains(format(',{0},', inputs.idle-lanes), ',smoke,')
518
535
  runs-on: ${{ inputs.runner }}
519
536
  timeout-minutes: 15
520
537
  steps:
@@ -115,6 +115,31 @@ on:
115
115
  required: false
116
116
  type: string
117
117
  default: '1'
118
+ idle-lanes:
119
+ description: >-
120
+ Comma-separated lanes the caller's planner found no entrypoint for
121
+ (integration, e2e, smoke, performance, eval). Only the separate E2E
122
+ job is skipped here; the merged fast lanes keep their own Detect
123
+ steps. Empty runs every lane.
124
+ required: false
125
+ type: string
126
+ default: ''
127
+ security-plan:
128
+ description: >-
129
+ Accepted so every orchestrator takes the same caller inputs. The
130
+ merged audit job profiles in place, so the plan is unused.
131
+ required: false
132
+ type: string
133
+ default: ''
134
+ codeql-plan:
135
+ description: >-
136
+ JSON CodeQL Detect outputs from the caller's planning job. When set,
137
+ the CodeQL workflow skips its Detect job and reads the plan, and a
138
+ plan marked idle (CodeQL unavailable) skips the workflow. Empty
139
+ detects inside the workflow as before.
140
+ required: false
141
+ type: string
142
+ default: ''
118
143
  secrets:
119
144
  TURBO_TOKEN:
120
145
  required: false
@@ -787,7 +812,11 @@ jobs:
787
812
 
788
813
  e2e:
789
814
  name: E2E
790
- if: vars.CI_BILLING_PAUSED != 'true' && inputs.mode == 'audit'
815
+ # The gate already accepts a skipped E2E lane; a planner-idle E2E lane
816
+ # skips here instead of allocating a runner for a no-op.
817
+ if: >-
818
+ vars.CI_BILLING_PAUSED != 'true' && inputs.mode == 'audit' &&
819
+ !contains(format(',{0},', inputs.idle-lanes), ',e2e,')
791
820
  permissions:
792
821
  actions: read
793
822
  contents: read
@@ -1203,7 +1232,9 @@ jobs:
1203
1232
  name: CodeQL
1204
1233
  # Release-please pull requests also analyze with CodeQL so repository
1205
1234
  # rulesets requiring code scanning results never deadlock the release.
1206
- if: vars.CI_BILLING_PAUSED != 'true' && inputs.codeql == true && (inputs.mode == 'audit' || inputs.mode == 'release')
1235
+ if: >-
1236
+ vars.CI_BILLING_PAUSED != 'true' && inputs.codeql == true && (inputs.mode == 'audit' || inputs.mode == 'release') &&
1237
+ fromJSON(inputs.codeql-plan || '{}').idle != 'true'
1207
1238
  permissions:
1208
1239
  actions: read
1209
1240
  contents: read
@@ -1217,6 +1248,7 @@ jobs:
1217
1248
  rust-shards: ${{ inputs.rust-shards }}
1218
1249
  rust-threads: ${{ inputs.rust-threads }}
1219
1250
  rust-max-parallel: ${{ inputs.rust-max-parallel }}
1251
+ plan: ${{ inputs.codeql-plan }}
1220
1252
 
1221
1253
  # Aggregate check: "Validation / Gate" (caller job name + this job name).
1222
1254
  # Always evaluates; only jobs required for the mode must have succeeded.
@@ -1243,8 +1275,10 @@ jobs:
1243
1275
  FOUNDRY_SECURITY: ${{ needs.audit.result }}
1244
1276
  # This orchestrator runs CodeQL only when the consumer's canonical
1245
1277
  # configuration enables it; otherwise the unavailability decision is
1246
- # treated as satisfied without registering a skipped PR check.
1247
- FOUNDRY_CODEQL: ${{ inputs.codeql == true && needs.codeql.result || 'success' }}
1278
+ # treated as satisfied without registering a skipped PR check. A CodeQL
1279
+ # workflow the caller planned as idle (unavailable) reports skipped;
1280
+ # only that planned skip counts as success.
1281
+ FOUNDRY_CODEQL: ${{ inputs.codeql == true && ((needs.codeql.result == 'skipped' && fromJSON(inputs.codeql-plan || '{}').idle == 'true' && 'success') || needs.codeql.result) || 'success' }}
1248
1282
  # Eval runs inside the merged fast-lanes job on the audit tier; a
1249
1283
  # fast-lanes failure on that tier marks the eval category failed even if
1250
1284
  # another merged lane caused it, which fails closed.
@@ -1293,7 +1327,8 @@ jobs:
1293
1327
  needs.fast-lanes.result == 'success' && needs.lint.result == 'success' &&
1294
1328
  needs.type-check.result == 'success' && needs.unit.result == 'success' &&
1295
1329
  needs.e2e.result == 'success' && needs.audit.result == 'success' &&
1296
- (inputs.codeql != true || needs.codeql.result == 'success') &&
1330
+ (inputs.codeql != true || needs.codeql.result == 'success' ||
1331
+ (needs.codeql.result == 'skipped' && fromJSON(inputs.codeql-plan || '{}').idle == 'true')) &&
1297
1332
  needs.gate.result == 'success'
1298
1333
  permissions:
1299
1334
  actions: read
@@ -1308,5 +1343,5 @@ jobs:
1308
1343
  runtime-repository: ${{ inputs.runtime-repository }}
1309
1344
  runtime-ref: ${{ inputs.runtime-ref }}
1310
1345
  job-results-json: >-
1311
- {"ci":"${{ needs.lint.result == 'success' && needs.type-check.result == 'success' && needs.fast-lanes.result == 'success' && 'success' || 'failure' }}","test":"${{ needs.unit.result == 'success' && needs.fast-lanes.result == 'success' && needs.e2e.result == 'success' && 'success' || 'failure' }}","security":"${{ needs.audit.result }}","codeql":"${{ inputs.codeql == true && needs.codeql.result || 'not-configured' }}","eval":"${{ needs.fast-lanes.result }}","gate":"${{ needs.gate.result }}"}
1346
+ {"ci":"${{ needs.lint.result == 'success' && needs.type-check.result == 'success' && needs.fast-lanes.result == 'success' && 'success' || 'failure' }}","test":"${{ needs.unit.result == 'success' && needs.fast-lanes.result == 'success' && needs.e2e.result == 'success' && 'success' || 'failure' }}","security":"${{ needs.audit.result }}","codeql":"${{ inputs.codeql == true && ((needs.codeql.result == 'skipped' && fromJSON(inputs.codeql-plan || '{}').idle == 'true' && 'success') || needs.codeql.result) || 'not-configured' }}","eval":"${{ needs.fast-lanes.result }}","gate":"${{ needs.gate.result }}"}
1312
1347
  required-job-names-json: '["Validation / Gate"]'
@@ -75,6 +75,32 @@ on:
75
75
  required: false
76
76
  type: number
77
77
  default: 1
78
+ idle-lanes:
79
+ description: >-
80
+ Comma-separated lanes the caller's planner found no entrypoint for
81
+ (integration, e2e, smoke, performance, eval). Their jobs are
82
+ skipped instead of allocating a runner for a no-op. Empty runs every
83
+ lane.
84
+ required: false
85
+ type: string
86
+ default: ''
87
+ security-plan:
88
+ description: >-
89
+ JSON Security Profile outputs from the caller's planning job. When
90
+ set, the Security workflow skips its Profile job and reads the plan,
91
+ and a plan marked idle (no dependency audit and no dependency
92
+ review to run) skips the workflow. Empty profiles inside the
93
+ workflow as before.
94
+ required: false
95
+ type: string
96
+ default: ''
97
+ codeql-plan:
98
+ description: >-
99
+ Accepted so every orchestrator takes the same caller inputs. This
100
+ variant runs no CodeQL, so the plan is unused.
101
+ required: false
102
+ type: string
103
+ default: ''
78
104
  artifact-prefix:
79
105
  description: Prefix for task receipts; use a unique value when calling this workflow more than once.
80
106
  required: false
@@ -181,12 +207,17 @@ jobs:
181
207
  integration-total-shards: ${{ inputs.integration-total-shards }}
182
208
  e2e-shard-list: ${{ inputs.e2e-shard-list }}
183
209
  e2e-total-shards: ${{ inputs.e2e-total-shards }}
210
+ idle-lanes: ${{ inputs.idle-lanes }}
184
211
  secrets:
185
212
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
186
213
 
187
214
  security:
188
215
  name: Security
189
- if: vars.CI_BILLING_PAUSED != 'true' && inputs.mode == 'audit'
216
+ # A plan marked idle has no audit or review to run; the gate counts that
217
+ # planned skip as the success the empty workflow used to report.
218
+ if: >-
219
+ vars.CI_BILLING_PAUSED != 'true' && inputs.mode == 'audit' &&
220
+ fromJSON(inputs.security-plan || '{}').idle != 'true'
190
221
  permissions:
191
222
  contents: read
192
223
  uses: ./.github/workflows/security.yml
@@ -194,6 +225,7 @@ jobs:
194
225
  runtime-repository: ${{ inputs.runtime-repository }}
195
226
  runtime-ref: ${{ inputs.runtime-ref }}
196
227
  runner: ${{ inputs.security-runner }}
228
+ plan: ${{ inputs.security-plan }}
197
229
 
198
230
  eval:
199
231
  name: Eval
@@ -209,6 +241,7 @@ jobs:
209
241
  runtime-ref: ${{ inputs.runtime-ref }}
210
242
  runner: ${{ inputs.eval-runner }}
211
243
  artifact-prefix: ${{ inputs.artifact-prefix }}
244
+ idle-lanes: ${{ inputs.idle-lanes }}
212
245
 
213
246
  # Aggregate check: "Validation / Gate" (caller job name + this job name).
214
247
  # Always evaluates; only jobs required for the mode must have succeeded.
@@ -228,8 +261,15 @@ jobs:
228
261
  FOUNDRY_MODE: ${{ inputs.mode }}
229
262
  FOUNDRY_CI: ${{ needs.ci.result }}
230
263
  FOUNDRY_TEST: ${{ needs.test.result }}
231
- FOUNDRY_SECURITY: ${{ needs.security.result }}
232
- FOUNDRY_EVAL: ${{ needs.eval.result }}
264
+ # A Security or CodeQL workflow the caller planned as idle never
265
+ # starts and reports skipped. Count only that planned skip as the
266
+ # success its empty run reported before.
267
+ FOUNDRY_SECURITY: ${{ (needs.security.result == 'skipped' && fromJSON(inputs.security-plan || '{}').idle == 'true' && 'success') || needs.security.result }}
268
+ # An eval lane the caller planned as idle (no eval script, no
269
+ # eval_command, or eval: false) never starts, so the whole Eval
270
+ # workflow reports skipped. Count that exactly like the no-op success
271
+ # it reported before; any other skip still fails the gate.
272
+ FOUNDRY_EVAL: ${{ (needs.eval.result == 'skipped' && contains(format(',{0},', inputs.idle-lanes), ',eval,') && 'success') || needs.eval.result }}
233
273
  FOUNDRY_SOURCE_QUALIFICATION: ${{ needs.qualification.outputs.reuse }}
234
274
  FOUNDRY_QUALIFICATION: ${{ needs.qualification.result }}
235
275
  # This workflow is selected only for repositories whose canonical
@@ -276,7 +316,10 @@ jobs:
276
316
  inputs.release-qualification-receipts == true &&
277
317
  github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main' &&
278
318
  needs.ci.result == 'success' && needs.test.result == 'success' &&
279
- needs.security.result == 'success' && needs.eval.result == 'success' &&
319
+ (needs.security.result == 'success' ||
320
+ (needs.security.result == 'skipped' && fromJSON(inputs.security-plan || '{}').idle == 'true')) &&
321
+ (needs.eval.result == 'success' ||
322
+ (needs.eval.result == 'skipped' && contains(format(',{0},', inputs.idle-lanes), ',eval,'))) &&
280
323
  needs.gate.result == 'success'
281
324
  permissions:
282
325
  actions: read
@@ -291,5 +334,5 @@ jobs:
291
334
  runtime-repository: ${{ inputs.runtime-repository }}
292
335
  runtime-ref: ${{ inputs.runtime-ref }}
293
336
  job-results-json: >-
294
- {"ci":"${{ needs.ci.result }}","test":"${{ needs.test.result }}","security":"${{ needs.security.result }}","codeql":"not-configured","eval":"${{ needs.eval.result }}","gate":"${{ needs.gate.result }}"}
337
+ {"ci":"${{ needs.ci.result }}","test":"${{ needs.test.result }}","security":"${{ (needs.security.result == 'skipped' && fromJSON(inputs.security-plan || '{}').idle == 'true' && 'success') || needs.security.result }}","codeql":"not-configured","eval":"${{ (needs.eval.result == 'skipped' && contains(format(',{0},', inputs.idle-lanes), ',eval,') && 'success') || needs.eval.result }}","gate":"${{ needs.gate.result }}"}
295
338
  required-job-names-json: '["Validation / Gate"]'
@@ -90,6 +90,34 @@ on:
90
90
  required: false
91
91
  type: string
92
92
  default: '1'
93
+ idle-lanes:
94
+ description: >-
95
+ Comma-separated lanes the caller's planner found no entrypoint for
96
+ (integration, e2e, smoke, performance, eval). Their jobs are
97
+ skipped instead of allocating a runner for a no-op. Empty runs every
98
+ lane.
99
+ required: false
100
+ type: string
101
+ default: ''
102
+ security-plan:
103
+ description: >-
104
+ JSON Security Profile outputs from the caller's planning job. When
105
+ set, the Security workflow skips its Profile job and reads the plan,
106
+ and a plan marked idle (no dependency audit and no dependency
107
+ review to run) skips the workflow. Empty profiles inside the
108
+ workflow as before.
109
+ required: false
110
+ type: string
111
+ default: ''
112
+ codeql-plan:
113
+ description: >-
114
+ JSON CodeQL Detect outputs from the caller's planning job. When set,
115
+ the CodeQL workflow skips its Detect job and reads the plan, and a
116
+ plan marked idle (CodeQL unavailable) skips the workflow. Empty
117
+ detects inside the workflow as before.
118
+ required: false
119
+ type: string
120
+ default: ''
93
121
  artifact-prefix:
94
122
  description: Prefix for task receipts; use a unique value when calling this workflow more than once.
95
123
  required: false
@@ -181,12 +209,17 @@ jobs:
181
209
  integration-total-shards: ${{ inputs.integration-total-shards }}
182
210
  e2e-shard-list: ${{ inputs.e2e-shard-list }}
183
211
  e2e-total-shards: ${{ inputs.e2e-total-shards }}
212
+ idle-lanes: ${{ inputs.idle-lanes }}
184
213
  secrets:
185
214
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
186
215
 
187
216
  security:
188
217
  name: Security
189
- if: vars.CI_BILLING_PAUSED != 'true' && inputs.mode == 'audit'
218
+ # A plan marked idle has no audit or review to run; the gate counts that
219
+ # planned skip as the success the empty workflow used to report.
220
+ if: >-
221
+ vars.CI_BILLING_PAUSED != 'true' && inputs.mode == 'audit' &&
222
+ fromJSON(inputs.security-plan || '{}').idle != 'true'
190
223
  permissions:
191
224
  contents: read
192
225
  uses: ./.github/workflows/security.yml
@@ -194,12 +227,15 @@ jobs:
194
227
  runtime-repository: ${{ inputs.runtime-repository }}
195
228
  runtime-ref: ${{ inputs.runtime-ref }}
196
229
  runner: ${{ inputs.security-runner }}
230
+ plan: ${{ inputs.security-plan }}
197
231
 
198
232
  codeql:
199
233
  name: CodeQL
200
234
  # Release-please pull requests also analyze with CodeQL so repository
201
235
  # rulesets requiring code scanning results never deadlock the release.
202
- if: vars.CI_BILLING_PAUSED != 'true' && (inputs.mode == 'audit' || inputs.mode == 'release')
236
+ if: >-
237
+ vars.CI_BILLING_PAUSED != 'true' && (inputs.mode == 'audit' || inputs.mode == 'release') &&
238
+ fromJSON(inputs.codeql-plan || '{}').idle != 'true'
203
239
  permissions:
204
240
  actions: read
205
241
  contents: read
@@ -213,6 +249,7 @@ jobs:
213
249
  rust-shards: ${{ inputs.rust-shards }}
214
250
  rust-threads: ${{ inputs.rust-threads }}
215
251
  rust-max-parallel: ${{ inputs.rust-max-parallel }}
252
+ plan: ${{ inputs.codeql-plan }}
216
253
 
217
254
  eval:
218
255
  name: Eval
@@ -228,6 +265,7 @@ jobs:
228
265
  runtime-ref: ${{ inputs.runtime-ref }}
229
266
  runner: ${{ inputs.eval-runner }}
230
267
  artifact-prefix: ${{ inputs.artifact-prefix }}
268
+ idle-lanes: ${{ inputs.idle-lanes }}
231
269
 
232
270
  # Aggregate check: "Validation / Gate" (caller job name + this job name).
233
271
  # Always evaluates; only jobs required for the mode must have succeeded.
@@ -247,9 +285,16 @@ jobs:
247
285
  FOUNDRY_MODE: ${{ inputs.mode }}
248
286
  FOUNDRY_CI: ${{ needs.ci.result }}
249
287
  FOUNDRY_TEST: ${{ needs.test.result }}
250
- FOUNDRY_SECURITY: ${{ needs.security.result }}
251
- FOUNDRY_CODEQL: ${{ needs.codeql.result }}
252
- FOUNDRY_EVAL: ${{ needs.eval.result }}
288
+ # A Security or CodeQL workflow the caller planned as idle never
289
+ # starts and reports skipped. Count only that planned skip as the
290
+ # success its empty run reported before.
291
+ FOUNDRY_SECURITY: ${{ (needs.security.result == 'skipped' && fromJSON(inputs.security-plan || '{}').idle == 'true' && 'success') || needs.security.result }}
292
+ FOUNDRY_CODEQL: ${{ (needs.codeql.result == 'skipped' && fromJSON(inputs.codeql-plan || '{}').idle == 'true' && 'success') || needs.codeql.result }}
293
+ # An eval lane the caller planned as idle (no eval script, no
294
+ # eval_command, or eval: false) never starts, so the whole Eval
295
+ # workflow reports skipped. Count that exactly like the no-op success
296
+ # it reported before; any other skip still fails the gate.
297
+ FOUNDRY_EVAL: ${{ (needs.eval.result == 'skipped' && contains(format(',{0},', inputs.idle-lanes), ',eval,') && 'success') || needs.eval.result }}
253
298
  FOUNDRY_SOURCE_QUALIFICATION: ${{ needs.qualification.outputs.reuse }}
254
299
  FOUNDRY_QUALIFICATION: ${{ needs.qualification.result }}
255
300
  steps:
@@ -292,8 +337,13 @@ jobs:
292
337
  inputs.release-qualification-receipts == true &&
293
338
  github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main' &&
294
339
  needs.ci.result == 'success' && needs.test.result == 'success' &&
295
- needs.security.result == 'success' && needs.codeql.result == 'success' &&
296
- needs.eval.result == 'success' && needs.gate.result == 'success'
340
+ (needs.security.result == 'success' ||
341
+ (needs.security.result == 'skipped' && fromJSON(inputs.security-plan || '{}').idle == 'true')) &&
342
+ (needs.codeql.result == 'success' ||
343
+ (needs.codeql.result == 'skipped' && fromJSON(inputs.codeql-plan || '{}').idle == 'true')) &&
344
+ (needs.eval.result == 'success' ||
345
+ (needs.eval.result == 'skipped' && contains(format(',{0},', inputs.idle-lanes), ',eval,'))) &&
346
+ needs.gate.result == 'success'
297
347
  permissions:
298
348
  actions: read
299
349
  contents: read
@@ -307,5 +357,5 @@ jobs:
307
357
  runtime-repository: ${{ inputs.runtime-repository }}
308
358
  runtime-ref: ${{ inputs.runtime-ref }}
309
359
  job-results-json: >-
310
- {"ci":"${{ needs.ci.result }}","test":"${{ needs.test.result }}","security":"${{ needs.security.result }}","codeql":"${{ needs.codeql.result }}","eval":"${{ needs.eval.result }}","gate":"${{ needs.gate.result }}"}
360
+ {"ci":"${{ needs.ci.result }}","test":"${{ needs.test.result }}","security":"${{ (needs.security.result == 'skipped' && fromJSON(inputs.security-plan || '{}').idle == 'true' && 'success') || needs.security.result }}","codeql":"${{ (needs.codeql.result == 'skipped' && fromJSON(inputs.codeql-plan || '{}').idle == 'true' && 'success') || needs.codeql.result }}","eval":"${{ (needs.eval.result == 'skipped' && contains(format(',{0},', inputs.idle-lanes), ',eval,') && 'success') || needs.eval.result }}","gate":"${{ needs.gate.result }}"}
311
361
  required-job-names-json: '["Validation / Gate"]'
@@ -22,18 +22,34 @@ concurrency:
22
22
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
23
23
 
24
24
  jobs:
25
+ # One planning job decides everything the orchestrator needs before any
26
+ # lane starts: the validation tier (with the docs-only downgrade), the idle
27
+ # lanes, the Security Profile, and the CodeQL language detection. Each used
28
+ # to queue its own runner in sequence ahead of the real work; on a saturated
29
+ # runner pool every extra hop costs minutes of queueing for seconds of work.
25
30
  mode:
26
- name: Mode
27
- # The mode classifier is only needed for ready pull requests. Main pushes
28
- # use the dedicated default-branch CodeQL lane below; draft updates do not
31
+ name: Plan
32
+ # The planner is only needed for ready pull requests. Main pushes use the
33
+ # dedicated default-branch CodeQL lane below; draft updates do not
29
34
  # allocate a validation runner.
30
35
  if: vars.CI_BILLING_PAUSED != 'true' && github.event_name == 'pull_request' && github.event.pull_request.draft == false
31
36
  runs-on: ubuntu-slim
32
37
  timeout-minutes: 10
38
+ # The scopes of the Mode, Security Profile, and CodeQL Detect jobs this
39
+ # job replaces: the CodeQL plan reads Code Security status, the pull
40
+ # request's changed files, and the repository rulesets.
33
41
  permissions:
42
+ actions: read
34
43
  contents: read
44
+ packages: read
45
+ security-events: write
35
46
  outputs:
36
47
  mode: ${{ steps.classify.outputs.mode }}
48
+ idle-lanes: ${{ steps.lanes.outputs.idle_lanes }}
49
+ # Empty unless the tier runs Security or CodeQL and planning succeeded;
50
+ # an empty plan makes each workflow profile and detect for itself.
51
+ security-plan: ${{ steps.plan.outputs.security_plan }}
52
+ codeql-plan: ${{ steps.plan.outputs.codeql_plan }}
37
53
  steps:
38
54
  # The runtime classifies policy from the consumer repository's config.
39
55
  # Keep this checkout before the isolated runtime checkout below.
@@ -61,6 +77,89 @@ jobs:
61
77
  FOUNDRY_BASE_REF: ${{ github.event.pull_request.base.ref }}
62
78
  FOUNDRY_HEAD_REF: ${{ github.event.pull_request.head.ref }}
63
79
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" validation mode >> "$GITHUB_OUTPUT"
80
+ # Lanes without an entrypoint (no package script, configured command,
81
+ # or test files) are skipped by the orchestrator rather than started on
82
+ # a runner only to report a no-op. Detection errors keep the lane.
83
+ - name: Plan lanes
84
+ id: lanes
85
+ run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" validation lanes >> "$GITHUB_OUTPUT"
86
+ # The steps below replace the Security Profile and CodeQL Detect jobs
87
+ # on the tiers that run those workflows. They mirror those jobs' steps
88
+ # and fail open: if any of them fails, the plans stay empty and each
89
+ # workflow runs its own Profile or Detect job, which reports the error.
90
+ - name: Detect Code Security
91
+ id: security
92
+ if: steps.classify.outputs.mode == 'audit' || steps.classify.outputs.mode == 'release'
93
+ continue-on-error: true
94
+ env:
95
+ GH_TOKEN: ${{ github.token }}
96
+ REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }}
97
+ run: |
98
+ status="disabled"
99
+ if [ "$REPOSITORY_VISIBILITY" = "public" ]; then
100
+ status="enabled"
101
+ else
102
+ status="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.security_and_analysis.code_security.status // "disabled"' 2>/dev/null || printf 'disabled')"
103
+ fi
104
+ printf 'status=%s\n' "$status" >> "$GITHUB_OUTPUT"
105
+ - name: List pull request files
106
+ id: changed
107
+ if: steps.classify.outputs.mode == 'audit' || steps.classify.outputs.mode == 'release'
108
+ continue-on-error: true
109
+ env:
110
+ GH_TOKEN: ${{ github.token }}
111
+ PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
112
+ run: |
113
+ set -euo pipefail
114
+ # Newline-separated changed paths for the runtime's change
115
+ # detection. A failed listing leaves the file absent and the
116
+ # runtime fails open toward analyzing every language and shard.
117
+ gh api "repos/$GITHUB_REPOSITORY/pulls/$PULL_REQUEST_NUMBER/files?per_page=100" \
118
+ --paginate --jq '.[].filename' > "$RUNNER_TEMP/changed-files.txt" || true
119
+ echo "file=$RUNNER_TEMP/changed-files.txt" >> "$GITHUB_OUTPUT"
120
+ - name: Detect code scanning merge gate
121
+ id: gate
122
+ if: steps.classify.outputs.mode == 'audit' || steps.classify.outputs.mode == 'release'
123
+ continue-on-error: true
124
+ env:
125
+ GH_TOKEN: ${{ github.token }}
126
+ run: |
127
+ set -euo pipefail
128
+ # The code-scanning merge gate waits for results in every tracked
129
+ # category, so change detection must never skip an upload while it
130
+ # is active. Any ruleset API failure also disables change
131
+ # detection: full analysis is the safe default.
132
+ gated=false
133
+ for id in $(gh api "repos/$GITHUB_REPOSITORY/rulesets" --paginate \
134
+ -q '.[] | select(.enforcement == "active") | .id' 2>/dev/null); do
135
+ if gh api "repos/$GITHUB_REPOSITORY/rulesets/$id" \
136
+ -q 'any(.rules[]?; .type == "code_scanning")' 2>/dev/null | grep -q true; then
137
+ gated=true
138
+ break
139
+ fi
140
+ done
141
+ echo "gated=$gated" >> "$GITHUB_OUTPUT"
142
+ - name: Plan security and CodeQL
143
+ id: plan
144
+ if: >-
145
+ (steps.classify.outputs.mode == 'audit' || steps.classify.outputs.mode == 'release') &&
146
+ steps.security.outcome == 'success' && steps.changed.outcome == 'success' &&
147
+ steps.gate.outcome == 'success'
148
+ continue-on-error: true
149
+ env:
150
+ REPO_FOUNDRY_PROFILE: ${{ vars.REPO_FOUNDRY_PROFILE }}
151
+ REPO_FOUNDRY_LANGUAGES: ${{ vars.REPO_FOUNDRY_LANGUAGES }}
152
+ REPO_FOUNDRY_FEATURES: ${{ vars.REPO_FOUNDRY_FEATURES }}
153
+ REPO_FOUNDRY_PACKAGE_MANAGER: ${{ vars.REPO_FOUNDRY_PACKAGE_MANAGER }}
154
+ REPO_FOUNDRY_PRIVATE: ${{ github.event.repository.private }}
155
+ REPO_FOUNDRY_VISIBILITY: ${{ github.event.repository.visibility }}
156
+ REPO_FOUNDRY_CODE_SECURITY: ${{ steps.security.outputs.status }}
157
+ FOUNDRY_EVENT_NAME: ${{ github.event_name }}
158
+ FOUNDRY_CODEQL_CHANGED_FILES_FILE: ${{ steps.changed.outputs.file }}
159
+ FOUNDRY_CODEQL_MERGE_GATE: ${{ steps.gate.outputs.gated }}
160
+ # The runtime computes both plans before writing either, so a failure
161
+ # never hands the orchestrator half a plan.
162
+ run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" validation plan >> "$GITHUB_OUTPUT"
64
163
 
65
164
  # The stable aggregate check name is the caller job name plus the
66
165
  # orchestrator gate job name: "Validation / Gate". Keep both names fixed.
@@ -107,6 +206,9 @@ jobs:
107
206
  integration-total-shards: '1'
108
207
  e2e-shard-list: '1'
109
208
  e2e-total-shards: '1'
209
+ idle-lanes: ${{ needs.mode.outputs.idle-lanes }}
210
+ security-plan: ${{ needs.mode.outputs.security-plan }}
211
+ codeql-plan: ${{ needs.mode.outputs.codeql-plan }}
110
212
  secrets:
111
213
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
112
214
  NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.53.7](https://github.com/0xPlayerOne/code-foundry/compare/v1.53.6...v1.53.7) (2026-10-31)
4
+
5
+
6
+ ### Performance
7
+
8
+ * **validation:** fold Security Profile and CodeQL Detect into the plan job ([#827](https://github.com/0xPlayerOne/code-foundry/issues/827)) ([75ca83c](https://github.com/0xPlayerOne/code-foundry/commit/75ca83c042f01e23a1f3c62b26f92e1bc6641a80))
9
+
10
+ ## [1.53.6](https://github.com/0xPlayerOne/code-foundry/compare/v1.53.5...v1.53.6) (2026-10-31)
11
+
12
+
13
+ ### Performance
14
+
15
+ * **validation:** skip lane jobs that have nothing to run ([#825](https://github.com/0xPlayerOne/code-foundry/issues/825)) ([4d04f62](https://github.com/0xPlayerOne/code-foundry/commit/4d04f627bfac572bc056b5a5f448b66c17500c00))
16
+
3
17
  ## [1.53.5](https://github.com/0xPlayerOne/code-foundry/compare/v1.53.4...v1.53.5) (2026-10-31)
4
18
 
5
19
 
@@ -136,23 +150,9 @@
136
150
 
137
151
  * handle empty security audit matrices ([#785](https://github.com/0xPlayerOne/code-foundry/issues/785)) ([02f0565](https://github.com/0xPlayerOne/code-foundry/commit/02f05652e68267a2aa2df5d5af8f13df9ba1e3aa))
138
152
 
139
- ## [1.49.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.48.9...v1.49.0) (2026-10-27)
140
-
141
-
142
- ### Features
143
-
144
- * support isolated integration test shards ([#783](https://github.com/0xPlayerOne/code-foundry/issues/783)) ([ad1123f](https://github.com/0xPlayerOne/code-foundry/commit/ad1123ff3bb9654bfbdaef4219490cca3e7e3d20))
145
-
146
- ## [1.48.9](https://github.com/0xPlayerOne/code-foundry/compare/v1.48.8...v1.48.9) (2026-10-26)
147
-
148
-
149
- ### Bug Fixes
150
-
151
- * **release:** preserve fallback stdin and paginate existing releases ([#781](https://github.com/0xPlayerOne/code-foundry/issues/781)) ([ede4d2c](https://github.com/0xPlayerOne/code-foundry/commit/ede4d2c7e3bc7d1174580c9a0bc83bdaa160c2da))
152
-
153
153
  <!-- code-foundry: packaged changelog -->
154
154
 
155
155
  ---
156
156
 
157
- This packaged changelog lists the 20 most recent releases; 276 older releases are omitted to keep the package small.
157
+ This packaged changelog lists the 20 most recent releases; 278 older releases are omitted to keep the package small.
158
158
  See the [full changelog](https://github.com/0xPlayerOne/code-foundry/blob/main/CHANGELOG.md) for the complete history.
@@ -409,7 +409,7 @@ duplicates, empty scopes, and more than eight shards are rejected. Use
409
409
 
410
410
  Every lane rendered from this configuration (pull-request validation, the
411
411
  default-branch scan, the scheduled audit, and merge queues) receives the same
412
- shard list, and the CodeQL Detect job fails closed when caller workflows drift
412
+ shard list, and CodeQL detection (the caller's Plan job or the CodeQL Detect job) fails closed when caller workflows drift
413
413
  apart. Keep them aligned: each shard uploads a SARIF category keyed by a hash
414
414
  of the shard scope, GitHub code scanning baselines every category it has seen
415
415
  on the default branch, and the code-scanning merge gate waits for results in
package/docs/WORKFLOWS.md CHANGED
@@ -310,6 +310,29 @@ repositories by failing closed unless every generated check succeeds. An
310
310
  empty analysis matrix while analysis is enabled fails the build instead of
311
311
  silently skipping every analyzer.
312
312
 
313
+ The caller's `Plan` job (job id `mode`, formerly named `Mode`) classifies the
314
+ validation tier, including the docs-only downgrade, and also plans lanes: it runs the same applicability check
315
+ each lane's Detect step runs and passes the integration, E2E, smoke,
316
+ performance, and eval lanes that have no package script, configured command,
317
+ or test files to the orchestrator as `idle-lanes`. Those jobs are skipped
318
+ instead of queuing a runner only to report a no-op. Unit always keeps its job,
319
+ and a lane whose detection errors keeps its job so the error surfaces there.
320
+ The gate counts a planned idle Eval workflow (which reports `skipped`) as the
321
+ no-op success it reported before; any other skip, and every lane with work,
322
+ is still required. Callers that pass no `idle-lanes` run every lane as before.
323
+
324
+ On the tiers that run Security or CodeQL, the same `Plan` job also runs the
325
+ Security Profile and CodeQL language detection (Code Security status, changed
326
+ files, code-scanning merge gate) and passes their outputs to the orchestrator
327
+ as `security-plan` and `codeql-plan`. The Security workflow then skips its
328
+ `Profile` job and the CodeQL workflow skips its `Detect` job, so audits and
329
+ analyzers start one runner hop earlier. A plan marked idle (no audit or review
330
+ to run, or CodeQL unavailable) skips that workflow, and the gate counts only
331
+ that planned skip as success. Planning fails open: if any planning step fails,
332
+ the plans stay empty and each workflow profiles and detects for itself, as it
333
+ does for callers that pass no plans (the scheduled audit and default-branch
334
+ CodeQL lanes).
335
+
313
336
  ## Security behavior
314
337
 
315
338
  CodeQL is a separate workflow using GitHub's official actions. The default
@@ -318,9 +341,11 @@ Security availability for private repositories. When CodeQL is unavailable,
318
341
  set `codeql: false` and sync: the generated caller selects an orchestrator that
319
342
  omits CodeQL entirely, so pull requests do not show a skipped analyzer.
320
343
  Dependency Review follows the same capability policy through
321
- `dependency_review: auto`, but executes as a conditional step inside the
322
- Security profile job and never registers a separate skipped pull-request
323
- check. JavaScript, Python, and Rust audits remain available without Advanced
344
+ `dependency_review: auto`, but executes as a conditional step and never
345
+ registers a separate skipped pull-request check: inside the Security profile
346
+ job, or, when the caller planned the profile, as the last step of the first
347
+ dependency audit job. A run with a review but no audit stays unplanned, so the
348
+ profile job still runs the review. JavaScript, Python, and Rust audits remain available without Advanced
324
349
  Security. If GitHub default setup is enabled, disable its generated CodeQL
325
350
  workflow to avoid duplicate analysis.
326
351
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.53.5",
3
+ "version": "1.53.7",
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": {
@@ -433,7 +433,10 @@ function synchronize(options) {
433
433
  if (!entry.endsWith('.yml') || standard.has(`.github/workflows/${entry}`)) continue
434
434
  const destination = join(workflowsDir, entry)
435
435
  const original = readFileSync(destination, 'utf8')
436
- const updated = original.replace(runtimePin, `$1@${runtimeRef}`)
436
+ const updated = alignRuntimeRefInputs(
437
+ original.replace(runtimePin, `$1@${runtimeRef}`),
438
+ runtimeRepository
439
+ )
437
440
  if (updated !== original) {
438
441
  changed.push(`.github/workflows/${entry}`)
439
442
  if (dryRun)
@@ -1099,6 +1102,32 @@ export function renderDevChannel(config) {
1099
1102
  )
1100
1103
  }
1101
1104
 
1105
+ /**
1106
+ * Keep each preserved job's `runtime-ref:` input on the same plain semver
1107
+ * version as its `uses:` pin of the runtime repository. The reusable
1108
+ * workflows check out the runtime at `runtime-ref`, so a pin refreshed without
1109
+ * its input would run the new workflow against the old runtime. Only plain
1110
+ * semver values move: expressions, branch names, and SHAs are intentional.
1111
+ * @param {string} content @param {string} runtimeRepository
1112
+ */
1113
+ export function alignRuntimeRefInputs(content, runtimeRepository) {
1114
+ const pin = new RegExp(
1115
+ `uses:\\s*${escapeRegExp(runtimeRepository)}/\\.github/(?:workflows|actions)/[^\\s@]+@(v\\d+\\.\\d+\\.\\d+)\\s*$`,
1116
+ 'm'
1117
+ )
1118
+ return content
1119
+ .split(/(?=^ {2}[A-Za-z0-9_-]+:[ \t]*\r?$)/m)
1120
+ .map((block) => {
1121
+ const version = block.match(pin)?.[1]
1122
+ if (!version) return block
1123
+ return block.replace(
1124
+ /^([ \t]+runtime-ref:[ \t]*)(['"]?)v\d+\.\d+\.\d+\2([ \t]*)$/gm,
1125
+ (_, key, quote, trailing) => `${key}${quote}${version}${quote}${trailing}`
1126
+ )
1127
+ })
1128
+ .join('')
1129
+ }
1130
+
1102
1131
  /**
1103
1132
  * Pin consumer runtime refs without retaining the self-CI expression used by
1104
1133
  * the Code Foundry repository. Self callers keep their event-relative refs.
@@ -354,6 +354,13 @@ function downgradeDocsOnlyPullRequest(mode) {
354
354
  }
355
355
  }
356
356
 
357
+ /**
358
+ * Lanes that own a whole runner job and can be skipped when they have no work.
359
+ * Unit stays out: it is the one test lane every tier requires, so it always
360
+ * keeps its job.
361
+ */
362
+ const PLANNED_LANES = ['integration', 'e2e', 'smoke', 'performance', 'eval']
363
+
357
364
  /** @param {string} task */
358
365
  function validation(task) {
359
366
  if (task === 'mode') {
@@ -370,6 +377,60 @@ function validation(task) {
370
377
  )
371
378
  return
372
379
  }
380
+ if (task === 'lanes') {
381
+ // Lanes with no entrypoint would only allocate a runner to report a
382
+ // no-op. The caller's mode job already holds the checkout, so it names
383
+ // them once and the orchestrator skips those jobs. This mirrors each
384
+ // lane's own Detect step (`relevant`) and fails open: a lane whose
385
+ // detection throws keeps its job so the job reports the error itself.
386
+ const idle = PLANNED_LANES.filter((lane) => {
387
+ try {
388
+ return !relevant(lane)
389
+ } catch {
390
+ return false
391
+ }
392
+ })
393
+ writeOutput('idle_lanes', idle.join(','))
394
+ return
395
+ }
396
+ if (task === 'plan') {
397
+ // The caller's planning job already holds the consumer checkout and the
398
+ // runtime, so it also runs the Security Profile and CodeQL Detect steps
399
+ // that would otherwise each queue a runner before any audit or analysis
400
+ // could start. Each plan carries exactly the outputs that job emits, plus
401
+ // `idle` when the workflow would have nothing to run.
402
+ const profile = securityProfile()
403
+ const auditMatrix = JSON.stringify(securityAuditMatrix(profile))
404
+ const dependencyReview =
405
+ (process.env.FOUNDRY_EVENT_NAME ?? '') === 'pull_request' &&
406
+ profile.dependency_review === 'true'
407
+ const securityPlan = {
408
+ ...profile,
409
+ audit_matrix: auditMatrix,
410
+ idle: auditMatrix === '[]' && !dependencyReview ? 'true' : 'false',
411
+ }
412
+ const detection = codeqlDetection()
413
+ const enabled = detection.enabled === 'true'
414
+ const codeqlPlan = {
415
+ ...detection,
416
+ matrix: enabled
417
+ ? JSON.stringify(
418
+ codeqlAnalysisMatrix(JSON.parse(detection.languages), configuredRustShards())
419
+ )
420
+ : '[]',
421
+ code_security: process.env.REPO_FOUNDRY_CODE_SECURITY ?? '',
422
+ idle: enabled ? 'false' : 'true',
423
+ }
424
+ // Dependency Review rides on the first audit job of a planned run. With
425
+ // no audit to ride on, leave Security unplanned so its Profile job runs
426
+ // the review as it always has, rather than adding a job of its own.
427
+ writeOutput(
428
+ 'security_plan',
429
+ auditMatrix === '[]' && dependencyReview ? '' : JSON.stringify(securityPlan)
430
+ )
431
+ writeOutput('codeql_plan', JSON.stringify(codeqlPlan))
432
+ return
433
+ }
373
434
  if (task === 'gate') {
374
435
  const mode = process.env.FOUNDRY_MODE ?? ''
375
436
  const gate = evaluateValidationGate({
@@ -917,24 +978,56 @@ function readFileSafe(file) {
917
978
  }
918
979
  }
919
980
 
981
+ /**
982
+ * The Security workflow's Profile outputs, as the strings the job emits.
983
+ * @returns {Record<string, string>}
984
+ */
985
+ function securityProfile() {
986
+ const python = hasDependencyManifest('python')
987
+ const requirements = capture('git', ['ls-files', '*requirements*.txt'])
988
+ .split('\n')
989
+ .filter(Boolean)
990
+ return {
991
+ javascript: hasDependencyManifest('javascript') ? 'true' : 'false',
992
+ rust: hasDependencyManifest('rust') ? 'true' : 'false',
993
+ python: python ? 'true' : 'false',
994
+ python_requirements: JSON.stringify(
995
+ python ? (requirements.length ? requirements : ['project']) : ['none']
996
+ ),
997
+ dependency_review: featureEnabled('dependency_review') ? 'true' : 'false',
998
+ }
999
+ }
1000
+
1001
+ /**
1002
+ * The dependency audit matrix built from a profile: the same entries the
1003
+ * Security workflow's "Build audit matrix" step emits.
1004
+ * @param {Record<string, string>} profile
1005
+ */
1006
+ function securityAuditMatrix(profile) {
1007
+ /** @type {Array<Record<string, string>>} */
1008
+ const entries = []
1009
+ if (profile.javascript === 'true') entries.push({ display: 'JavaScript', language: 'javascript' })
1010
+ if (profile.rust === 'true') entries.push({ display: 'Rust', language: 'rust' })
1011
+ if (profile.python === 'true') {
1012
+ /** @type {unknown} */
1013
+ let requirements = []
1014
+ try {
1015
+ requirements = JSON.parse(profile.python_requirements || '[]')
1016
+ } catch {
1017
+ requirements = []
1018
+ }
1019
+ for (const requirement of Array.isArray(requirements) ? requirements : []) {
1020
+ if (!requirement || requirement === 'none') continue
1021
+ entries.push({ display: `Python / ${requirement}`, language: 'python', requirement })
1022
+ }
1023
+ }
1024
+ return entries
1025
+ }
1026
+
920
1027
  /** @param {string} task @param {string} ecosystem */
921
1028
  function security(task, ecosystem) {
922
1029
  if (task === 'profile') {
923
- writeOutput('javascript', hasDependencyManifest('javascript') ? 'true' : 'false')
924
- writeOutput('rust', hasDependencyManifest('rust') ? 'true' : 'false')
925
- writeOutput('python', hasDependencyManifest('python') ? 'true' : 'false')
926
- const requirements = capture('git', ['ls-files', '*requirements*.txt'])
927
- .split('\n')
928
- .filter(Boolean)
929
- writeOutput(
930
- 'python_requirements',
931
- hasDependencyManifest('python')
932
- ? requirements.length
933
- ? requirements
934
- : ['project']
935
- : ['none']
936
- )
937
- writeOutput('dependency_review', featureEnabled('dependency_review') ? 'true' : 'false')
1030
+ for (const [key, value] of Object.entries(securityProfile())) writeOutput(key, value)
938
1031
  return
939
1032
  }
940
1033
  if (task === 'should_run') {
@@ -1059,18 +1152,28 @@ function assertRustShardsAligned() {
1059
1152
  }
1060
1153
 
1061
1154
  function codeql() {
1155
+ for (const [key, value] of Object.entries(codeqlDetection())) writeOutput(key, value)
1156
+ }
1157
+
1158
+ /**
1159
+ * The CodeQL workflow's "Detect languages" outputs, as the strings the step
1160
+ * emits, in the order it emits them.
1161
+ * @returns {Record<string, string>}
1162
+ */
1163
+ function codeqlDetection() {
1062
1164
  const enabled =
1063
1165
  featureEnabled('codeql') &&
1064
1166
  (isPublicRepository() || process.env.REPO_FOUNDRY_CODE_SECURITY === 'enabled')
1065
- writeOutput('enabled', enabled ? 'true' : 'false')
1167
+ /** @type {Record<string, string>} */
1168
+ const outputs = { enabled: enabled ? 'true' : 'false' }
1066
1169
  if (!enabled) {
1067
- writeOutput('languages', [])
1170
+ outputs.languages = '[]'
1068
1171
  for (const language of ['actions', 'javascript', 'python', 'rust']) {
1069
- writeOutput(`${language}_available`, 'false')
1070
- writeOutput(`${language}_changed`, 'false')
1071
- writeOutput(`${language}_build_mode`, 'none')
1172
+ outputs[`${language}_available`] = 'false'
1173
+ outputs[`${language}_changed`] = 'false'
1174
+ outputs[`${language}_build_mode`] = 'none'
1072
1175
  }
1073
- return
1176
+ return outputs
1074
1177
  }
1075
1178
  // Detect lane drift before any analysis uploads a category the other lanes
1076
1179
  // never produce; a stall surfaces only later as a blocked merge gate.
@@ -1106,14 +1209,55 @@ function codeql() {
1106
1209
  rustEntry.shards = shards
1107
1210
  rustEntry.changed = shards.some((entry) => entry.changed)
1108
1211
  }
1109
- writeOutput('languages', languagesJson)
1212
+ outputs.languages = JSON.stringify(languagesJson)
1110
1213
  for (const language of ['actions', 'javascript-typescript', 'python', 'rust']) {
1111
1214
  const entry = languagesJson.find((item) => item.language === language)
1112
1215
  const prefix = language === 'javascript-typescript' ? 'javascript' : language
1113
- writeOutput(`${prefix}_available`, entry ? 'true' : 'false')
1114
- writeOutput(`${prefix}_changed`, entry?.changed ? 'true' : 'false')
1115
- writeOutput(`${prefix}_build_mode`, entry?.['build-mode'] ?? 'none')
1216
+ outputs[`${prefix}_available`] = entry ? 'true' : 'false'
1217
+ outputs[`${prefix}_changed`] = entry?.changed ? 'true' : 'false'
1218
+ outputs[`${prefix}_build_mode`] = entry?.['build-mode'] ?? 'none'
1219
+ }
1220
+ return outputs
1221
+ }
1222
+
1223
+ /**
1224
+ * The CodeQL analysis matrix: the same entries the CodeQL workflow's "Build
1225
+ * analysis matrix" step emits from detection. SARIF categories are the
1226
+ * code-scanning baseline keys and must stay byte-identical to that step's.
1227
+ * @param {CodeqlLanguageEntry[]} detected @param {string[]} shards
1228
+ */
1229
+ function codeqlAnalysisMatrix(detected, shards) {
1230
+ /** @type {Record<string, string>} */
1231
+ const display = { actions: 'Actions', 'javascript-typescript': 'TypeScript', python: 'Python' }
1232
+ /** @type {Array<Record<string, string | boolean>>} */
1233
+ const matrix = detected
1234
+ .filter((entry) => entry.language !== 'rust')
1235
+ .map((entry) => ({
1236
+ display: display[entry.language] || entry.language,
1237
+ language: entry.language,
1238
+ category: `/language:${entry.language}`,
1239
+ build_mode: entry['build-mode'] || 'none',
1240
+ changed: entry.changed === true,
1241
+ }))
1242
+ const rust = detected.find((entry) => entry.language === 'rust')
1243
+ const rustShards =
1244
+ rust && Array.isArray(rust.shards) && rust.shards.length
1245
+ ? rust.shards
1246
+ : rust
1247
+ ? shards.map((shard) => ({ shard: String(shard), changed: rust.changed === true }))
1248
+ : []
1249
+ for (const entry of rustShards) {
1250
+ matrix.push({
1251
+ display: entry.shard === 'all' ? 'Rust' : `Rust (${entry.shard})`,
1252
+ language: 'rust',
1253
+ category: '/language:rust',
1254
+ build_mode: 'none',
1255
+ changed: entry.changed === true,
1256
+ shard: String(entry.shard),
1257
+ })
1116
1258
  }
1259
+ if (!matrix.length) throw new Error('CodeQL analysis matrix is empty while analysis is enabled.')
1260
+ return matrix
1117
1261
  }
1118
1262
 
1119
1263
  /**