code-foundry 1.19.0 → 1.20.2

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.
@@ -18,6 +18,11 @@ on:
18
18
  required: false
19
19
  type: string
20
20
  default: ubuntu-latest
21
+ artifact-prefix:
22
+ description: Prefix for task receipts; use a unique value when calling this workflow more than once.
23
+ required: false
24
+ type: string
25
+ default: task-result
21
26
  secrets:
22
27
  TURBO_TOKEN:
23
28
  required: false
@@ -86,8 +91,22 @@ jobs:
86
91
  cache-build: 'false'
87
92
  task: format
88
93
  - name: Format
94
+ id: execute
89
95
  if: steps.applicability.outputs.applicable == 'true'
90
96
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci format
97
+ - name: Retain task receipt
98
+ if: >-
99
+ always() &&
100
+ (steps.execute.outcome == 'success' || steps.execute.outcome == 'failure' ||
101
+ steps.applicability.outputs.applicable == 'false') &&
102
+ hashFiles('.code-foundry/results/format.json') != ''
103
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
104
+ with:
105
+ name: ${{ inputs.artifact-prefix }}-${{ github.run_id }}-${{ github.run_attempt }}-format
106
+ path: .code-foundry/results/format.json
107
+ include-hidden-files: true
108
+ if-no-files-found: error
109
+ retention-days: 14
91
110
 
92
111
  lint:
93
112
  name: Lint
@@ -126,8 +145,22 @@ jobs:
126
145
  cache-lint: 'true'
127
146
  task: lint
128
147
  - name: Lint
148
+ id: execute
129
149
  if: steps.applicability.outputs.applicable == 'true'
130
150
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci lint
151
+ - name: Retain task receipt
152
+ if: >-
153
+ always() &&
154
+ (steps.execute.outcome == 'success' || steps.execute.outcome == 'failure' ||
155
+ steps.applicability.outputs.applicable == 'false') &&
156
+ hashFiles('.code-foundry/results/lint.json') != ''
157
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
158
+ with:
159
+ name: ${{ inputs.artifact-prefix }}-${{ github.run_id }}-${{ github.run_attempt }}-lint
160
+ path: .code-foundry/results/lint.json
161
+ include-hidden-files: true
162
+ if-no-files-found: error
163
+ retention-days: 14
131
164
 
132
165
  type-check:
133
166
  name: Type-Check
@@ -166,8 +199,22 @@ jobs:
166
199
  cache-build: 'true'
167
200
  task: type_check
168
201
  - name: Type-Check
202
+ id: execute
169
203
  if: steps.applicability.outputs.applicable == 'true'
170
204
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci type_check
205
+ - name: Retain task receipt
206
+ if: >-
207
+ always() &&
208
+ (steps.execute.outcome == 'success' || steps.execute.outcome == 'failure' ||
209
+ steps.applicability.outputs.applicable == 'false') &&
210
+ hashFiles('.code-foundry/results/type_check.json') != ''
211
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
212
+ with:
213
+ name: ${{ inputs.artifact-prefix }}-${{ github.run_id }}-${{ github.run_attempt }}-type_check
214
+ path: .code-foundry/results/type_check.json
215
+ include-hidden-files: true
216
+ if-no-files-found: error
217
+ retention-days: 14
171
218
 
172
219
  build:
173
220
  name: Build
@@ -211,5 +258,19 @@ jobs:
211
258
  cache-build: 'true'
212
259
  task: build
213
260
  - name: Build
261
+ id: execute
214
262
  if: steps.applicability.outputs.applicable == 'true'
215
263
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci build
264
+ - name: Retain task receipt
265
+ if: >-
266
+ always() &&
267
+ (steps.execute.outcome == 'success' || steps.execute.outcome == 'failure' ||
268
+ steps.applicability.outputs.applicable == 'false') &&
269
+ hashFiles('.code-foundry/results/build.json') != ''
270
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
271
+ with:
272
+ name: ${{ inputs.artifact-prefix }}-${{ github.run_id }}-${{ github.run_attempt }}-build
273
+ path: .code-foundry/results/build.json
274
+ include-hidden-files: true
275
+ if-no-files-found: error
276
+ retention-days: 14
@@ -8,6 +8,19 @@ on:
8
8
  required: false
9
9
  type: boolean
10
10
  default: false
11
+ outputs:
12
+ filename:
13
+ description: Exact qualified npm archive name.
14
+ value: ${{ jobs.pack.outputs.filename }}
15
+ source-sha:
16
+ description: Commit whose archive passed the complete qualification matrix.
17
+ value: ${{ jobs.gate.outputs.source-sha }}
18
+ sha256:
19
+ description: SHA-256 of the qualified archive, without an algorithm prefix.
20
+ value: ${{ jobs.gate.outputs.sha256 }}
21
+ candidate-artifact:
22
+ description: Candidate artifact scoped to this workflow run and attempt.
23
+ value: ${{ jobs.gate.outputs.candidate-artifact }}
11
24
 
12
25
  permissions:
13
26
  actions: read
@@ -21,6 +34,9 @@ jobs:
21
34
  timeout-minutes: 10
22
35
  outputs:
23
36
  filename: ${{ steps.pack.outputs.filename }}
37
+ source-sha: ${{ steps.pack.outputs.source-sha }}
38
+ sha256: ${{ steps.pack.outputs.sha256 }}
39
+ run-attempt: ${{ steps.pack.outputs.run-attempt }}
24
40
  steps:
25
41
  - name: Checkout candidate
26
42
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -37,14 +53,18 @@ jobs:
37
53
  mkdir -p "$RUNNER_TEMP/candidate"
38
54
  npm pack --ignore-scripts --json --pack-destination "$RUNNER_TEMP/candidate" > "$RUNNER_TEMP/candidate/pack.json"
39
55
  PACKAGE=$(node -e 'const p=require(process.argv[1]); if(p.length!==1 || !/^[A-Za-z0-9._-]+\.tgz$/.test(p[0].filename)) process.exit(1); console.log(p[0].filename)' "$RUNNER_TEMP/candidate/pack.json")
