code-foundry 1.53.6 → 1.53.8

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
@@ -554,56 +554,77 @@ jobs:
554
554
  # A ready head already receives synchronize validation. Preserve
555
555
  # its live/completed owner instead of cancelling and reallocating
556
556
  # CI every time another queued main release run prepares it.
557
- runs=$(GH_TOKEN="$VALIDATION_TOKEN" gh api \
558
- "repos/$GITHUB_REPOSITORY/actions/runs" --method GET \
559
- -f event=pull_request -f head_sha="$PR_HEAD" -f per_page=100)
560
- validation=$(jq -er --arg head "$PR_HEAD" --arg branch "$PR_BRANCH" \
561
- --arg repo "$GITHUB_REPOSITORY" '
562
- def active: .status == "queued" or .status == "in_progress" or
563
- .status == "waiting" or .status == "pending" or .status == "requested";
564
- def valid_run:
565
- type == "object" and
566
- (.id | type == "number" and . > 0 and floor == .) and
567
- (.run_attempt | type == "number" and . >= 1 and floor == .) and
568
- (.head_sha | type == "string" and test("^[a-f0-9]{40}$")) and
569
- (.head_branch | type == "string" and length > 0) and
570
- (.event | type == "string" and length > 0) and
571
- (.repository | type == "object") and
572
- (.repository.full_name | type == "string" and length > 0) and
573
- (.path | type == "string" and length > 0) and
574
- ((active and has("conclusion") and .conclusion == null) or
575
- (.status == "completed" and
576
- (.conclusion == "success" or .conclusion == "failure" or
577
- .conclusion == "timed_out" or .conclusion == "action_required" or
578
- .conclusion == "cancelled" or .conclusion == "skipped" or
579
- .conclusion == "neutral")));
580
- if (.workflow_runs | type) != "array" or
581
- (.total_count | type) != "number" then
582
- error("Invalid validation owner response")
583
- elif .total_count != (.workflow_runs | length) then
584
- error("Incomplete validation owner response")
585
- elif any(.workflow_runs[]; valid_run | not) then
586
- error("Invalid validation run identity or state")
587
- else
588
- [.workflow_runs[] | (.path | split("@")[0]) as $workflow | select(
589
- .head_sha == $head and .head_branch == $branch and
590
- .event == "pull_request" and .repository.full_name == $repo and
591
- ($workflow == ".github/workflows/validation.yml" or
592
- $workflow == ".github/workflows/validation_self-ci.yml")
593
- )] | sort_by(.id) | last |
594
- if . == null then "missing"
595
- elif active or (.status == "completed" and .conclusion == "success") then "owned"
596
- elif .status == "completed" and
597
- (.conclusion == "failure" or .conclusion == "timed_out" or
598
- .conclusion == "action_required") then "failed"
599
- elif .status == "completed" and
600
- (.conclusion == "cancelled" or .conclusion == "skipped" or
601
- .conclusion == "neutral") then "missing"
602
- else error("Unconfirmed validation owner state") end
603
- end' <<< "$runs")
604
- fresh_state=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" \
605
- --json isDraft,headRefOid,headRefName \
606
- --jq '[.isDraft, .headRefOid, .headRefName] | @tsv')
557
+ #
558
+ # GitHub registers the synchronize run for a head this job (or
559
+ # Release Please) just pushed a few seconds after the push. An
560
+ # absent owner is therefore polled for a bounded window before the
561
+ # canonical transition, so the transition does not cancel that
562
+ # run. A cancelled, skipped or neutral owner is already terminal
563
+ # and transitions at once; after the window an absent owner
564
+ # still gets the explicit transition, and a failed one still fails.
565
+ owner_poll=1
566
+ while :; do
567
+ runs=$(GH_TOKEN="$VALIDATION_TOKEN" gh api \
568
+ "repos/$GITHUB_REPOSITORY/actions/runs" --method GET \
569
+ -f event=pull_request -f head_sha="$PR_HEAD" -f per_page=100)
570
+ validation=$(jq -er --arg head "$PR_HEAD" --arg branch "$PR_BRANCH" \
571
+ --arg repo "$GITHUB_REPOSITORY" '
572
+ def active: .status == "queued" or .status == "in_progress" or
573
+ .status == "waiting" or .status == "pending" or .status == "requested";
574
+ def valid_run:
575
+ type == "object" and
576
+ (.id | type == "number" and . > 0 and floor == .) and
577
+ (.run_attempt | type == "number" and . >= 1 and floor == .) and
578
+ (.head_sha | type == "string" and test("^[a-f0-9]{40}$")) and
579
+ (.head_branch | type == "string" and length > 0) and
580
+ (.event | type == "string" and length > 0) and
581
+ (.repository | type == "object") and
582
+ (.repository.full_name | type == "string" and length > 0) and
583
+ (.path | type == "string" and length > 0) and
584
+ ((active and has("conclusion") and .conclusion == null) or
585
+ (.status == "completed" and
586
+ (.conclusion == "success" or .conclusion == "failure" or
587
+ .conclusion == "timed_out" or .conclusion == "action_required" or
588
+ .conclusion == "cancelled" or .conclusion == "skipped" or
589
+ .conclusion == "neutral")));
590
+ if (.workflow_runs | type) != "array" or
591
+ (.total_count | type) != "number" then
592
+ error("Invalid validation owner response")
593
+ elif .total_count != (.workflow_runs | length) then
594
+ error("Incomplete validation owner response")
595
+ elif any(.workflow_runs[]; valid_run | not) then
596
+ error("Invalid validation run identity or state")
597
+ else
598
+ [.workflow_runs[] | (.path | split("@")[0]) as $workflow | select(
599
+ .head_sha == $head and .head_branch == $branch and
600
+ .event == "pull_request" and .repository.full_name == $repo and
601
+ ($workflow == ".github/workflows/validation.yml" or
602
+ $workflow == ".github/workflows/validation_self-ci.yml")
603
+ )] | sort_by(.id) | last |
604
+ if . == null then "absent"
605
+ elif active or (.status == "completed" and .conclusion == "success") then "owned"
606
+ elif .status == "completed" and
607
+ (.conclusion == "failure" or .conclusion == "timed_out" or
608
+ .conclusion == "action_required") then "failed"
609
+ elif .status == "completed" and
610
+ (.conclusion == "cancelled" or .conclusion == "skipped" or
611
+ .conclusion == "neutral") then "missing"
612
+ else error("Unconfirmed validation owner state") end
613
+ end' <<< "$runs")
614
+ fresh_state=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" \
615
+ --json isDraft,headRefOid,headRefName \
616
+ --jq '[.isDraft, .headRefOid, .headRefName] | @tsv')
617
+ if [ "$fresh_state" != "$pr_state" ] || [ "$validation" != absent ] ||
618
+ [ "$owner_poll" -ge 7 ]; then
619
+ break
620
+ fi
621
+ echo "Release PR #$pr has no current-head validation yet (lookup $owner_poll of 7); waiting for its synchronize run to register."
622
+ owner_poll=$((owner_poll + 1))
623
+ sleep 5
624
+ done
625
+ if [ "$validation" = absent ]; then
626
+ validation=missing
627
+ fi
607
628
  if [ "$fresh_state" != "$pr_state" ]; then
