code-foundry 1.18.0 → 1.20.1
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/consumer-qualification.yml +113 -11
- package/CHANGELOG.md +21 -0
- package/docs/PERFORMANCE.md +11 -15
- package/docs/consumer-qualification.md +18 -2
- package/docs/fleet-release-eligibility.md +80 -0
- package/docs/fleet-rollouts.md +4 -3
- package/docs/merge-queues.md +89 -0
- package/package.json +1 -1
- package/src/cli.mjs +19 -6
- package/src/commands/fleet-core.mjs +567 -0
- package/src/commands/fleet.mjs +9 -562
- package/src/commands/sync.mjs +44 -1
- package/src/lib/fleet-eligibility.mjs +245 -0
- package/src/lib/merge-queue.mjs +271 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { lstatSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
export const RELEASE_POLICY_FILE = '.code-foundry-release-policy.json'
|
|
9
|
+
/** @typedef {(command:string, args:string[]) => string} Run */
|
|
10
|
+
/** @param {unknown} condition @param {string} message @returns {asserts condition} */
|
|
11
|
+
function ensure(condition, message) {
|
|
12
|
+
if (!condition) throw new Error(message)
|
|
13
|
+
}
|
|
14
|
+
/** @type {Run} */
|
|
15
|
+
function runCommand(command, args) {
|
|
16
|
+
const result = spawnSync(command, args, {
|
|
17
|
+
encoding: 'utf8',
|
|
18
|
+
timeout: 120_000,
|
|
19
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
20
|
+
env: { ...process.env, GH_HOST: 'github.com', GH_PROMPT_DISABLED: '1' },
|
|
21
|
+
})
|
|
22
|
+
ensure(
|
|
23
|
+
!result.error && result.status === 0,
|
|
24
|
+
`Fleet eligibility is unverified: ${command} failed (${result.status ?? result.error?.message})`
|
|
25
|
+
)
|
|
26
|
+
return result.stdout.trim()
|
|
27
|
+
}
|
|
28
|
+
/** @param {Buffer} bytes */
|
|
29
|
+
function hash(bytes) {
|
|
30
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
31
|
+
}
|
|
32
|
+
/** @param {unknown} value */
|
|
33
|
+
export function validateReleasePolicy(value) {
|
|
34
|
+
const policy = /** @type {Record<string, any>} */ (value)
|
|
35
|
+
ensure(policy?.schema_version === 1, 'Expected a version-1 release policy')
|
|
36
|
+
ensure(
|
|
37
|
+
typeof policy.repository === 'string' &&
|
|
38
|
+
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(policy.repository) &&
|
|
39
|
+
!policy.repository
|
|
40
|
+
.split('/')
|
|
41
|
+
.some((/** @type {string} */ part) => ['.', '..'].includes(part)),
|
|
42
|
+
'Invalid release-policy repository'
|
|
43
|
+
)
|
|
44
|
+
ensure(
|
|
45
|
+
typeof policy.workflow === 'string' &&
|
|
46
|
+
/^\.github\/workflows\/[A-Za-z0-9_-]+\.ya?ml$/.test(policy.workflow),
|
|
47
|
+
'Expected an exact qualification workflow path'
|
|
48
|
+
)
|
|
49
|
+
ensure(
|
|
50
|
+
policy.branch === 'main' || policy.branch === 'staging',
|
|
51
|
+
'Select a protected main or staging qualification branch'
|
|
52
|
+
)
|
|
53
|
+
ensure(
|
|
54
|
+
Array.isArray(policy.required_jobs) &&
|
|
55
|
+
policy.required_jobs.length > 0 &&
|
|
56
|
+
policy.required_jobs.every(
|
|
57
|
+
(/** @type {unknown} */ job) =>
|
|
58
|
+
typeof job === 'string' && job.trim() === job && job.length > 0
|
|
59
|
+
) &&
|
|
60
|
+
new Set(policy.required_jobs).size === policy.required_jobs.length,
|
|
61
|
+
'Expected unique nonempty required qualification job names'
|
|
62
|
+
)
|
|
63
|
+
return policy
|
|
64
|
+
}
|
|
65
|
+
/** @param {unknown} value @param {string} property */
|
|
66
|
+
function pages(value, property) {
|
|
67
|
+
ensure(Array.isArray(value) && value.length > 0, 'Missing paginated qualification evidence')
|
|
68
|
+
/** @type {Record<string, any>[]} */
|
|
69
|
+
const items = []
|
|
70
|
+
let expected = 0
|
|
71
|
+
for (const page of value) {
|
|
72
|
+
ensure(
|
|
73
|
+
Number.isInteger(page.total_count) && page.total_count >= 0 && Array.isArray(page[property]),
|
|
74
|
+
'Malformed paginated qualification evidence'
|
|
75
|
+
)
|
|
76
|
+
expected = Math.max(expected, page.total_count)
|
|
77
|
+
items.push(...page[property])
|
|
78
|
+
}
|
|
79
|
+
ensure(items.length >= expected, 'Incomplete qualification evidence pagination')
|
|
80
|
+
return items
|
|
81
|
+
}
|
|
82
|
+
/** @param {string} root @param {string} source @param {string} version @param {Run} [run] */
|
|
83
|
+
export function assertFleetEligibility(root, source, version, run = runCommand) {
|
|
84
|
+
const path = join(root, RELEASE_POLICY_FILE)
|
|
85
|
+
let entry
|
|
86
|
+
try {
|
|
87
|
+
entry = lstatSync(path)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT') return null
|
|
90
|
+
throw error
|
|
91
|
+
}
|
|
92
|
+
ensure(
|
|
93
|
+
entry.isFile() && !entry.isSymbolicLink(),
|
|
94
|
+
'Release policy must be a regular non-symlink file'
|
|
95
|
+
)
|
|
96
|
+
const bytes = readFileSync(path)
|
|
97
|
+
const policy = validateReleasePolicy(JSON.parse(bytes.toString('utf8')))
|
|
98
|
+
ensure(
|
|
99
|
+
/^v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(version),
|
|
100
|
+
'Qualified upgrades require an explicit version tag'
|
|
101
|
+
)
|
|
102
|
+
const git = (/** @type {string[]} */ args) => run('git', ['-C', source, ...args]).trim()
|
|
103
|
+
const sourceSha = git(['rev-parse', 'HEAD'])
|
|
104
|
+
ensure(
|
|
105
|
+
/^[a-f0-9]{40}$/.test(sourceSha),
|
|
106
|
+
'Qualified upgrades require a source Git checkout with an exact commit'
|
|
107
|
+
)
|
|
108
|
+
ensure(
|
|
109
|
+
git(['status', '--porcelain', '--untracked-files=all']) === '',
|
|
110
|
+
'Qualification cannot authorize a dirty runtime source'
|
|
111
|
+
)
|
|
112
|
+
// Execute the installed/trusted verifier, never code from the proposed source checkout.
|
|
113
|
+
const verifier = fileURLToPath(new URL('../commands/release-integrity.mjs', import.meta.url))
|
|
114
|
+
const verified = JSON.parse(
|
|
115
|
+
run(process.execPath, [
|
|
116
|
+
verifier,
|
|
117
|
+
'release',
|
|
118
|
+
'--repo',
|
|
119
|
+
policy.repository,
|
|
120
|
+
'--tag',
|
|
121
|
+
version,
|
|
122
|
+
'--expected-sha',
|
|
123
|
+
sourceSha,
|
|
124
|
+
])
|
|
125
|
+
)
|
|
126
|
+
ensure(
|
|
127
|
+
verified.status === 'passed' &&
|
|
128
|
+
verified.check === 'release-attestation' &&
|
|
129
|
+
verified.immutable === true &&
|
|
130
|
+
verified.repository === policy.repository &&
|
|
131
|
+
verified.tag === version &&
|
|
132
|
+
verified.sourceSha === sourceSha,
|
|
133
|
+
'Release identity was not cryptographically verified'
|
|
134
|
+
)
|
|
135
|
+
const api = (/** @type {string} */ endpoint, paginate = false) =>
|
|
136
|
+
JSON.parse(
|
|
137
|
+
run('gh', [
|
|
138
|
+
'api',
|
|
139
|
+
'--hostname',
|
|
140
|
+
'github.com',
|
|
141
|
+
endpoint,
|
|
142
|
+
...(paginate ? ['--paginate', '--slurp'] : []),
|
|
143
|
+
])
|
|
144
|
+
)
|
|
145
|
+
const workflow = api(
|
|
146
|
+
`repos/${policy.repository}/actions/workflows/${encodeURIComponent(policy.workflow.split('/').at(-1))}`
|
|
147
|
+
)
|
|
148
|
+
ensure(
|
|
149
|
+
Number.isSafeInteger(workflow.id) &&
|
|
150
|
+
workflow.path === policy.workflow &&
|
|
151
|
+
workflow.state === 'active',
|
|
152
|
+
'Qualification workflow is missing, disabled, or has a different identity'
|
|
153
|
+
)
|
|
154
|
+
const runs = pages(
|
|
155
|
+
api(
|
|
156
|
+
`repos/${policy.repository}/actions/workflows/${workflow.id}/runs?head_sha=${sourceSha}&per_page=100`,
|
|
157
|
+
true
|
|
158
|
+
),
|
|
159
|
+
'workflow_runs'
|
|
160
|
+
)
|
|
161
|
+
.filter(
|
|
162
|
+
(item) =>
|
|
163
|
+
['push', 'workflow_dispatch'].includes(item.event) && item.head_branch === policy.branch
|
|
164
|
+
)
|
|
165
|
+
// oxlint-disable-next-line unicorn/no-array-sort
|
|
166
|
+
.sort((a, b) => b.run_number - a.run_number || b.run_attempt - a.run_attempt)
|
|
167
|
+
ensure(
|
|
168
|
+
runs.every(
|
|
169
|
+
(item) => Number.isSafeInteger(item.run_number) && Number.isSafeInteger(item.run_attempt)
|
|
170
|
+
),
|
|
171
|
+
'Malformed qualification run ordering'
|
|
172
|
+
)
|
|
173
|
+
const current = runs[0]
|
|
174
|
+
ensure(
|
|
175
|
+
current &&
|
|
176
|
+
Number.isSafeInteger(current.id) &&
|
|
177
|
+
Number.isSafeInteger(current.run_attempt) &&
|
|
178
|
+
current.run_attempt > 0 &&
|
|
179
|
+
current.workflow_id === workflow.id &&
|
|
180
|
+
current.path === policy.workflow &&
|
|
181
|
+
current.head_sha === sourceSha &&
|
|
182
|
+
current.repository?.full_name === policy.repository &&
|
|
183
|
+
current.head_repository?.full_name === policy.repository,
|
|
184
|
+
'No trusted qualification run matches the exact source and workflow'
|
|
185
|
+
)
|
|
186
|
+
ensure(
|
|
187
|
+
current.status === 'completed' && current.conclusion === 'success',
|
|
188
|
+
'Latest matching qualification has not passed; older successes do not authorize rollout'
|
|
189
|
+
)
|
|
190
|
+
const jobs = pages(
|
|
191
|
+
api(
|
|
192
|
+
`repos/${policy.repository}/actions/runs/${current.id}/attempts/${current.run_attempt}/jobs?per_page=100`,
|
|
193
|
+
true
|
|
194
|
+
),
|
|
195
|
+
'jobs'
|
|
196
|
+
)
|
|
197
|
+
for (const name of policy.required_jobs) {
|
|
198
|
+
const matches = jobs.filter((job) => job.name === name)
|
|
199
|
+
ensure(
|
|
200
|
+
matches.length === 1 &&
|
|
201
|
+
matches[0].status === 'completed' &&
|
|
202
|
+
matches[0].conclusion === 'success',
|
|
203
|
+
`Required qualification job did not pass: ${name}`
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
const latest = api(`repos/${policy.repository}/actions/runs/${current.id}`)
|
|
207
|
+
ensure(
|
|
208
|
+
latest.run_attempt === current.run_attempt &&
|
|
209
|
+
latest.head_sha === sourceSha &&
|
|
210
|
+
latest.status === 'completed' &&
|
|
211
|
+
latest.conclusion === 'success',
|
|
212
|
+
'Qualification run changed during verification'
|
|
213
|
+
)
|
|
214
|
+
ensure(
|
|
215
|
+
git(['rev-parse', 'HEAD']) === sourceSha &&
|
|
216
|
+
git(['status', '--porcelain', '--untracked-files=all']) === '',
|
|
217
|
+
'Runtime source changed during eligibility verification'
|
|
218
|
+
)
|
|
219
|
+
ensure(
|
|
220
|
+
!lstatSync(path).isSymbolicLink() && hash(readFileSync(path)) === hash(bytes),
|
|
221
|
+
'Release policy changed during verification'
|
|
222
|
+
)
|
|
223
|
+
return {
|
|
224
|
+
schema_version: 1,
|
|
225
|
+
kind: 'code-foundry-fleet-eligibility',
|
|
226
|
+
status: 'passed',
|
|
227
|
+
repository: policy.repository,
|
|
228
|
+
tag: version,
|
|
229
|
+
source_sha: sourceSha,
|
|
230
|
+
policy_sha256: hash(bytes),
|
|
231
|
+
workflow_id: workflow.id,
|
|
232
|
+
run_id: current.id,
|
|
233
|
+
run_attempt: current.run_attempt,
|
|
234
|
+
required_jobs: policy.required_jobs,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Every public upgrade path, including dry run, passes the same opt-in guard.
|
|
238
|
+
* @template T @param {string} root @param {string} source @param {string} version
|
|
239
|
+
* @param {() => T} operation @param {Run} [run]
|
|
240
|
+
*/
|
|
241
|
+
export function guardedFleetUpgrade(root, source, version, operation, run) {
|
|
242
|
+
const eligibility = assertFleetEligibility(root, source, version, run)
|
|
243
|
+
if (eligibility) console.error(JSON.stringify(eligibility))
|
|
244
|
+
return operation()
|
|
245
|
+
}
|
|
@@ -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
|
+
}
|