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.
- package/.github/workflows/ci.yml +61 -0
- package/.github/workflows/consumer-qualification.yml +113 -11
- package/.github/workflows/test.yml +75 -0
- package/.github/workflows/validation-no-codeql.yml +7 -0
- package/.github/workflows/validation.yml +7 -0
- package/CHANGELOG.md +21 -0
- package/docs/PERFORMANCE.md +7 -7
- package/docs/consumer-qualification.md +18 -2
- package/docs/merge-queues.md +89 -0
- package/docs/required-capabilities.md +23 -3
- package/package.json +1 -1
- package/src/commands/sync.mjs +44 -1
- package/src/lib/merge-queue.mjs +271 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"$(
|
|
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:
|
|
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
|
|
package/docs/PERFORMANCE.md
CHANGED
|
@@ -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 |
|
|
19
|
-
| Unpacked artifact |
|
|
20
|
-
| Packed files |
|
|
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,
|
|
28
|
-
|
|
29
|
-
and
|
|
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
|
|
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.
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
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": {
|
package/src/commands/sync.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|