608
629
  if [ "$state_attempt" -eq 3 ]; then
609
630
  echo 'Release PR state did not stabilize during validation lookup; refusing readiness mutation.' >&2
@@ -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
@@ -124,6 +124,22 @@ on:
124
124
  required: false
125
125
  type: string
126
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: ''
127
143
  secrets:
128
144
  TURBO_TOKEN:
129
145
  required: false
@@ -1216,7 +1232,9 @@ jobs:
1216
1232
  name: CodeQL
1217
1233
  # Release-please pull requests also analyze with CodeQL so repository
1218
1234
  # rulesets requiring code scanning results never deadlock the release.
1219
- 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'
1220
1238
  permissions:
1221
1239
  actions: read
1222
1240
  contents: read
@@ -1230,6 +1248,7 @@ jobs:
1230
1248
  rust-shards: ${{ inputs.rust-shards }}
1231
1249
  rust-threads: ${{ inputs.rust-threads }}
1232
1250
  rust-max-parallel: ${{ inputs.rust-max-parallel }}
1251
+ plan: ${{ inputs.codeql-plan }}
1233
1252
 
1234
1253
  # Aggregate check: "Validation / Gate" (caller job name + this job name).
1235
1254
  # Always evaluates; only jobs required for the mode must have succeeded.