40
- echo "filename=$PACKAGE" >> "$GITHUB_OUTPUT"
56
+ {
57
+ echo "filename=$PACKAGE"
58
+ echo "source-sha=$(git rev-parse HEAD)"
59
+ echo "sha256=$(sha256sum "$RUNNER_TEMP/candidate/$PACKAGE" | cut -d' ' -f1)"
60
+ echo "run-attempt=$GITHUB_RUN_ATTEMPT"
61
+ } >> "$GITHUB_OUTPUT"
41
62
  - name: Retain the shared candidate
42
63
  uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
43
64
  with:
44
- name: qualification-candidate-${{ github.run_id }}
65
+ name: qualification-candidate-${{ github.run_id }}-${{ github.run_attempt }}
45
66
  path: ${{ runner.temp }}/candidate/*
46
67
  if-no-files-found: error
47
- overwrite: true
48
68
  retention-days: 14
49
69
 
50
70
  qualify:
@@ -57,6 +77,11 @@ jobs:
57
77
  fail-fast: false
58
78
  matrix:
59
79
  node: ['20', '22', '24']
80
+ env:
81
+ PACKAGE: ${{ needs.pack.outputs.filename }}
82
+ SOURCE_SHA: ${{ needs.pack.outputs.source-sha }}
83
+ CANDIDATE_SHA256: ${{ needs.pack.outputs.sha256 }}
84
+ PACK_ATTEMPT: ${{ needs.pack.outputs.run-attempt }}
60
85
  steps:
61
86
  - name: Checkout qualification runtime
62
87
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -70,26 +95,103 @@ jobs:
70
95
  env:
71
96
  GH_TOKEN: ${{ github.token }}
72
97
  run: |
98
+ set -euo pipefail
99
+ test "$PACK_ATTEMPT" = "$GITHUB_RUN_ATTEMPT" || { echo 'Rerun all jobs: qualification cannot mix run attempts.' >&2; exit 1; }
100
+ test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
73
101
  gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" \
74
- --name "qualification-candidate-$GITHUB_RUN_ID" \
102
+ --name "qualification-candidate-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" \
75
103
  --dir "$RUNNER_TEMP/candidate"
104
+ test "$(sha256sum "$RUNNER_TEMP/candidate/$PACKAGE" | cut -d' ' -f1)" = "$CANDIDATE_SHA256"
76
105
  - name: Install pinned workflow validator
77
106
  run: go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
78
107
  - name: Qualify the exact shared distributable
79
- env:
80
- PACKAGE: ${{ needs.pack.outputs.filename }}
81
108
  run: |
82
109
  set -euo pipefail
83
110
  node src/lib/consumer-qualification.mjs \
84
- "$RUNNER_TEMP/candidate/$PACKAGE" \
85
- "$(git rev-parse HEAD)" \
86
- "$RUNNER_TEMP/report.json" \
87
- "$(go env GOPATH)/bin/actionlint"
111
+ "$RUNNER_TEMP/candidate/$PACKAGE" "$SOURCE_SHA" \
112
+ "$RUNNER_TEMP/report.json" "$(go env GOPATH)/bin/actionlint"
88
113
  - name: Retain qualification evidence
89
114
  if: always()
90
115
  uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
91
116
  with:
92
117
  name: consumer-qualification-${{ github.run_attempt }}-node-${{ matrix.node }}
93
118
  path: ${{ runner.temp }}/report.json
94
- if-no-files-found: warn
119
+ if-no-files-found: error
120
+ retention-days: 14
121
+
122
+ gate:
123
+ name: Gate
124
+ needs: [pack, qualify]
125
+ if: >-
126
+ always() && !cancelled() &&
127
+ (vars.CI_BILLING_PAUSED != 'true' || inputs['billing-pause-bypass'] == true)
128
+ runs-on: ubuntu-24.04
129
+ timeout-minutes: 10
130
+ outputs:
131
+ source-sha: ${{ steps.verify.outputs.source-sha }}
132
+ sha256: ${{ steps.verify.outputs.sha256 }}
133
+ candidate-artifact: ${{ steps.verify.outputs.candidate-artifact }}
134
+ env:
135
+ PACKAGE: ${{ needs.pack.outputs.filename }}
136
+ SOURCE_SHA: ${{ needs.pack.outputs.source-sha }}
137
+ CANDIDATE_SHA256: ${{ needs.pack.outputs.sha256 }}
138
+ PACK_ATTEMPT: ${{ needs.pack.outputs.run-attempt }}
139
+ PACK_RESULT: ${{ needs.pack.result }}
140
+ QUALIFY_RESULT: ${{ needs.qualify.result }}
141
+ steps:
142
+ - name: Require the complete successful matrix
143
+ run: |
144
+ set -euo pipefail
145
+ test "$PACK_RESULT" = success
146
+ test "$QUALIFY_RESULT" = success
147
+ test "$PACK_ATTEMPT" = "$GITHUB_RUN_ATTEMPT" || { echo 'Rerun all jobs: qualification cannot mix run attempts.' >&2; exit 1; }
148
+ - name: Checkout qualification policy
149
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
150
+ with:
151
+ persist-credentials: false
152
+ - name: Setup Node
153
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
154
+ with:
155
+ node-version: '24'
156
+ - name: Retrieve this attempt's candidate and reports
157
+ env:
158
+ GH_TOKEN: ${{ github.token }}
159
+ run: |
160
+ set -euo pipefail
161
+ test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
162
+ gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" \
163
+ --name "qualification-candidate-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" \
164
+ --dir "$RUNNER_TEMP/candidate"
165
+ for node in 20 22 24; do
166
+ gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" \
167
+ --name "consumer-qualification-$GITHUB_RUN_ATTEMPT-node-$node" \
168
+ --dir "$RUNNER_TEMP/reports/$node"
169
+ done
170
+ - name: Verify source, archive and every fixture before handing off
171
+ id: verify
172
+ run: |
173
+ node --input-type=module <<'NODE'
174
+ import assert from 'node:assert/strict'
175
+ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
176
+ import { join } from 'node:path'
177
+ import { qualifyArchive } from './src/commands/qualified-publication.mjs'
178
+ const env = process.env
179
+ const version = JSON.parse(readFileSync('package.json', 'utf8')).version
180
+ const candidate = {
181
+ repository: env.GITHUB_REPOSITORY, tag: `v${version}`,
182
+ sourceSha: env.SOURCE_SHA, asset: env.PACKAGE,
183
+ directory: join(env.RUNNER_TEMP, 'candidate'),
184
+ }
185
+ const result = qualifyArchive(candidate, ['20', '22', '24'].map(node => join(env.RUNNER_TEMP, 'reports', node, 'report.json')))
186
+ assert.equal(result.digest, `sha256:${env.CANDIDATE_SHA256}`)
187
+ const artifact = `qualification-candidate-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`
188
+ writeFileSync(join(env.RUNNER_TEMP, 'qualification.json'), JSON.stringify({ ...result, run_id: env.GITHUB_RUN_ID, run_attempt: env.GITHUB_RUN_ATTEMPT }, null, 2) + '\n')
189
+ appendFileSync(env.GITHUB_OUTPUT, `source-sha=${result.sourceSha}\nsha256=${env.CANDIDATE_SHA256}\ncandidate-artifact=${artifact}\n`)
190
+ NODE
191
+ - name: Retain the qualified handoff
192
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
193
+ with:
194
+ name: qualification-handoff-${{ github.run_id }}-${{ github.run_attempt }}
195
+ path: ${{ runner.temp }}/qualification.json
196
+ if-no-files-found: error
95
197
  retention-days: 14
@@ -33,6 +33,11 @@ on:
33
33
  required: false
34
34
  type: boolean
35
35
  default: false
36
+ artifact-prefix:
37
+ description: Prefix for task receipts; use a unique value when calling this workflow more than once.
38
+ required: false
39
+ type: string
40
+ default: task-result
36
41
  secrets:
37
42
  TURBO_TOKEN:
38
43
  required: false
@@ -104,8 +109,22 @@ jobs:
104
109
  task-python: ${{ steps.applicability.outputs.python }}
105
110
  task-rust: ${{ steps.applicability.outputs.rust }}
106
111
  - name: Unit
112
+ id: execute
107
113
  if: steps.applicability.outputs.applicable == 'true'
108
114
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci unit
115
+ - name: Retain task receipt
116
+ if: >-
117
+ always() &&
118
+ (steps.execute.outcome == 'success' || steps.execute.outcome == 'failure' ||
119
+ steps.applicability.outputs.applicable == 'false') &&
120
+ hashFiles('.code-foundry/results/unit.json') != ''
121
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
122
+ with:
123
+ name: ${{ inputs.artifact-prefix }}-${{ github.run_id }}-${{ github.run_attempt }}-unit
124
+ path: .code-foundry/results/unit.json
125
+ include-hidden-files: true
126
+ if-no-files-found: error
127
+ retention-days: 14
109
128
  - name: Upload coverage
110
129
  if: >-
111
130
  always() &&
@@ -171,8 +190,22 @@ jobs:
171
190
  task-python: ${{ steps.applicability.outputs.python }}
172
191
  task-rust: ${{ steps.applicability.outputs.rust }}
173
192
  - name: Performance
193
+ id: execute
174
194
  if: steps.applicability.outputs.applicable == 'true'
175
195
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci performance
196
+ - name: Retain task receipt
197
+ if: >-
198
+ always() &&
199
+ (steps.execute.outcome == 'success' || steps.execute.outcome == 'failure' ||
200
+ steps.applicability.outputs.applicable == 'false') &&
201
+ hashFiles('.code-foundry/results/performance.json') != ''
202
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
203
+ with:
204
+ name: ${{ inputs.artifact-prefix }}-${{ github.run_id }}-${{ github.run_attempt }}-performance
205
+ path: .code-foundry/results/performance.json
206
+ include-hidden-files: true
207
+ if-no-files-found: error
208
+ retention-days: 14
176
209
  - name: Upload performance results
177
210
  if: >-
178
211
  always() &&
@@ -236,8 +269,22 @@ jobs:
236
269
  task-python: ${{ steps.applicability.outputs.python }}
237
270
  task-rust: ${{ steps.applicability.outputs.rust }}
238
271
  - name: Integration
272
+ id: execute
239
273
  if: steps.applicability.outputs.applicable == 'true'
240
274
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci integration
275
+ - name: Retain task receipt
276
+ if: >-
277
+ always() &&
278
+ (steps.execute.outcome == 'success' || steps.execute.outcome == 'failure' ||
279
+ steps.applicability.outputs.applicable == 'false') &&
280
+ hashFiles('.code-foundry/results/integration.json') != ''
281
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
282
+ with:
283
+ name: ${{ inputs.artifact-prefix }}-${{ github.run_id }}-${{ github.run_attempt }}-integration
284
+ path: .code-foundry/results/integration.json
285
+ include-hidden-files: true
286
+ if-no-files-found: error
287
+ retention-days: 14
241
288
 
242
289
  e2e:
243
290
  name: E2E
@@ -298,8 +345,22 @@ jobs:
298
345
  task-python: ${{ steps.applicability.outputs.python }}
299
346
  task-rust: ${{ steps.applicability.outputs.rust }}
300
347
  - name: E2E
348
+ id: execute
301
349
  if: steps.applicability.outputs.applicable == 'true'
302
350
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci e2e
351
+ - name: Retain task receipt
352
+ if: >-
353
+ always() &&
354
+ (steps.execute.outcome == 'success' || steps.execute.outcome == 'failure' ||
355
+ steps.applicability.outputs.applicable == 'false') &&
356
+ hashFiles('.code-foundry/results/e2e.json') != ''
357
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
358
+ with:
359
+ name: ${{ inputs.artifact-prefix }}-${{ github.run_id }}-${{ github.run_attempt }}-e2e
360
+ path: .code-foundry/results/e2e.json
361
+ include-hidden-files: true
362
+ if-no-files-found: error
363
+ retention-days: 14
303
364
 
304
365
  smoke:
305
366
  name: Smoke
@@ -348,5 +409,19 @@ jobs:
348
409
  task-python: ${{ steps.applicability.outputs.python }}
349
410
  task-rust: ${{ steps.applicability.outputs.rust }}
350
411
  - name: Smoke
412
+ id: execute
351
413
  if: steps.applicability.outputs.applicable == 'true'
352
414
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci smoke
415
+ - name: Retain task receipt
416
+ if: >-
417
+ always() &&
418
+ (steps.execute.outcome == 'success' || steps.execute.outcome == 'failure' ||
419
+ steps.applicability.outputs.applicable == 'false') &&
420
+ hashFiles('.code-foundry/results/smoke.json') != ''
421
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
422
+ with:
423
+ name: ${{ inputs.artifact-prefix }}-${{ github.run_id }}-${{ github.run_attempt }}-smoke
424
+ path: .code-foundry/results/smoke.json
425
+ include-hidden-files: true
426
+ if-no-files-found: error
427
+ retention-days: 14
@@ -62,6 +62,11 @@ on:
62
62
  required: false
63
63
  type: number
64
64
  default: 1
65
+ artifact-prefix:
66
+ description: Prefix for task receipts; use a unique value when calling this workflow more than once.
67
+ required: false
68
+ type: string
69
+ default: task-result
65
70
  secrets:
66
71
  TURBO_TOKEN:
67
72
  required: false
@@ -86,6 +91,7 @@ jobs:
86
91
  runtime-repository: ${{ inputs.runtime-repository }}
87
92
  runtime-ref: ${{ inputs.runtime-ref }}
88
93
  runner: ${{ inputs.ci-runner }}
94
+ artifact-prefix: ${{ inputs.artifact-prefix }}
89
95
  secrets:
90
96
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
91
97
  NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
@@ -101,6 +107,7 @@ jobs:
101
107
  unit-runner: ${{ inputs.unit-runner }}
102
108
  performance-runner: ${{ inputs.performance-runner }}
103
109
  unit-only: ${{ inputs.mode == 'fast' }}
110
+ artifact-prefix: ${{ inputs.artifact-prefix }}
104
111
  secrets:
105
112
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
106
113
 
@@ -62,6 +62,11 @@ on:
62
62
  required: false
63
63
  type: number
64
64
  default: 1
65
+ artifact-prefix:
66
+ description: Prefix for task receipts; use a unique value when calling this workflow more than once.
67
+ required: false
68
+ type: string
69
+ default: task-result
65
70
  secrets:
66
71
  TURBO_TOKEN:
67
72
  required: false
@@ -86,6 +91,7 @@ jobs:
86
91
  runtime-repository: ${{ inputs.runtime-repository }}
87
92
  runtime-ref: ${{ inputs.runtime-ref }}
88
93
  runner: ${{ inputs.ci-runner }}
94
+ artifact-prefix: ${{ inputs.artifact-prefix }}
89
95
  secrets:
90
96
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
91
97
  NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
@@ -101,6 +107,7 @@ jobs:
101
107
  unit-runner: ${{ inputs.unit-runner }}
102
108
  performance-runner: ${{ inputs.performance-runner }}
103
109
  unit-only: ${{ inputs.mode == 'fast' }}
110
+ artifact-prefix: ${{ inputs.artifact-prefix }}
104
111
  secrets:
105
112
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
106
113
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.20.2](https://github.com/0xPlayerOne/code-foundry/compare/v1.20.1...v1.20.2) (2026-09-09)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **ci:** retain scoped task receipts across success and failure ([#557](https://github.com/0xPlayerOne/code-foundry/issues/557)) ([ee0fe45](https://github.com/0xPlayerOne/code-foundry/commit/ee0fe45e821621e3193831d70a533077b7bcf8a9))
9
+
10
+ ## [1.20.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.20.0...v1.20.1) (2026-09-09)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **qualification:** isolate attempts and verify release handoff ([#556](https://github.com/0xPlayerOne/code-foundry/issues/556)) ([5fe7f42](https://github.com/0xPlayerOne/code-foundry/commit/5fe7f4203b8018fd3eb88bd68e2286c6feb3d3dc))
16
+
17
+ ## [1.20.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.19.0...v1.20.0) (2026-09-09)
18
+
19
+
20
+ ### Features
21
+
22
+ * **ci:** add opt-in merge-group audit validation ([#549](https://github.com/0xPlayerOne/code-foundry/issues/549)) ([ae418a7](https://github.com/0xPlayerOne/code-foundry/commit/ae418a779ada2927bd391cc6cf54db16e342d921))
23
+
3
24
  ## [1.19.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.18.0...v1.19.0) (2026-09-09)
4
25
 
5
26
 
@@ -15,19 +15,19 @@ belong to the run that produced them, not the source tree.
15
15
  | Format, lint, type-check, and build | 15 s | Bound the local CI feedback loop |
16
16
  | Runtime dependencies | 0 | Keep the installed CLI dependency-free |
17
17
  | Development dependencies | 4 | Prevent unreviewed toolchain growth |
18
- | Packed artifact | 250 kB | Bound registry transfer and install cost |
19
- | Unpacked artifact | 930 kB | Bound installed footprint |
20
- | Packed files | 110 | Detect accidental release contents |
18
+ | Packed artifact | 255 kB | Bound registry transfer and install cost |
19
+ | Unpacked artifact | 965 kB | Bound installed footprint |
20
+ | Packed files | 115 | Detect accidental release contents |
21
21
 
22
22
  The performance workflow disables build-cache reads and writes for this task.
23
23
  That makes timing comparisons independent of a warm protected-branch cache and
24
24
  prevents benchmark code from populating shared cache entries.
25
25
 
26
26
  The merged candidate includes the release-integrity verifier, fleet eligibility,
27
- consumer qualification harness, product-quality profiles, and qualified
28
- publication workflow. It measured 243,466 packed bytes, 930,389 unpacked bytes,
29
- and 109 files on Node 24.18.0; the 250 kB, 945 kB, and 110-file limits retain a
30
- small margin while continuing to bound package growth.
27
+ consumer qualification harness, product-quality profiles, qualified publication
28
+ workflow, and opt-in merge-queue verifier. It measured 248,464 packed bytes,
29
+ 947,310 unpacked bytes, and 111 files on Node 24.18.0; the 255 kB, 965 kB, and
30
+ 115-file limits retain a small margin while continuing to bound package growth.
31
31
 
32
32
  ## v1.6.1 baseline
33
33
 
@@ -6,7 +6,7 @@ Release-candidate compatibility checks against the distributable package.
6
6
  **Scope:** Package installation, CLI initialization/synchronization, generated workflow contracts.
7
7
 
8
8
  `consumer-qualification.yml` packs the checked-out candidate once, shares that archive across all supported
9
- Node majors using an artifact scoped to the same workflow run (including failed-job reruns), installs it offline with lifecycle scripts disabled,
9
+ Node majors using an artifact scoped to the same workflow run **and attempt**, installs it offline with lifecycle scripts disabled,
10
10
  and executes its public CLI from the installed package. The harness does not
11
11
  import the source checkout's initializer as a substitute for package testing.
12
12
 
@@ -39,10 +39,26 @@ member. Reports are evidence, not signatures or a substitute for release identit
39
39
  verification.
40
40
 
41
41
  The self release caller cannot start Release Please or npm publication until all
42
- qualification jobs succeed. An explicit `release-while-paused` dispatch is passed
42
+ qualification jobs and the aggregate `Gate` succeed. An explicit `release-while-paused` dispatch is passed
43
43
  through to the reusable workflow; ordinary calls remain blocked by the billing pause.
44
44
  Consumer-generated release callers do not inherit this Code Foundry-specific
45
45
  qualification job. This does not yet guarantee that its legacy publishing job uses
46
46
  this same archive; the verified-publication workflow handles that separate
47
47
  requirement. No branch protections, repository settings, credentials, or consumer
48
48
  runtimes are changed by this feature.
49
+
50
+ ## Verified handoff and reruns
51
+
52
+ The reusable workflow exports `filename`, `source-sha`, `sha256`, and
53
+ `candidate-artifact`. Only the gate exports the verified source, digest, and
54
+ artifact identity. It requires a successful pack and complete Node matrix,
55
+ retrieves every report from the current attempt, and checks all fixture results
56
+ against the same source and archive through the publication eligibility policy.
57
+ Its retained `qualification-handoff-RUN_ID-ATTEMPT` receipt records that identity.
58
+ A green matrix without valid matching reports cannot authorize a release.
59
+
60
+ Artifacts are never overwritten. On a failed or cancelled qualification, use
61
+ **Re-run all jobs**, not a failed-job-only rerun: a later attempt cannot reuse
62
+ an earlier pack or silently combine old and new reports. Missing/expired artifacts
63
+ fail closed; start a fresh complete run. This stricter retry policy also applies
64
+ when qualification is nested in a release workflow. No release settings change.
@@ -0,0 +1,89 @@
1
+ # Merge Queue Validation
2
+
3
+ Opt-in validation of the combined commit produced by GitHub's merge queue.
4
+
5
+ **Activation:** `merge_queue: true` in the consumer's `.github/code-foundry.yml`, followed by normal sync.
6
+ **Required check:** Existing canonical `Validation / Gate`.
7
+
8
+ The synchronizer adds `.github/workflows/validation-merge-queue.yml` only when
9
+ explicitly enabled. The ordinary PR readiness workflow is unchanged: this feature
10
+ does not turn draft pushes into CI, mark PRs ready, enqueue PRs, or change release
11
+ behavior. Queue events receive the full audit even when staging PRs normally use
12
+ fast validation. Release Please branch detection is deliberately not reused for
13
+ a merge group containing several PRs; the group's combined tree is audited, not
14
+ treated as a generated version-only diff.
15
+
16
+ ```yaml
17
+ merge_queue: true
18
+ ```
19
+
20
+ Normal `code-foundry sync` updates the generated queue caller alongside the other
21
+ runtime pins. Explicit fleet runtime overrides are honored. The installed runtime
22
+ must contain this feature, and queues require an exact commit or released version
23
+ pin rather than a moving branch. Direct topology enables main; staging-release
24
+ enables main and staging. Existing runner choices, Rust CodeQL sharding, and an
25
+ explicit `codeql: false` policy select the same appropriate validation orchestrator
26
+ as ordinary PRs. No CodeQL policy or runtime default is relaxed.
27
+
28
+ ## Event identity and checks
29
+
30
+ The generated caller accepts only `merge_group` / `checks_requested`. A pinned,
31
+ trusted verifier checks the payload's repository, allowed base branch, queue ref,
32
+ base SHA, and head SHA against the actual workflow context before validation can
33
+ start. The existing reusable orchestrator then checks out the merge-group commit
34
+ through its normal GitHub context and runs CI, tests (including non-unit lanes),
35
+ security, and the configured CodeQL policy with `mode: audit`.
36
+
37
+ The caller job is named `Validation`, so the existing orchestrator still emits
38
+ `Validation / Gate`. That gate's audit truth table requires successful results;
39
+ skipped or failed required jobs cannot satisfy it. A failed identity job prevents
40
+ validation from starting and cannot manufacture a successful aggregate check.
41
+ The standard PR classifier remains PR/scheduled/manual-specific; merge groups
42
+ have their own strict classifier rather than weakening its event whitelist.
43
+
44
+ Concurrency is scoped to the actual queue ref and has a different prefix from PR,
45
+ release, and scheduled workflows. New groups do not cancel unrelated groups or
46
+ PR readiness runs. Replaced attempts for the same queue ref can supersede each
47
+ other. The billing-pause switch is preserved; paused validation does not produce
48
+ a success that authorizes a merge.
49
+
50
+ The caller forwards no secrets and grants no deployment or OIDC publication
51
+ permission. Its permission union matches the existing audit chain (including
52
+ CodeQL security-event uploads). Applications requiring secret-backed tests must
53
+ provide safe queue-compatible fixtures or a separately reviewed explicit caller;
54
+ this feature does not introduce `secrets: inherit`. Native test commands remain
55
+ repository-owned code, not a sandbox.
56
+
57
+ ## Preservation and disabling
58
+
59
+ Setting `merge_queue: false` or removing the key removes only a caller bearing the
60
+ exact Foundry management marker. A custom file at that path is preserved when
61
+ disabled; enabling over it fails before synchronization writes, even with force.
62
+ Symlinked workflow paths are rejected. Dry runs report changes without creating,
63
+ updating, or deleting the caller. The original synchronizer is retained verbatim
64
+ in `sync-core.mjs`; its public exports are preserved through the thin wrapper.
65
+
66
+ Disable the repository's queue rule before removing its required queue workflow.
67
+ Otherwise queued PRs will correctly wait for checks that no longer run.
68
+
69
+ ## Repository activation remains separate
70
+
71
+ A repository administrator must verify GitHub plan/repository eligibility,
72
+ review queue merge-method compatibility with the chosen branch topology, and
73
+ configure required checks. Register the aggregate check, not PR-only mode or
74
+ readiness checks that have no merge-group equivalent. An existing rule requiring
75
+ individual checks needs those exact contexts reviewed as well. Do not enable the
76
+ queue rule before the workflow is merged and a disposable queue exercise passes.
77
+ No repository rule, branch protection, secret, merge setting, or queue is changed
78
+ by this PR.
79
+
80
+ Focused tests cover event identity and rejection, generated audit wiring,
81
+ configured CodeQL policy, pin evolution, idempotence, dry runs, and ownership
82
+ preservation. They do not establish a live merge-group run or full sync integration.
83
+ Before readiness, run the existing complete sync/fleet tests, locked formatter,
84
+ linter, TypeScript and package budgets, and Actionlint against rendered callers
85
+ and their pinned/local reusable workflow contracts. Exercise two queued PRs and
86
+ one deliberately failing check in an eligible disposable repository.
87
+
88
+ References: [GitHub merge-queue CI configuration](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue),
89
+ [merge-group event](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#merge_group).
@@ -60,9 +60,29 @@ appear in the GitHub job summary when `GITHUB_STEP_SUMMARY` is available.
60
60
  The recorded command is the delegated executor invocation, not a transcript of
61
61
  all nested package scripts. No environment variables or captured command output
62
62
  are copied into the report. Repository scripts remain responsible for sanitizing
63
- their own logs. Upload `.code-foundry/results/*.json` with `if: always()` and
64
- `include-hidden-files: true` to retain downloadable reports; only upload this
65
- specific directory, not arbitrary hidden files or the entire checkout.
63
+ their own logs and command arguments. Receipts are diagnostics, not signatures,
64
+ attestations, or authorization to publish or merge.
65
+
66
+ The shared CI and Test workflows retain each executed task's receipt, including
67
+ failure, and discovery receipts for explicitly skipped optional tasks. Each upload
68
+ selects exactly `.code-foundry/results/<task>.json`, not the broader hidden
69
+ directory. The artifact name is `task-result-RUN_ID-ATTEMPT-TASK` and retention is
70
+ 14 days. The optional `artifact-prefix` input is exposed by CI, Test, and both
71
+ validation orchestrators; it disambiguates multiple invocations in the same run
72
+ when forwarded to the leaf workflows. Use a different prefix for each such
73
+ invocation. Existing coverage/performance artifact uploads are unchanged.
74
+
75
+ Missing receipts from an older runtime, a discovery/setup failure, or termination
76
+ before the runtime writes its report do not create an artifact. Missing evidence
77
+ is not success: inspect the task's actual outcome and the validation gate. Uploads
78
+ run after success or failure and do not suppress a failing task exit. When an
79
+ existing receipt disappears during upload, the upload itself fails. A job skipped
80
+ by the workflow's tier or billing policy cannot create a receipt.
81
+
82
+ These files describe repository-controlled execution and can include paths and
83
+ script-derived reasons. Do not put credentials in command arguments. Artifact
84
+ access follows repository/Actions visibility; receipt retention does not provide
85
+ a sandbox or independently validate repository-authored evidence.
66
86
 
67
87
  `node src/runtime.mjs ci plan` prints a JSON discovery plan without executing
68
88
  checks. Discovery validates every required task before reusable workflows select
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.19.0",
3
+ "version": "1.20.2",
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": {
@@ -18,6 +18,13 @@ import {
18
18
  } from '../lib/config.mjs'
19
19
  import { buildReleaseConfig, buildReleaseManifest } from '../lib/release-manifest.mjs'
20
20
  import { customWorkflowFiles, overlayPolicy } from '../lib/overlay.mjs'
21
+ import {
22
+ inspectQueueCaller,
23
+ mergeQueueEnabled,
24
+ queueRuntimeRef,
25
+ renderMergeQueueCaller,
26
+ syncQueueCaller,
27
+ } from '../lib/merge-queue.mjs'
21
28
 
22
29
  const standardFiles = [
23
30
  '.editorconfig',
@@ -109,7 +116,7 @@ const legacyFiles = [
109
116
  /** @typedef {{ target: string, source: string, dryRun?: boolean, force?: boolean, init?: boolean, runtimeRef?: string, configureHooks?: boolean }} SyncOptions */
