code-foundry 1.2.0 → 1.3.0
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/code-foundry.yml +1 -1
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/codeql.yml +7 -2
- package/.oxfmtrc.json +7 -0
- package/.oxlintrc.json +5 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +14 -0
- package/README.md +3 -2
- package/docs/CONFIGURATION.md +24 -24
- package/docs/WORKFLOWS.md +16 -16
- package/package.json +20 -17
- package/src/cli.mjs +60 -14
- package/src/commands/ci.mjs +166 -57
- package/src/commands/doctor.mjs +98 -31
- package/src/commands/fleet.mjs +139 -29
- package/src/commands/release.mjs +378 -96
- package/src/commands/sync.mjs +332 -104
- package/src/lib/config.mjs +8 -2
- package/src/lib/github-doctor.mjs +153 -51
- package/src/lib/overlay.mjs +6 -2
- package/src/lib/profile.mjs +26 -8
- package/src/lib/release-hook.mjs +17 -5
- package/src/lib/release-manifest.mjs +81 -19
- package/src/lib/release-policy.mjs +157 -55
- package/src/lib/test-discovery.mjs +7 -2
- package/src/runtime.mjs +259 -61
- package/.prettierignore +0 -9
- package/.prettierrc +0 -6
package/src/commands/ci.mjs
CHANGED
|
@@ -39,24 +39,46 @@ export function manageCiBilling(root, action) {
|
|
|
39
39
|
if (action === 'pause') {
|
|
40
40
|
const rulesets = activeBranchRulesets(repository)
|
|
41
41
|
const candidates = rulesets.filter((ruleset) => requiredChecks(ruleset).some(isManagedCheck))
|
|
42
|
-
const inherited = candidates.filter(
|
|
42
|
+
const inherited = candidates.filter(
|
|
43
|
+
(ruleset) => ruleset.source_type !== 'Repository' || ruleset.source !== repository
|
|
44
|
+
)
|
|
43
45
|
if (inherited.length) {
|
|
44
|
-
throw new Error(
|
|
46
|
+
throw new Error(
|
|
47
|
+
`${CI_BILLING_REQUIRED_CHECK} is enforced by an inherited ruleset that this repository cannot safely edit.`
|
|
48
|
+
)
|
|
45
49
|
}
|
|
46
50
|
if (!candidates.length) {
|
|
47
51
|
if (paused && backup) {
|
|
48
52
|
const cancelledRuns = cancelActiveRuns(repository)
|
|
49
|
-
const result = {
|
|
53
|
+
const result = {
|
|
54
|
+
repository,
|
|
55
|
+
paused: true,
|
|
56
|
+
changed: false,
|
|
57
|
+
cancelledRuns,
|
|
58
|
+
rulesets: backup.rulesets.map((/** @type {any} */ ruleset) => ruleset.rulesetName),
|
|
59
|
+
}
|
|
50
60
|
console.log(JSON.stringify(result, null, 2))
|
|
51
61
|
return result
|
|
52
62
|
}
|
|
53
|
-
throw new Error(
|
|
63
|
+
throw new Error(
|
|
64
|
+
`No active branch ruleset requires ${CI_BILLING_REQUIRED_CHECK}; refusing to pause without a restorable managed gate.`
|
|
65
|
+
)
|
|
54
66
|
}
|
|
55
67
|
if (backup && !paused) {
|
|
56
|
-
throw new Error(
|
|
68
|
+
throw new Error(
|
|
69
|
+
`${CI_BILLING_BACKUP_VARIABLE} already exists while CI is active; inspect or remove the stale backup before pausing.`
|
|
70
|
+
)
|
|
57
71
|
}
|
|
58
|
-
if (
|
|
59
|
-
|
|
72
|
+
if (
|
|
73
|
+
backup &&
|
|
74
|
+
candidates.some(
|
|
75
|
+
(ruleset) =>
|
|
76
|
+
!backup.rulesets.some((/** @type {any} */ saved) => saved.rulesetId === ruleset.id)
|
|
77
|
+
)
|
|
78
|
+
) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`${CI_BILLING_BACKUP_VARIABLE} does not cover every managed ruleset; refusing to overwrite the recovery state.`
|
|
81
|
+
)
|
|
60
82
|
}
|
|
61
83
|
|
|
62
84
|
const changes = candidates.map(buildPausedRuleset)
|
|
@@ -65,7 +87,13 @@ export function manageCiBilling(root, action) {
|
|
|
65
87
|
setVariable(repository, CI_BILLING_PAUSED_VARIABLE, 'true')
|
|
66
88
|
const cancelledRuns = cancelActiveRuns(repository)
|
|
67
89
|
for (const change of changes) updateRuleset(repository, change.ruleset)
|
|
68
|
-
const result = {
|
|
90
|
+
const result = {
|
|
91
|
+
repository,
|
|
92
|
+
paused: true,
|
|
93
|
+
changed: true,
|
|
94
|
+
cancelledRuns,
|
|
95
|
+
rulesets: changes.map((change) => change.ruleset.name),
|
|
96
|
+
}
|
|
69
97
|
console.log(JSON.stringify(result, null, 2))
|
|
70
98
|
return result
|
|
71
99
|
}
|
|
@@ -76,14 +104,18 @@ export function manageCiBilling(root, action) {
|
|
|
76
104
|
console.log(JSON.stringify(result, null, 2))
|
|
77
105
|
return result
|
|
78
106
|
}
|
|
79
|
-
throw new Error(
|
|
107
|
+
throw new Error(
|
|
108
|
+
`${CI_BILLING_PAUSED_VARIABLE} is true but ${CI_BILLING_BACKUP_VARIABLE} is missing; refusing an unsafe resume.`
|
|
109
|
+
)
|
|
80
110
|
}
|
|
81
111
|
|
|
82
112
|
let changed = paused
|
|
83
113
|
for (const saved of backup.rulesets) {
|
|
84
114
|
const current = apiJson(repository, `repos/${repository}/rulesets/${saved.rulesetId}`)
|
|
85
115
|
if (!current || current.name !== saved.rulesetName) {
|
|
86
|
-
throw new Error(
|
|
116
|
+
throw new Error(
|
|
117
|
+
`The backed-up ruleset ${saved.rulesetName} (${saved.rulesetId}) is unavailable; refusing an unsafe resume.`
|
|
118
|
+
)
|
|
87
119
|
}
|
|
88
120
|
const restored = buildResumedRuleset(current, saved)
|
|
89
121
|
if (restored.changed) updateRuleset(repository, restored.ruleset)
|
|
@@ -91,27 +123,44 @@ export function manageCiBilling(root, action) {
|
|
|
91
123
|
}
|
|
92
124
|
setVariable(repository, CI_BILLING_PAUSED_VARIABLE, 'false')
|
|
93
125
|
deleteVariable(repository, CI_BILLING_BACKUP_VARIABLE)
|
|
94
|
-
const result = {
|
|
126
|
+
const result = {
|
|
127
|
+
repository,
|
|
128
|
+
paused: false,
|
|
129
|
+
changed,
|
|
130
|
+
rulesets: backup.rulesets.map((/** @type {any} */ ruleset) => ruleset.rulesetName),
|
|
131
|
+
}
|
|
95
132
|
console.log(JSON.stringify(result, null, 2))
|
|
96
133
|
return result
|
|
97
134
|
}
|
|
98
135
|
|
|
99
136
|
/** @param {any} ruleset */
|
|
100
137
|
export function buildPausedRuleset(ruleset) {
|
|
101
|
-
const requiredRuleIndexes = (ruleset.rules ?? []).flatMap(
|
|
102
|
-
|
|
138
|
+
const requiredRuleIndexes = (ruleset.rules ?? []).flatMap(
|
|
139
|
+
(/** @type {any} */ rule, /** @type {number} */ index) =>
|
|
140
|
+
rule?.type === 'required_status_checks' &&
|
|
141
|
+
(rule.parameters?.required_status_checks ?? []).some(isManagedCheck)
|
|
142
|
+
? [index]
|
|
143
|
+
: []
|
|
103
144
|
)
|
|
104
|
-
if (requiredRuleIndexes.length > 1)
|
|
145
|
+
if (requiredRuleIndexes.length > 1)
|
|
146
|
+
throw new Error(
|
|
147
|
+
`${CI_BILLING_REQUIRED_CHECK} appears in multiple rules; refusing an ambiguous pause.`
|
|
148
|
+
)
|
|
105
149
|
const requiredRuleIndex = requiredRuleIndexes[0]
|
|
106
150
|
const requiredRule = ruleset.rules?.[requiredRuleIndex]
|
|
107
151
|
const matches = (requiredRule?.parameters?.required_status_checks ?? []).filter(isManagedCheck)
|
|
108
|
-
if (!matches.length)
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
152
|
+
if (!matches.length)
|
|
153
|
+
throw new Error(`${CI_BILLING_REQUIRED_CHECK} is not present in the selected ruleset.`)
|
|
154
|
+
const remaining = (requiredRule.parameters?.required_status_checks ?? []).filter(
|
|
155
|
+
(/** @type {any} */ check) => !isManagedCheck(check)
|
|
156
|
+
)
|
|
157
|
+
const rules = (ruleset.rules ?? []).flatMap(
|
|
158
|
+
(/** @type {any} */ rule, /** @type {number} */ index) => {
|
|
159
|
+
if (index !== requiredRuleIndex) return [rule]
|
|
160
|
+
if (!remaining.length) return []
|
|
161
|
+
return [{ ...rule, parameters: { ...rule.parameters, required_status_checks: remaining } }]
|
|
162
|
+
}
|
|
163
|
+
)
|
|
115
164
|
return {
|
|
116
165
|
ruleset: { ...ruleset, rules },
|
|
117
166
|
backup: {
|
|
@@ -119,7 +168,9 @@ export function buildPausedRuleset(ruleset) {
|
|
|
119
168
|
rulesetName: ruleset.name,
|
|
120
169
|
checks: matches,
|
|
121
170
|
parameters: {
|
|
122
|
-
strict_required_status_checks_policy: Boolean(
|
|
171
|
+
strict_required_status_checks_policy: Boolean(
|
|
172
|
+
requiredRule?.parameters?.strict_required_status_checks_policy
|
|
173
|
+
),
|
|
123
174
|
do_not_enforce_on_create: Boolean(requiredRule?.parameters?.do_not_enforce_on_create),
|
|
124
175
|
},
|
|
125
176
|
},
|
|
@@ -151,34 +202,46 @@ export function buildResumedRuleset(ruleset, backup) {
|
|
|
151
202
|
|
|
152
203
|
/** @param {any} backup */
|
|
153
204
|
function validateBackup(backup) {
|
|
154
|
-
return
|
|
205
|
+
return (
|
|
206
|
+
backup?.version === 1 &&
|
|
155
207
|
Array.isArray(backup.rulesets) &&
|
|
156
208
|
backup.rulesets.length > 0 &&
|
|
157
|
-
backup.rulesets.every(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
209
|
+
backup.rulesets.every(
|
|
210
|
+
(/** @type {any} */ ruleset) =>
|
|
211
|
+
Number.isInteger(ruleset?.rulesetId) &&
|
|
212
|
+
typeof ruleset.rulesetName === 'string' &&
|
|
213
|
+
Array.isArray(ruleset.checks) &&
|
|
214
|
+
ruleset.checks.length > 0 &&
|
|
215
|
+
ruleset.checks.every(isManagedCheck) &&
|
|
216
|
+
ruleset.parameters &&
|
|
217
|
+
typeof ruleset.parameters === 'object'
|
|
164
218
|
)
|
|
219
|
+
)
|
|
165
220
|
}
|
|
166
221
|
|
|
167
222
|
/** @param {string} value */
|
|
168
223
|
function parseBackup(value) {
|
|
169
224
|
let backup
|
|
170
|
-
try {
|
|
171
|
-
|
|
172
|
-
|
|
225
|
+
try {
|
|
226
|
+
backup = JSON.parse(value)
|
|
227
|
+
} catch {
|
|
228
|
+
throw new Error(`${CI_BILLING_BACKUP_VARIABLE} is not valid JSON; refusing to change CI state.`)
|
|
229
|
+
}
|
|
230
|
+
if (!validateBackup(backup))
|
|
231
|
+
throw new Error(`${CI_BILLING_BACKUP_VARIABLE} is invalid; refusing to change CI state.`)
|
|
173
232
|
return backup
|
|
174
233
|
}
|
|
175
234
|
|
|
176
235
|
/** @param {any} check */
|
|
177
|
-
function isManagedCheck(check) {
|
|
236
|
+
function isManagedCheck(check) {
|
|
237
|
+
return check?.context === CI_BILLING_REQUIRED_CHECK
|
|
238
|
+
}
|
|
178
239
|
|
|
179
240
|
/** @param {any} ruleset @returns {any[]} */
|
|
180
241
|
function requiredChecks(ruleset) {
|
|
181
|
-
return (ruleset?.rules ?? []).flatMap((/** @type {any} */ rule) =>
|
|
242
|
+
return (ruleset?.rules ?? []).flatMap((/** @type {any} */ rule) =>
|
|
243
|
+
rule?.type === 'required_status_checks' ? (rule.parameters?.required_status_checks ?? []) : []
|
|
244
|
+
)
|
|
182
245
|
}
|
|
183
246
|
|
|
184
247
|
/** @param {string} repository @returns {any[]} */
|
|
@@ -187,7 +250,10 @@ function activeBranchRulesets(repository) {
|
|
|
187
250
|
if (!Array.isArray(summaries)) throw new Error('Unable to read repository rulesets.')
|
|
188
251
|
return summaries
|
|
189
252
|
.map((ruleset) => apiJson(repository, `repos/${repository}/rulesets/${ruleset.id}`))
|
|
190
|
-
.filter(
|
|
253
|
+
.filter(
|
|
254
|
+
(/** @type {any} */ ruleset) =>
|
|
255
|
+
ruleset && ruleset.enforcement === 'active' && ruleset.target === 'branch'
|
|
256
|
+
)
|
|
191
257
|
}
|
|
192
258
|
|
|
193
259
|
/** @param {string} repository @param {any} ruleset */
|
|
@@ -200,26 +266,42 @@ function updateRuleset(repository, ruleset) {
|
|
|
200
266
|
conditions: ruleset.conditions,
|
|
201
267
|
rules: ruleset.rules,
|
|
202
268
|
}
|
|
203
|
-
const result = spawnSync(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
269
|
+
const result = spawnSync(
|
|
270
|
+
'gh',
|
|
271
|
+
['api', '--method', 'PUT', `repos/${repository}/rulesets/${ruleset.id}`, '--input', '-'],
|
|
272
|
+
{
|
|
273
|
+
encoding: 'utf8',
|
|
274
|
+
input: JSON.stringify(payload),
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
if (result.status !== 0)
|
|
278
|
+
throw new Error(
|
|
279
|
+
`Unable to update ruleset ${ruleset.name}: ${result.stderr.trim() || 'GitHub API request failed'}`
|
|
280
|
+
)
|
|
208
281
|
}
|
|
209
282
|
|
|
210
283
|
/** @param {string} repository @returns {number[]} */
|
|
211
284
|
function cancelActiveRuns(repository) {
|
|
212
285
|
const ids = new Set()
|
|
213
286
|
for (const status of ['queued', 'in_progress', 'requested', 'waiting', 'pending']) {
|
|
214
|
-
const response = apiJson(
|
|
287
|
+
const response = apiJson(
|
|
288
|
+
repository,
|
|
289
|
+
`repos/${repository}/actions/runs?status=${status}&per_page=100`
|
|
290
|
+
)
|
|
215
291
|
for (const run of response?.workflow_runs ?? []) {
|
|
216
292
|
if (Number.isInteger(run?.id)) ids.add(run.id)
|
|
217
293
|
}
|
|
218
294
|
}
|
|
219
295
|
for (const id of ids) {
|
|
220
|
-
const result = spawnSync(
|
|
296
|
+
const result = spawnSync(
|
|
297
|
+
'gh',
|
|
298
|
+
['api', '--method', 'POST', `repos/${repository}/actions/runs/${id}/cancel`],
|
|
299
|
+
{ encoding: 'utf8' }
|
|
300
|
+
)
|
|
221
301
|
if (result.status !== 0 && !/409|cannot be cancelled/i.test(result.stderr)) {
|
|
222
|
-
throw new Error(
|
|
302
|
+
throw new Error(
|
|
303
|
+
`Unable to cancel workflow run ${id}: ${result.stderr.trim() || 'GitHub API request failed'}`
|
|
304
|
+
)
|
|
223
305
|
}
|
|
224
306
|
}
|
|
225
307
|
return [...ids]
|
|
@@ -227,42 +309,69 @@ function cancelActiveRuns(repository) {
|
|
|
227
309
|
|
|
228
310
|
/** @param {string} repository @param {string} name @returns {string|null} */
|
|
229
311
|
function readVariable(repository, name) {
|
|
230
|
-
const result = spawnSync('gh', ['api', `repos/${repository}/actions/variables/${name}`], {
|
|
312
|
+
const result = spawnSync('gh', ['api', `repos/${repository}/actions/variables/${name}`], {
|
|
313
|
+
encoding: 'utf8',
|
|
314
|
+
})
|
|
231
315
|
if (result.status !== 0) {
|
|
232
316
|
if (/404|not found/i.test(result.stderr)) return null
|
|
233
|
-
throw new Error(
|
|
317
|
+
throw new Error(
|
|
318
|
+
`Unable to read repository variable ${name}: ${result.stderr.trim() || 'GitHub API request failed'}`
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
return JSON.parse(result.stdout)?.value ?? null
|
|
323
|
+
} catch {
|
|
324
|
+
throw new Error(`GitHub returned invalid JSON for repository variable ${name}.`)
|
|
234
325
|
}
|
|
235
|
-
try { return JSON.parse(result.stdout)?.value ?? null }
|
|
236
|
-
catch { throw new Error(`GitHub returned invalid JSON for repository variable ${name}.`) }
|
|
237
326
|
}
|
|
238
327
|
|
|
239
328
|
/** @param {string} repository @param {string} name @param {string} value */
|
|
240
329
|
function setVariable(repository, name, value) {
|
|
241
|
-
const result = spawnSync('gh', ['variable', 'set', name, '--repo', repository, '--body', value], {
|
|
242
|
-
|
|
330
|
+
const result = spawnSync('gh', ['variable', 'set', name, '--repo', repository, '--body', value], {
|
|
331
|
+
encoding: 'utf8',
|
|
332
|
+
})
|
|
333
|
+
if (result.status !== 0)
|
|
334
|
+
throw new Error(
|
|
335
|
+
`Unable to set repository variable ${name}: ${result.stderr.trim() || 'GitHub CLI request failed'}`
|
|
336
|
+
)
|
|
243
337
|
}
|
|
244
338
|
|
|
245
339
|
/** @param {string} repository @param {string} name */
|
|
246
340
|
function deleteVariable(repository, name) {
|
|
247
|
-
const result = spawnSync('gh', ['variable', 'delete', name, '--repo', repository], {
|
|
248
|
-
|
|
341
|
+
const result = spawnSync('gh', ['variable', 'delete', name, '--repo', repository], {
|
|
342
|
+
encoding: 'utf8',
|
|
343
|
+
})
|
|
344
|
+
if (result.status !== 0)
|
|
345
|
+
throw new Error(
|
|
346
|
+
`Unable to delete repository variable ${name}: ${result.stderr.trim() || 'GitHub CLI request failed'}`
|
|
347
|
+
)
|
|
249
348
|
}
|
|
250
349
|
|
|
251
350
|
/** @param {string} repository @param {string} endpoint @returns {any} */
|
|
252
351
|
function apiJson(repository, endpoint) {
|
|
253
352
|
const result = spawnSync('gh', ['api', endpoint], { encoding: 'utf8' })
|
|
254
|
-
if (result.status !== 0)
|
|
255
|
-
|
|
256
|
-
|
|
353
|
+
if (result.status !== 0)
|
|
354
|
+
throw new Error(
|
|
355
|
+
`Unable to read ${endpoint} for ${repository}: ${result.stderr.trim() || 'GitHub API request failed'}`
|
|
356
|
+
)
|
|
357
|
+
try {
|
|
358
|
+
return JSON.parse(result.stdout)
|
|
359
|
+
} catch {
|
|
360
|
+
throw new Error(`GitHub returned invalid JSON for ${endpoint}.`)
|
|
361
|
+
}
|
|
257
362
|
}
|
|
258
363
|
|
|
259
364
|
/** @param {string} root @returns {string} */
|
|
260
365
|
function remoteRepository(root) {
|
|
261
366
|
const result = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8' })
|
|
262
367
|
const value = result.status === 0 ? result.stdout.trim() : ''
|
|
263
|
-
return value
|
|
368
|
+
return value
|
|
369
|
+
.replace(/^git@github\.com:/, '')
|
|
370
|
+
.replace(/^https?:\/\/github\.com\//, '')
|
|
371
|
+
.replace(/\.git$/, '')
|
|
264
372
|
}
|
|
265
373
|
|
|
266
374
|
function requireGh() {
|
|
267
|
-
if (spawnSync('gh', ['--version'], { stdio: 'ignore' }).status !== 0)
|
|
375
|
+
if (spawnSync('gh', ['--version'], { stdio: 'ignore' }).status !== 0)
|
|
376
|
+
throw new Error('CI billing controls require the `gh` CLI.')
|
|
268
377
|
}
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -16,56 +16,97 @@ export function doctor(root, options = {}) {
|
|
|
16
16
|
const recommendations = recommendRunners(target)
|
|
17
17
|
let errors = 0
|
|
18
18
|
/** @param {string} message */
|
|
19
|
-
const error = (message) => {
|
|
19
|
+
const error = (message) => {
|
|
20
|
+
console.error(`ERROR: ${message}`)
|
|
21
|
+
errors += 1
|
|
22
|
+
}
|
|
20
23
|
/** @param {string} message */
|
|
21
24
|
const warn = (message) => console.warn(`WARN: ${message}`)
|
|
22
25
|
|
|
23
26
|
const toolchain = config.toolchain ?? 'auto'
|
|
24
27
|
if (!['auto', 'native', 'mise'].includes(toolchain)) error(`unsupported toolchain: ${toolchain}`)
|
|
25
|
-
if (toolchain === 'mise' && !existsSync(join(target, '.mise.toml')))
|
|
28
|
+
if (toolchain === 'mise' && !existsSync(join(target, '.mise.toml')))
|
|
29
|
+
error('.mise.toml is required when toolchain: mise')
|
|
26
30
|
if (toolchain === 'auto') {
|
|
27
|
-
console.log(
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
console.log(
|
|
32
|
+
existsSync(join(target, '.mise.toml'))
|
|
33
|
+
? 'Toolchain: mise (detected from existing .mise.toml).'
|
|
34
|
+
: 'Toolchain: native (mise not configured).'
|
|
35
|
+
)
|
|
30
36
|
}
|
|
31
37
|
const hooks = git(target, ['config', '--get', 'core.hooksPath'])
|
|
32
38
|
if (hooks !== '.githooks') warn('Git hooks are not enabled; run `npx code-foundry init`')
|
|
33
|
-
if (
|
|
34
|
-
|
|
39
|
+
if (
|
|
40
|
+
['rust', 'python', 'solidity'].some((language) =>
|
|
41
|
+
profile.languages.split(',').includes(language)
|
|
42
|
+
) &&
|
|
43
|
+
(config.unit_runner ?? recommendations.unit_runner) === 'ubuntu-slim'
|
|
44
|
+
) {
|
|
45
|
+
warn(
|
|
46
|
+
'unit_runner is ubuntu-slim for a native-toolchain repository; ubuntu-latest is recommended.'
|
|
47
|
+
)
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
const packageFile = join(target, 'package.json')
|
|
38
51
|
if (existsSync(packageFile)) {
|
|
39
52
|
let packageJson
|
|
40
|
-
try {
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
try {
|
|
54
|
+
packageJson = JSON.parse(readFileSync(packageFile, 'utf8'))
|
|
55
|
+
} catch {
|
|
56
|
+
error('package.json is not valid JSON')
|
|
57
|
+
packageJson = {}
|
|
58
|
+
}
|
|
59
|
+
const lockfiles = [
|
|
60
|
+
'bun.lock',
|
|
61
|
+
'bun.lockb',
|
|
62
|
+
'pnpm-lock.yaml',
|
|
63
|
+
'yarn.lock',
|
|
64
|
+
'package-lock.json',
|
|
65
|
+
].filter((file) => existsSync(join(target, file)))
|
|
43
66
|
const groups = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
|
|
44
|
-
if (!lockfiles.length && groups.some((key) => Object.keys(packageJson[key] ?? {}).length))
|
|
67
|
+
if (!lockfiles.length && groups.some((key) => Object.keys(packageJson[key] ?? {}).length))
|
|
68
|
+
error('package.json exists but no supported lockfile was found')
|
|
45
69
|
if (lockfiles.length > 1) error('multiple JavaScript lockfiles found; keep one package manager')
|
|
46
70
|
if (packageJson.packageManager && lockfiles.length) {
|
|
47
71
|
const declared = String(packageJson.packageManager).split('@')[0]
|
|
48
|
-
const actual = lockfiles[0]?.startsWith('bun')
|
|
49
|
-
|
|
72
|
+
const actual = lockfiles[0]?.startsWith('bun')
|
|
73
|
+
? 'bun'
|
|
74
|
+
: lockfiles[0]?.split('-')[0].replace('.yaml', '')
|
|
75
|
+
if (actual && declared !== actual)
|
|
76
|
+
error(`packageManager (${declared}) does not match ${actual} lockfile`)
|
|
50
77
|
}
|
|
51
78
|
}
|
|
52
79
|
|
|
53
|
-
if (profile.languages.split(',').includes('rust') && !commandExists('cargo'))
|
|
80
|
+
if (profile.languages.split(',').includes('rust') && !commandExists('cargo'))
|
|
81
|
+
error('Cargo is required for this repository')
|
|
54
82
|
if (profile.languages.split(',').includes('rust')) {
|
|
55
|
-
const metadata = spawnSync('cargo', ['metadata', '--no-deps', '--format-version', '1'], {
|
|
83
|
+
const metadata = spawnSync('cargo', ['metadata', '--no-deps', '--format-version', '1'], {
|
|
84
|
+
cwd: target,
|
|
85
|
+
stdio: 'ignore',
|
|
86
|
+
})
|
|
56
87
|
if (metadata.status !== 0) error('cargo metadata failed')
|
|
57
88
|
}
|
|
58
|
-
if (
|
|
89
|
+
if (
|
|
90
|
+
profile.languages.split(',').includes('python') &&
|
|
91
|
+
!commandExists('python') &&
|
|
92
|
+
!existsSync(join(target, '.venv/bin/python'))
|
|
93
|
+
)
|
|
94
|
+
error('Python is required for this repository')
|
|
59
95
|
|
|
60
96
|
const releaseConfigPath = join(target, 'release-please-config.json')
|
|
61
97
|
if (existsSync(releaseConfigPath)) {
|
|
62
98
|
/** @type {Record<string, any>} */
|
|
63
99
|
let releaseConfig = {}
|
|
64
|
-
try {
|
|
65
|
-
|
|
100
|
+
try {
|
|
101
|
+
releaseConfig = JSON.parse(readFileSync(releaseConfigPath, 'utf8'))
|
|
102
|
+
} catch {
|
|
103
|
+
error('release-please-config.json is not valid JSON')
|
|
104
|
+
}
|
|
66
105
|
const manifestMode = Boolean(releaseConfig.packages || releaseConfig['release-type'])
|
|
67
106
|
if (manifestMode && !existsSync(join(target, '.release-please-manifest.json'))) {
|
|
68
|
-
error(
|
|
107
|
+
error(
|
|
108
|
+
'release-please-config.json requires .release-please-manifest.json; run code-foundry sync to bootstrap it'
|
|
109
|
+
)
|
|
69
110
|
}
|
|
70
111
|
}
|
|
71
112
|
|
|
@@ -76,17 +117,24 @@ export function doctor(root, options = {}) {
|
|
|
76
117
|
}
|
|
77
118
|
const mergeStrategy = config.merge_strategy ?? 'rebase'
|
|
78
119
|
if (workflow === 'staging-release' && mergeStrategy !== 'rebase') {
|
|
79
|
-
error(
|
|
120
|
+
error(
|
|
121
|
+
`merge_strategy must be "rebase" for the staging-release promotion topology; got "${mergeStrategy}".`
|
|
122
|
+
)
|
|
80
123
|
}
|
|
81
124
|
const releaseMergeStrategy = config.release_merge_strategy ?? ''
|
|
82
125
|
if (includesValue(features, 'release') && releaseMergeStrategy !== 'rebase') {
|
|
83
|
-
error(
|
|
126
|
+
error(
|
|
127
|
+
`release_merge_strategy must be "rebase" for automated release merges; got "${releaseMergeStrategy || '(unset; release automation never defaults to merge)'}".`
|
|
128
|
+
)
|
|
84
129
|
}
|
|
85
130
|
for (const name of ['validation', 'draft-pr', 'release-pr', 'release']) {
|
|
86
131
|
if (name === 'release-pr' && workflow !== 'staging-release') continue
|
|
87
|
-
if (includesValue(features, name) && !existsSync(join(target, `.github/workflows/${name}.yml`)))
|
|
132
|
+
if (includesValue(features, name) && !existsSync(join(target, `.github/workflows/${name}.yml`)))
|
|
133
|
+
error(`missing enabled workflow: ${name}.yml`)
|
|
88
134
|
}
|
|
89
|
-
const validationEnabled =
|
|
135
|
+
const validationEnabled =
|
|
136
|
+
includesValue(features, 'validation') ||
|
|
137
|
+
['ci', 'test', 'security', 'codeql'].some((legacy) => includesValue(features, legacy))
|
|
90
138
|
const validationCaller = ['validation.yml', 'validation_self-ci.yml']
|
|
91
139
|
.map((file) => join(target, `.github/workflows/${file}`))
|
|
92
140
|
.find((file) => existsSync(file) && /pull_request:/.test(readFileSync(file, 'utf8')))
|
|
@@ -95,15 +143,23 @@ export function doctor(root, options = {}) {
|
|
|
95
143
|
} else if (validationCaller) {
|
|
96
144
|
const caller = readFileSync(validationCaller, 'utf8')
|
|
97
145
|
if (!/^ validation:\n name: Validation/m.test(caller)) {
|
|
98
|
-
error(
|
|
146
|
+
error(
|
|
147
|
+
'validation caller is missing the Validation job; the Validation / Gate aggregate check cannot form.'
|
|
148
|
+
)
|
|
99
149
|
}
|
|
100
|
-
if (
|
|
150
|
+
if (
|
|
151
|
+
!/uses:\s+(?:\.\/\.github\/workflows\/validation\.yml|\S+\/\.github\/workflows\/validation\.yml@)/.test(
|
|
152
|
+
caller
|
|
153
|
+
)
|
|
154
|
+
) {
|
|
101
155
|
error('validation caller does not reference the validation orchestrator.')
|
|
102
156
|
}
|
|
103
157
|
const runtimeRef = caller.match(/^\s+runtime-ref:\s+(.+?)\s*$/m)?.[1]
|
|
104
158
|
const checkoutRef = caller.match(/^\s+ref:\s+(.+?)\s*$/m)?.[1]
|
|
105
159
|
if (!runtimeRef || !checkoutRef) {
|
|
106
|
-
error(
|
|
160
|
+
error(
|
|
161
|
+
'validation caller is missing runtime ref wiring (mode checkout or orchestrator input).'
|
|
162
|
+
)
|
|
107
163
|
} else if (runtimeRef !== checkoutRef) {
|
|
108
164
|
error(`validation caller pins mismatched runtime refs (${runtimeRef} vs ${checkoutRef}).`)
|
|
109
165
|
} else if (runtimeRef !== '${{ github.sha }}' && !/^v\d+\.\d+\.\d+$/.test(runtimeRef)) {
|
|
@@ -113,11 +169,18 @@ export function doctor(root, options = {}) {
|
|
|
113
169
|
const runtimeRepository = config.runtime_repository ?? '0xPlayerOne/code-foundry'
|
|
114
170
|
for (const stem of ['ci', 'test', 'security', 'codeql']) {
|
|
115
171
|
const legacy = join(target, `.github/workflows/${stem}.yml`)
|
|
116
|
-
if (
|
|
117
|
-
|
|
172
|
+
if (
|
|
173
|
+
existsSync(legacy) &&
|
|
174
|
+
isGeneratedEventCaller(readFileSync(legacy, 'utf8'), stem, runtimeRepository)
|
|
175
|
+
) {
|
|
176
|
+
warn(
|
|
177
|
+
`stale generated legacy caller ${stem}.yml still triggers canonical suites; run code-foundry sync to migrate to validation.yml.`
|
|
178
|
+
)
|
|
118
179
|
}
|
|
119
180
|
}
|
|
120
|
-
console.log(
|
|
181
|
+
console.log(
|
|
182
|
+
'Remote CI, Test, Security, CodeQL, and release runtimes are loaded by the tiered validation orchestrator.'
|
|
183
|
+
)
|
|
121
184
|
if (options.github) {
|
|
122
185
|
const github = doctorGithub(target)
|
|
123
186
|
/** @type {{ codeFoundryTokenPresent?: boolean, stagingDeployKeyPresent?: boolean }} */
|
|
@@ -127,9 +190,13 @@ export function doctor(root, options = {}) {
|
|
|
127
190
|
console.log(`GitHub doctor inspected ${github.details.repository}.`)
|
|
128
191
|
const codeFoundryToken = secrets.codeFoundryTokenPresent ? 'present' : 'absent'
|
|
129
192
|
const stagingDeployKey = secrets.stagingDeployKeyPresent ? 'present' : 'absent'
|
|
130
|
-
console.log(
|
|
193
|
+
console.log(
|
|
194
|
+
`GitHub secrets: CODE_FOUNDRY_TOKEN=${codeFoundryToken}, STAGING_DEPLOY_KEY=${stagingDeployKey}`
|
|
195
|
+
)
|
|
131
196
|
if (!secrets.codeFoundryTokenPresent) {
|
|
132
|
-
warn(
|
|
197
|
+
warn(
|
|
198
|
+
'GitHub token routing fallback: PR creation will remain draft and requires manual ready-for-review to trigger validation.'
|
|
199
|
+
)
|
|
133
200
|
}
|
|
134
201
|
}
|
|
135
202
|
if (errors) throw new Error(`Repository doctor found ${errors} error(s).`)
|