@@ -1256,8 +1275,10 @@ jobs:
1256
1275
  FOUNDRY_SECURITY: ${{ needs.audit.result }}
1257
1276
  # This orchestrator runs CodeQL only when the consumer's canonical
1258
1277
  # configuration enables it; otherwise the unavailability decision is
1259
- # treated as satisfied without registering a skipped PR check.
1260
- 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' }}
1261
1282
  # Eval runs inside the merged fast-lanes job on the audit tier; a
1262
1283
  # fast-lanes failure on that tier marks the eval category failed even if
1263
1284
  # another merged lane caused it, which fails closed.
@@ -1306,7 +1327,8 @@ jobs:
1306
1327
  needs.fast-lanes.result == 'success' && needs.lint.result == 'success' &&
1307
1328
  needs.type-check.result == 'success' && needs.unit.result == 'success' &&
1308
1329
  needs.e2e.result == 'success' && needs.audit.result == 'success' &&
1309
- (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')) &&
1310
1332
  needs.gate.result == 'success'
1311
1333
  permissions:
1312
1334
  actions: read
@@ -1321,5 +1343,5 @@ jobs:
1321
1343
  runtime-repository: ${{ inputs.runtime-repository }}
1322
1344
  runtime-ref: ${{ inputs.runtime-ref }}
1323
1345
  job-results-json: >-
1324
- {"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 }}"}
1325
1347
  required-job-names-json: '["Validation / Gate"]'
@@ -84,6 +84,23 @@ on:
84
84
  required: false
85
85
  type: string
86
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: ''
87
104
  artifact-prefix:
88
105
  description: Prefix for task receipts; use a unique value when calling this workflow more than once.
89
106
  required: false
@@ -196,7 +213,11 @@ jobs:
196
213
 
197
214
  security:
198
215
  name: Security
199
- 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'
200
221
  permissions:
201
222
  contents: read
202
223
  uses: ./.github/workflows/security.yml
@@ -204,6 +225,7 @@ jobs:
204
225
  runtime-repository: ${{ inputs.runtime-repository }}
205
226
  runtime-ref: ${{ inputs.runtime-ref }}
206
227
  runner: ${{ inputs.security-runner }}
228
+ plan: ${{ inputs.security-plan }}
207
229
 
208
230
  eval:
209
231
  name: Eval
@@ -239,7 +261,10 @@ jobs:
239
261
  FOUNDRY_MODE: ${{ inputs.mode }}
240
262
  FOUNDRY_CI: ${{ needs.ci.result }}
241
263
  FOUNDRY_TEST: ${{ needs.test.result }}
242
- FOUNDRY_SECURITY: ${{ needs.security.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 }}
243
268
  # An eval lane the caller planned as idle (no eval script, no
244
269
  # eval_command, or eval: false) never starts, so the whole Eval
245
270
  # workflow reports skipped. Count that exactly like the no-op success
@@ -291,7 +316,8 @@ jobs:
291
316
  inputs.release-qualification-receipts == true &&
292
317
  github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main' &&
293
318
  needs.ci.result == 'success' && needs.test.result == 'success' &&
294
- needs.security.result == 'success' &&
319
+ (needs.security.result == 'success' ||
320
+ (needs.security.result == 'skipped' && fromJSON(inputs.security-plan || '{}').idle == 'true')) &&
295
321
  (needs.eval.result == 'success' ||
296
322
  (needs.eval.result == 'skipped' && contains(format(',{0},', inputs.idle-lanes), ',eval,'))) &&
297
323
  needs.gate.result == 'success'
@@ -308,5 +334,5 @@ jobs:
308
334
  runtime-repository: ${{ inputs.runtime-repository }}
309
335
  runtime-ref: ${{ inputs.runtime-ref }}
310
336
  job-results-json: >-
311
- {"ci":"${{ needs.ci.result }}","test":"${{ needs.test.result }}","security":"${{ 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 }}"}
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 }}"}
312
338
  required-job-names-json: '["Validation / Gate"]'