110
117
 
111
118
  /** @param {SyncOptions} options */
112
- export function syncRepository(options) {
119
+ function synchronize(options) {
113
120
  const target = resolve(options.target)
114
121
  const source = resolve(options.source)
115
122
  const dryRun = options.dryRun ?? false
@@ -441,6 +448,42 @@ export function syncRepository(options) {
441
448
  return { changed, config }
442
449
  }
443
450
 
451
+ /** @param {Parameters<typeof synchronize>[0]} options */
452
+ export function syncRepository(options) {
453
+ const target = resolve(options.target)
454
+ const source = resolve(options.source)
455
+ const existing = readConfig(join(target, '.github/code-foundry.yml'))
456
+ const enabled = mergeQueueEnabled(existing.merge_queue)
457
+ const ref = queueRuntimeRef(existing.runtime_ref, readPackageVersion(source), options.runtimeRef)
458
+ // Reject malformed opt-in settings or a user-owned destination before the
459
+ // original synchronizer changes any files. It remains the owner of defaults.
460
+ if (enabled) {
461
+ const preflight = renderMergeQueueCaller(
462
+ {
463
+ ...existing,
464
+ runtime_repository: configured(existing.runtime_repository, '0xPlayerOne/code-foundry'),
465
+ },
466
+ ref
467
+ )
468
+ inspectQueueCaller(target, preflight)
469
+ } else inspectQueueCaller(target, null)
470
+ const result = synchronize(options)
471
+ const content = enabled
472
+ ? renderMergeQueueCaller(
473
+ {
474
+ ...result.config,
475
+ runtime_repository: configured(
476
+ result.config.runtime_repository,
477
+ '0xPlayerOne/code-foundry'
478
+ ),
479
+ },
480
+ ref
481
+ )
482
+ : null
483
+ const changed = syncQueueCaller(target, content, options.dryRun ?? false)
484
+ return { ...result, changed: [...new Set([...result.changed, ...changed])] }
485
+ }
486
+
444
487
  /**
445
488
  * Baseline keys that are safe to merge into a repository's release config.
446
489
  * Package and release-type policy is detected from the repository itself and
@@ -0,0 +1,271 @@
1
+ // @ts-check
2
+ import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join, resolve } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+
6
+ export const QUEUE_CALLER = '.github/workflows/validation-merge-queue.yml'
7
+ export const QUEUE_MARKER = '# code-foundry-managed: merge-queue-v1\n'
8
+ /** @param {unknown} value @param {string} message @returns {asserts value} */
9
+ function ensure(value, message) {
10
+ if (!value) throw new Error(message)
11
+ }
12
+
13
+ /** @param {unknown} value */
14
+ export function mergeQueueEnabled(value) {
15
+ if (value === undefined || value === '' || value === false || value === 'false') return false
16
+ if (value === true || value === 'true') return true
17
+ throw new Error('merge_queue must be true or false')
18
+ }
19
+
20
+ /** Same version-pin policy as normal sync: released semver pins advance while
21
+ * custom refs persist unless an explicit fleet runtime override is supplied.
22
+ * @param {string | undefined} current @param {string} sourceVersion @param {string | undefined} override
23
+ */
24
+ export function queueRuntimeRef(current, sourceVersion, override) {
25
+ if (override !== undefined) return override
26
+ if (!current || current === 'auto' || /^v\d+\.\d+\.\d+$/.test(current)) return `v${sourceVersion}`
27
+ return current
28
+ }
29
+
30
+ /** @param {Record<string, string>} config @param {string} ref */
31
+ export function renderMergeQueueCaller(config, ref) {
32
+ const repository = config.runtime_repository || '0xPlayerOne/code-foundry'
33
+ ensure(
34
+ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) &&
35
+ !repository.split('/').some((part) => ['.', '..'].includes(part)),
36
+ 'Invalid queue runtime repository'
37
+ )
38
+ ensure(
39
+ /^(?:[a-f0-9]{40}|v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?)$/.test(ref),
40
+ 'Merge queues require a released version or exact commit pin, not a moving branch'
41
+ )
42
+ const topology = config.git_workflow || 'direct'
43
+ ensure(['direct', 'staging-release'].includes(topology), 'Unsupported queue topology')
44
+ const features = (config.features || 'all').split(/[\s,]+/)
45
+ ensure(
46
+ features.some((feature) =>
47
+ ['all', 'validation', 'ci', 'test', 'security', 'codeql'].includes(feature)
48
+ ),
49
+ 'Merge queues require the canonical PR validation caller'
50
+ )
51
+ const branches = topology === 'direct' ? ['main'] : ['main', 'staging']
52
+ const workflow = config.codeql === 'false' ? 'validation-no-codeql.yml' : 'validation.yml'
53
+ const runner = (/** @type {string} */ key, fallback = 'ubuntu-latest') => {
54
+ const value = config[key] || config.runner || fallback
55
+ ensure(/^[A-Za-z0-9_.-]+$/.test(value), `Unsupported queue runner label: ${key}`)
56
+ return JSON.stringify(value)
57
+ }
58
+ const threads = config.codeql_rust_threads || '1'
59
+ const parallel = config.codeql_rust_max_parallel || '1'
60
+ ensure(
61
+ /^(?:[1-9]|[1-5][0-9]|6[0-4])$/.test(threads),
62
+ 'Unsupported codeql_rust_threads; use an integer from 1 to 64.'
63
+ )
64
+ ensure(
65
+ /^(?:[1-8])$/.test(parallel),
66
+ 'Unsupported codeql_rust_max_parallel; use an integer from 1 to 8.'
67
+ )
68
+
69
+ const rawShards = config.codeql_rust_shards || '["all"]'
70
+ let shards
71
+ try {
72
+ shards = JSON.parse(rawShards)
73
+ } catch {
74
+ throw new Error('Invalid codeql_rust_shards; use a JSON array of relative Rust source paths.')
75
+ }
76
+ ensure(
77
+ Array.isArray(shards) && shards.length > 0 && shards.length <= 8,
78
+ 'Invalid codeql_rust_shards; configure between 1 and 8 shards.'
79
+ )
80
+ const seen = new Set()
81
+ for (const shard of shards) {
82
+ ensure(
83
+ typeof shard === 'string' && shard.length > 0 && shard.length <= 512 && !seen.has(shard),
84
+ 'Invalid codeql_rust_shards; shards must be unique non-empty strings.'
85
+ )
86
+ seen.add(shard)
87
+ if (shard === 'all') continue
88
+ for (const candidate of shard.split(',')) {
89
+ const path = candidate.trim()
90
+ ensure(
91
+ Boolean(path) &&
92
+ !path.startsWith('/') &&
93
+ !path.split('/').includes('..') &&
94
+ /^[A-Za-z0-9._/@+ -]+$/.test(path),
95
+ `Invalid Rust CodeQL shard path: ${path || '(empty)'}`
96
+ )
97
+ }
98
+ }
99
+ ensure(
100
+ !(seen.has('all') && shards.length !== 1),
101
+ 'Invalid codeql_rust_shards; "all" cannot be combined with scoped shards.'
102
+ )
103
+ return `${QUEUE_MARKER}name: Merge Queue
104
+
105
+ on:
106
+ merge_group:
107
+ types: [checks_requested]
108
+ branches: [${branches.join(', ')}]
109
+
110
+ permissions:
111
+ actions: read
112
+ contents: read
113
+ packages: read
114
+ security-events: write
115
+
116
+ concurrency:
117
+ group: code-foundry-merge-queue-\${{ github.repository }}-\${{ github.event.merge_group.head_ref }}
118
+ cancel-in-progress: true
119
+
120
+ jobs:
121
+ identity:
122
+ name: Merge group identity
123
+ if: vars.CI_BILLING_PAUSED != 'true'
124
+ runs-on: ${runner('runner')}
125
+ timeout-minutes: 5
126
+ permissions:
127
+ contents: read
128
+ steps:
129
+ - name: Checkout trusted identity verifier
130
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
131
+ with:
132
+ persist-credentials: false
133
+ repository: ${JSON.stringify(repository)}
134
+ ref: ${JSON.stringify(ref)}
135
+ path: .github/.code-foundry
136
+ sparse-checkout: src/lib
137
+ - name: Verify the exact merge-group commit
138
+ env:
139
+ FOUNDRY_QUEUE_BRANCHES: '${JSON.stringify(branches)}'
140
+ run: node .github/.code-foundry/src/lib/merge-queue.mjs
141
+
142
+ validation:
143
+ name: Validation
144
+ needs: identity
145
+ if: vars.CI_BILLING_PAUSED != 'true'
146
+ uses: ${repository}/.github/workflows/${workflow}@${ref}
147
+ with:
148
+ mode: audit
149
+ runtime-repository: ${JSON.stringify(repository)}
150
+ runtime-ref: ${JSON.stringify(ref)}
151
+ ci-runner: ${runner('ci_runner')}
152
+ test-runner: ${runner('test_runner')}
153
+ unit-runner: ${runner('unit_runner', 'ubuntu-slim')}
154
+ performance-runner: ${runner('performance_runner')}
155
+ security-runner: ${runner('security_runner', 'ubuntu-slim')}
156
+ codeql-runner: ${runner('codeql_runner')}
157
+ rust-shards: '${JSON.stringify(shards)}'
158
+ rust-threads: '${threads}'
159
+ rust-max-parallel: ${parallel}
160
+ `
161
+ }
162
+
163
+ /** @param {Record<string, any>} payload @param {Record<string, string | undefined>} environment @param {unknown} branches */
164
+ export function validateMergeGroup(payload, environment, branches) {
165
+ ensure(
166
+ Array.isArray(branches) &&
167
+ branches.length > 0 &&
168
+ branches.every((branch) => ['main', 'staging'].includes(branch)) &&
169
+ new Set(branches).size === branches.length,
170
+ 'Invalid allowed merge-queue branches'
171
+ )
172
+ ensure(
173
+ environment.GITHUB_EVENT_NAME === 'merge_group' && payload.action === 'checks_requested',
174
+ 'Only merge_group checks_requested is accepted'
175
+ )
176
+ const group = payload.merge_group
177
+ ensure(
178
+ group &&
179
+ /^[a-f0-9]{40}$/.test(group.head_sha ?? '') &&
180
+ /^[a-f0-9]{40}$/.test(group.base_sha ?? ''),
181
+ 'Merge-group SHA identity is incomplete'
182
+ )
183
+ ensure(
184
+ branches.some((branch) => group.base_ref === `refs/heads/${branch}`),
185
+ 'Merge-group base branch is not enabled'
186
+ )
187
+ const branch = group.base_ref.slice('refs/heads/'.length)
188
+ ensure(
189
+ typeof group.head_ref === 'string' &&
190
+ group.head_ref.startsWith(`refs/heads/gh-readonly-queue/${branch}/`) &&
191
+ group.head_ref.length > `refs/heads/gh-readonly-queue/${branch}/`.length,
192
+ 'Unexpected merge-group ref'
193
+ )
194
+ ensure(
195
+ environment.GITHUB_SHA === group.head_sha && environment.GITHUB_REF === group.head_ref,
196
+ 'Workflow commit/ref differs from the merge group'
197
+ )
198
+ ensure(
199
+ typeof environment.GITHUB_REPOSITORY === 'string' &&
200
+ environment.GITHUB_REPOSITORY === payload.repository?.full_name,
201
+ 'Merge-group repository identity mismatch'
202
+ )
203
+ return {
204
+ mode: 'audit',
205
+ head_sha: group.head_sha,
206
+ base_sha: group.base_sha,
207
+ base_ref: group.base_ref,
208
+ }
209
+ }
210
+
211
+ /** Refuse symlinked workflow paths and preserve unowned files, even with force.
212
+ * @param {string} root @param {string | null} content
213
+ */
214
+ export function inspectQueueCaller(root, content) {
215
+ let current = resolve(root)
216
+ for (const part of QUEUE_CALLER.split('/')) {
217
+ current = join(current, part)
218
+ let entry
219
+ try {
220
+ entry = lstatSync(current)
221
+ } catch (error) {
222
+ if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'ENOENT') throw error
223
+ }
224
+ ensure(!entry?.isSymbolicLink(), 'Merge-queue workflow paths must not contain symlinks')
225
+ }
226
+ let existing = null
227
+ try {
228
+ existing = readFileSync(current, 'utf8')
229
+ } catch (error) {
230
+ if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'ENOENT') throw error
231
+ }
232
+ if (existing !== null && !existing.startsWith(QUEUE_MARKER)) {
233
+ ensure(
234
+ content === null,
235
+ 'A repository-owned merge-queue caller already exists; refusing to overwrite it'
236
+ )
237
+ return { path: current, changed: false }
238
+ }
239
+ return { path: current, changed: existing !== content }
240
+ }
241
+
242
+ /** @param {string} root @param {string | null} content @param {boolean} [dryRun] */
243
+ export function syncQueueCaller(root, content, dryRun = false) {
244
+ const plan = inspectQueueCaller(root, content)
245
+ if (!plan.changed) return []
246
+ if (dryRun) console.log(`Would ${content === null ? 'remove' : 'sync'} ${QUEUE_CALLER}`)
247
+ else if (content === null) rmSync(plan.path, { force: true })
248
+ else {
249
+ mkdirSync(dirname(plan.path), { recursive: true })
250
+ writeFileSync(plan.path, content)
251
+ }
252
+ return [QUEUE_CALLER]
253
+ }
254
+
255
+ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
256
+ try {
257
+ ensure(process.env.GITHUB_EVENT_PATH, 'Missing GitHub event payload')
258
+ console.log(
259
+ JSON.stringify(
260
+ validateMergeGroup(
261
+ JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')),
262
+ process.env,
263
+ JSON.parse(process.env.FOUNDRY_QUEUE_BRANCHES || '[]')
264
+ )
265
+ )
266
+ )
267
+ } catch (error) {
268
+ console.error(error instanceof Error ? error.message : String(error))
269
+ process.exitCode = 1
270
+ }
271
+ }