@@ -99,6 +99,25 @@ on:
99
99
  required: false
100
100
  type: string
101
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: ''
102
121
  artifact-prefix:
103
122
  description: Prefix for task receipts; use a unique value when calling this workflow more than once.
104
123
  required: false
@@ -196,7 +215,11 @@ jobs:
196
215
 
197
216
  security:
198
217
  name: Security
199
- 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'
200
223
  permissions:
201
224
  contents: read
202
225
  uses: ./.github/workflows/security.yml
@@ -204,12 +227,15 @@ jobs:
204
227
  runtime-repository: ${{ inputs.runtime-repository }}
205
228
  runtime-ref: ${{ inputs.runtime-ref }}
206
229
  runner: ${{ inputs.security-runner }}
230
+ plan: ${{ inputs.security-plan }}
207
231
 
208
232
  codeql:
209
233
  name: CodeQL
210
234
  # Release-please pull requests also analyze with CodeQL so repository
211
235
  # rulesets requiring code scanning results never deadlock the release.
212
- 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'
213
239
  permissions:
214
240
  actions: read
215
241
  contents: read
@@ -223,6 +249,7 @@ jobs:
223
249
  rust-shards: ${{ inputs.rust-shards }}
224
250
  rust-threads: ${{ inputs.rust-threads }}
225
251
  rust-max-parallel: ${{ inputs.rust-max-parallel }}
252
+ plan: ${{ inputs.codeql-plan }}
226
253
 
227
254
  eval:
228
255
  name: Eval
@@ -258,8 +285,11 @@ jobs:
258
285
  FOUNDRY_MODE: ${{ inputs.mode }}
259
286
  FOUNDRY_CI: ${{ needs.ci.result }}
260
287
  FOUNDRY_TEST: ${{ needs.test.result }}
261
- FOUNDRY_SECURITY: ${{ needs.security.result }}
262
- FOUNDRY_CODEQL: ${{ needs.codeql.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 }}
263
293
  # An eval lane the caller planned as idle (no eval script, no
264
294
  # eval_command, or eval: false) never starts, so the whole Eval
265
295
  # workflow reports skipped. Count that exactly like the no-op success
@@ -307,7 +337,10 @@ jobs:
307
337
  inputs.release-qualification-receipts == true &&
308
338
  github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main' &&
309
339
  needs.ci.result == 'success' && needs.test.result == 'success' &&
310
- needs.security.result == 'success' && needs.codeql.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')) &&
311
344
  (needs.eval.result == 'success' ||
312
345
  (needs.eval.result == 'skipped' && contains(format(',{0},', inputs.idle-lanes), ',eval,'))) &&
313
346
  needs.gate.result == 'success'
@@ -324,5 +357,5 @@ jobs:
324
357
  runtime-repository: ${{ inputs.runtime-repository }}
325
358
  runtime-ref: ${{ inputs.runtime-ref }}
326
359
  job-results-json: >-
327
- {"ci":"${{ needs.ci.result }}","test":"${{ needs.test.result }}","security":"${{ needs.security.result }}","codeql":"${{ needs.codeql.result }}","eval":"${{ (needs.eval.result == 'skipped' && contains(format(',{0},', inputs.idle-lanes), ',eval,') && 'success') || 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 }}"}
328
361
  required-job-names-json: '["Validation / Gate"]'
@@ -22,19 +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 }}
37
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 }}
38
53
  steps:
39
54
  # The runtime classifies policy from the consumer repository's config.
40
55
  # Keep this checkout before the isolated runtime checkout below.
@@ -68,6 +83,83 @@ jobs:
68
83
  - name: Plan lanes
69
84
  id: lanes
70
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"
71
163
 
72
164
  # The stable aggregate check name is the caller job name plus the
73
165
  # orchestrator gate job name: "Validation / Gate". Keep both names fixed.
@@ -115,6 +207,8 @@ jobs:
115
207
  e2e-shard-list: '1'
116
208
  e2e-total-shards: '1'
117
209
  idle-lanes: ${{ needs.mode.outputs.idle-lanes }}
210
+ security-plan: ${{ needs.mode.outputs.security-plan }}
211
+ codeql-plan: ${{ needs.mode.outputs.codeql-plan }}
118
212
  secrets:
119
213
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
120
214
  NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.53.8](https://github.com/0xPlayerOne/code-foundry/compare/v1.53.7...v1.53.8) (2026-10-31)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **release:** wait for the synchronize validation run before re-readying ([#829](https://github.com/0xPlayerOne/code-foundry/issues/829)) ([d0abbe2](https://github.com/0xPlayerOne/code-foundry/commit/d0abbe27349635bcc5bb63aafac3ff2e989f960f))
9
+
10
+ ## [1.53.7](https://github.com/0xPlayerOne/code-foundry/compare/v1.53.6...v1.53.7) (2026-10-31)
11
+
12
+
13
+ ### Performance
14
+
15
+ * **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))
16
+
3
17
  ## [1.53.6](https://github.com/0xPlayerOne/code-foundry/compare/v1.53.5...v1.53.6) (2026-10-31)
4
18
 
5
19
 
@@ -136,23 +150,9 @@
136
150
 
137
151
  * safely categorize npm publication failures ([#787](https://github.com/0xPlayerOne/code-foundry/issues/787)) ([a4268af](https://github.com/0xPlayerOne/code-foundry/commit/a4268af5ce7cd0a32407420424419c0cad01dce8))
138
152
 
139
- ## [1.49.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.49.0...v1.49.1) (2026-10-28)
140
-
141
-
142
- ### Bug Fixes
143
-
144
- * handle empty security audit matrices ([#785](https://github.com/0xPlayerOne/code-foundry/issues/785)) ([02f0565](https://github.com/0xPlayerOne/code-foundry/commit/02f05652e68267a2aa2df5d5af8f13df9ba1e3aa))
145
-
146
- ## [1.49.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.48.9...v1.49.0) (2026-10-27)
147
-
148
-
149
- ### Features
150
-
151
- * support isolated integration test shards ([#783](https://github.com/0xPlayerOne/code-foundry/issues/783)) ([ad1123f](https://github.com/0xPlayerOne/code-foundry/commit/ad1123ff3bb9654bfbdaef4219490cca3e7e3d20))
152
-
153
153
  <!-- code-foundry: packaged changelog -->
154
154
 
155
155
  ---
156
156
 
157
- This packaged changelog lists the 20 most recent releases; 277 older releases are omitted to keep the package small.
157
+ This packaged changelog lists the 20 most recent releases; 279 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,7 +310,8 @@ 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 `Mode` job also plans lanes: it runs the same applicability check
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
314
315
  each lane's Detect step runs and passes the integration, E2E, smoke,
315
316
  performance, and eval lanes that have no package script, configured command,
316
317
  or test files to the orchestrator as `idle-lanes`. Those jobs are skipped
@@ -320,6 +321,18 @@ The gate counts a planned idle Eval workflow (which reports `skipped`) as the
320
321
  no-op success it reported before; any other skip, and every lane with work,
321
322
  is still required. Callers that pass no `idle-lanes` run every lane as before.
322
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
+
323
336
  ## Security behavior
324
337
 
325
338
  CodeQL is a separate workflow using GitHub's official actions. The default
@@ -328,9 +341,11 @@ Security availability for private repositories. When CodeQL is unavailable,
328
341
  set `codeql: false` and sync: the generated caller selects an orchestrator that
329
342
  omits CodeQL entirely, so pull requests do not show a skipped analyzer.
330
343
  Dependency Review follows the same capability policy through
331
- `dependency_review: auto`, but executes as a conditional step inside the
332
- Security profile job and never registers a separate skipped pull-request
333
- 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
334
349
  Security. If GitHub default setup is enabled, disable its generated CodeQL
335
350
  workflow to avoid duplicate analysis.
336
351
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.53.6",
3
+ "version": "1.53.8",
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.
@@ -393,6 +393,44 @@ function validation(task) {
393
393
  writeOutput('idle_lanes', idle.join(','))
394
394
  return
395
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
+ }
396
434
  if (task === 'gate') {
397
435
  const mode = process.env.FOUNDRY_MODE ?? ''
398
436
  const gate = evaluateValidationGate({
@@ -940,24 +978,56 @@ function readFileSafe(file) {
940
978
  }
941
979
  }
942
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
+
943
1027
  /** @param {string} task @param {string} ecosystem */
944
1028
  function security(task, ecosystem) {
945
1029
  if (task === 'profile') {
946
- writeOutput('javascript', hasDependencyManifest('javascript') ? 'true' : 'false')
947
- writeOutput('rust', hasDependencyManifest('rust') ? 'true' : 'false')
948
- writeOutput('python', hasDependencyManifest('python') ? 'true' : 'false')
949
- const requirements = capture('git', ['ls-files', '*requirements*.txt'])
950
- .split('\n')
951
- .filter(Boolean)
952
- writeOutput(
953
- 'python_requirements',
954
- hasDependencyManifest('python')
955
- ? requirements.length
956
- ? requirements
957
- : ['project']
958
- : ['none']
959
- )
960
- writeOutput('dependency_review', featureEnabled('dependency_review') ? 'true' : 'false')
1030
+ for (const [key, value] of Object.entries(securityProfile())) writeOutput(key, value)
961
1031
  return
962
1032
  }
963
1033
  if (task === 'should_run') {
@@ -1082,18 +1152,28 @@ function assertRustShardsAligned() {
1082
1152
  }
1083
1153
 
1084
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() {
1085
1164
  const enabled =
1086
1165
  featureEnabled('codeql') &&
1087
1166
  (isPublicRepository() || process.env.REPO_FOUNDRY_CODE_SECURITY === 'enabled')
1088
- writeOutput('enabled', enabled ? 'true' : 'false')
1167
+ /** @type {Record<string, string>} */
1168
+ const outputs = { enabled: enabled ? 'true' : 'false' }
1089
1169
  if (!enabled) {
1090
- writeOutput('languages', [])
1170
+ outputs.languages = '[]'
1091
1171
  for (const language of ['actions', 'javascript', 'python', 'rust']) {
1092
- writeOutput(`${language}_available`, 'false')
1093
- writeOutput(`${language}_changed`, 'false')
1094
- writeOutput(`${language}_build_mode`, 'none')
1172
+ outputs[`${language}_available`] = 'false'
1173
+ outputs[`${language}_changed`] = 'false'
1174
+ outputs[`${language}_build_mode`] = 'none'
1095
1175
  }
1096
- return
1176
+ return outputs
1097
1177
  }
1098
1178
  // Detect lane drift before any analysis uploads a category the other lanes
1099
1179
  // never produce; a stall surfaces only later as a blocked merge gate.
@@ -1129,14 +1209,55 @@ function codeql() {
1129
1209
  rustEntry.shards = shards
1130
1210
  rustEntry.changed = shards.some((entry) => entry.changed)
1131
1211
  }
1132
- writeOutput('languages', languagesJson)
1212
+ outputs.languages = JSON.stringify(languagesJson)
1133
1213
  for (const language of ['actions', 'javascript-typescript', 'python', 'rust']) {
1134
1214
  const entry = languagesJson.find((item) => item.language === language)
1135
1215
  const prefix = language === 'javascript-typescript' ? 'javascript' : language
1136
- writeOutput(`${prefix}_available`, entry ? 'true' : 'false')
1137
- writeOutput(`${prefix}_changed`, entry?.changed ? 'true' : 'false')
1138
- 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
+ })
1139
1258
  }
1259
+ if (!matrix.length) throw new Error('CodeQL analysis matrix is empty while analysis is enabled.')
1260
+ return matrix
1140
1261
  }
1141
1262
 
1142
1263
  /**