code-foundry 1.12.0 → 1.14.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/CONTRIBUTING.md +7 -6
- package/.github/workflows/draft-enforcement_self-ci.yml +3 -4
- package/.github/workflows/opencode-security_self-ci.yml +2 -1
- package/.github/workflows/validation_self-ci.yml +6 -4
- package/AGENTS.md +6 -5
- package/CHANGELOG.md +14 -0
- package/README.md +7 -6
- package/docs/PERFORMANCE.md +3 -3
- package/docs/README.md +2 -0
- package/docs/WORKFLOWS.md +14 -14
- package/docs/agent-validation.md +94 -0
- package/docs/fleet-rollouts.md +139 -0
- package/package.json +1 -1
- package/src/cli.mjs +7 -0
- package/src/commands/agent-check.mjs +309 -0
- package/src/commands/doctor.mjs +8 -3
- package/src/commands/fleet.mjs +355 -42
- package/src/commands/sync.mjs +11 -6
- package/src/lib/fleet-manifest.mjs +654 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import {
|
|
5
|
+
chmodSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs'
|
|
14
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { spawnSync } from 'node:child_process'
|
|
17
|
+
import { listValue, readConfig } from './config.mjs'
|
|
18
|
+
|
|
19
|
+
/** @typedef {{repository: string, path: string, cohort: string, profile?: string, expected?: Record<string,string>, requiredCapabilities?: string[], validation: string[][], requiredChecks: string[], exception?: {reason: string, expires: string}}} FleetEntry */
|
|
20
|
+
/** @typedef {{schemaVersion: number, cohorts: string[], repositories: FleetEntry[]}} FleetManifest */
|
|
21
|
+
/** @typedef {{status: number|null, stdout: string, stderr: string}} CommandResult */
|
|
22
|
+
/** @typedef {(cwd: string, argv: string[]) => CommandResult} Runner */
|
|
23
|
+
/** @typedef {(options: {target: string, source: string, force: boolean, runtimeRef: string, configureHooks?: boolean}) => {changed: string[]}} Synchronize */
|
|
24
|
+
|
|
25
|
+
/** @type {Runner} */
|
|
26
|
+
export function executeFleetCommand(cwd, argv) {
|
|
27
|
+
const result = spawnSync(argv[0], argv.slice(1), {
|
|
28
|
+
cwd,
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
timeout: 600000,
|
|
31
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
32
|
+
env: { ...process.env, GH_PROMPT_DISABLED: '1', GIT_TERMINAL_PROMPT: '0' },
|
|
33
|
+
})
|
|
34
|
+
if (result.error) throw result.error
|
|
35
|
+
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {string} root */
|
|
39
|
+
export function hasFleetManifest(root) {
|
|
40
|
+
return existsSync(join(root, 'code-foundry-fleet.json'))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {unknown} value @param {string} label @returns {string[]} */
|
|
44
|
+
function strings(value, label) {
|
|
45
|
+
if (
|
|
46
|
+
!Array.isArray(value) ||
|
|
47
|
+
!value.length ||
|
|
48
|
+
value.some((item) => typeof item !== 'string' || !item.trim() || item.includes('\0'))
|
|
49
|
+
)
|
|
50
|
+
throw new Error(`${label} must be a non-empty string array`)
|
|
51
|
+
if (new Set(value).size !== value.length) throw new Error(`${label} contains duplicates`)
|
|
52
|
+
return value
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param {string} root @param {string} path */
|
|
56
|
+
export function fleetPath(root, path) {
|
|
57
|
+
if (typeof path !== 'string' || !path || isAbsolute(path))
|
|
58
|
+
throw new Error('Fleet paths must be relative to the manifest root')
|
|
59
|
+
const base = realpathSync(root)
|
|
60
|
+
const target = resolve(base, path)
|
|
61
|
+
const rel = relative(base, target)
|
|
62
|
+
if (!rel || rel === '..' || rel.startsWith(`..${sep}`))
|
|
63
|
+
throw new Error('Fleet repository path escapes or equals the root')
|
|
64
|
+
let current = base
|
|
65
|
+
for (const part of rel.split(sep)) {
|
|
66
|
+
current = join(current, part)
|
|
67
|
+
if (existsSync(current) && lstatSync(current).isSymbolicLink())
|
|
68
|
+
throw new Error('Fleet repository paths must not contain symlinks')
|
|
69
|
+
}
|
|
70
|
+
return target
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {string} root @param {Date} [now] @returns {FleetManifest} */
|
|
74
|
+
export function readFleetManifest(root, now = new Date()) {
|
|
75
|
+
const value = JSON.parse(readFileSync(join(root, 'code-foundry-fleet.json'), 'utf8'))
|
|
76
|
+
if (value?.schemaVersion !== 1) throw new Error('Unsupported fleet manifest schema')
|
|
77
|
+
const cohorts = strings(value.cohorts, 'cohorts')
|
|
78
|
+
if (!Array.isArray(value.repositories) || !value.repositories.length)
|
|
79
|
+
throw new Error('Fleet repositories must be a non-empty array')
|
|
80
|
+
const repositories = new Set()
|
|
81
|
+
const paths = new Set()
|
|
82
|
+
for (const entry of value.repositories) {
|
|
83
|
+
if (!entry || !/^[\w.-]+\/[\w.-]+$/.test(entry.repository ?? ''))
|
|
84
|
+
throw new Error('Invalid fleet repository name')
|
|
85
|
+
const path = fleetPath(root, entry.path)
|
|
86
|
+
if (repositories.has(entry.repository.toLowerCase()) || paths.has(path))
|
|
87
|
+
throw new Error('Duplicate fleet repository or path')
|
|
88
|
+
repositories.add(entry.repository.toLowerCase())
|
|
89
|
+
paths.add(path)
|
|
90
|
+
if (!cohorts.includes(entry.cohort)) throw new Error(`Unknown cohort for ${entry.repository}`)
|
|
91
|
+
if (entry.profile !== undefined && (typeof entry.profile !== 'string' || !entry.profile.trim()))
|
|
92
|
+
throw new Error('profile must be a non-empty annotation')
|
|
93
|
+
if (
|
|
94
|
+
entry.expected !== undefined &&
|
|
95
|
+
(!entry.expected ||
|
|
96
|
+
Array.isArray(entry.expected) ||
|
|
97
|
+
typeof entry.expected !== 'object' ||
|
|
98
|
+
Object.values(entry.expected).some((item) => typeof item !== 'string'))
|
|
99
|
+
)
|
|
100
|
+
throw new Error('expected must map Code Foundry config keys to scalar strings')
|
|
101
|
+
if (entry.requiredCapabilities !== undefined)
|
|
102
|
+
strings(entry.requiredCapabilities, 'requiredCapabilities')
|
|
103
|
+
if (!Array.isArray(entry.validation) || !entry.validation.length)
|
|
104
|
+
throw new Error('Every repository requires explicit validation argv commands')
|
|
105
|
+
for (const argv of entry.validation) {
|
|
106
|
+
if (
|
|
107
|
+
!Array.isArray(argv) ||
|
|
108
|
+
!argv.length ||
|
|
109
|
+
argv.some((arg) => typeof arg !== 'string' || !arg.length || arg.includes('\0'))
|
|
110
|
+
)
|
|
111
|
+
throw new Error('Validation commands must be non-empty argv arrays')
|
|
112
|
+
}
|
|
113
|
+
entry.requiredChecks = strings(entry.requiredChecks ?? ['Validation / Gate'], 'requiredChecks')
|
|
114
|
+
if (entry.exception) {
|
|
115
|
+
if (
|
|
116
|
+
typeof entry.exception.reason !== 'string' ||
|
|
117
|
+
!entry.exception.reason.trim() ||
|
|
118
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(entry.exception.expires ?? '')
|
|
119
|
+
)
|
|
120
|
+
throw new Error('Fleet exceptions require a reason and YYYY-MM-DD expiry')
|
|
121
|
+
const expiry = new Date(`${entry.exception.expires}T23:59:59.999Z`)
|
|
122
|
+
if (
|
|
123
|
+
!Number.isFinite(expiry.getTime()) ||
|
|
124
|
+
expiry.toISOString().slice(0, 10) !== entry.exception.expires ||
|
|
125
|
+
expiry < now
|
|
126
|
+
)
|
|
127
|
+
throw new Error(`Expired or invalid exception for ${entry.repository}`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
cohorts.some(
|
|
132
|
+
(cohort) =>
|
|
133
|
+
!value.repositories.some((/** @type {FleetEntry} */ entry) => entry.cohort === cohort)
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
throw new Error('Every cohort must contain a repository')
|
|
137
|
+
return { schemaVersion: 1, cohorts, repositories: value.repositories }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @param {string} remote */
|
|
141
|
+
export function githubRemote(remote) {
|
|
142
|
+
return (
|
|
143
|
+
remote
|
|
144
|
+
.trim()
|
|
145
|
+
.match(/^(?:git@github\.com:|https:\/\/github\.com\/)([\w.-]+\/[\w.-]+?)(?:\.git)?$/)?.[1] ??
|
|
146
|
+
''
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @param {Runner} run @param {string} cwd @param {string[]} argv */
|
|
151
|
+
function checked(run, cwd, argv) {
|
|
152
|
+
const result = run(cwd, argv)
|
|
153
|
+
if (result.status !== 0)
|
|
154
|
+
throw new Error(`${argv[0]} ${argv[1] ?? ''} failed (${result.status ?? 'signal'})`)
|
|
155
|
+
return result.stdout.trim()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @param {Runner} run @param {string} path @param {string} branch */
|
|
159
|
+
function remoteBranchHead(run, path, branch) {
|
|
160
|
+
const output = checked(run, path, [
|
|
161
|
+
'git',
|
|
162
|
+
'ls-remote',
|
|
163
|
+
'--heads',
|
|
164
|
+
'origin',
|
|
165
|
+
`refs/heads/${branch}`,
|
|
166
|
+
])
|
|
167
|
+
return output.split(/\s/)[0] ?? ''
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** @param {Record<string,string>} config @param {FleetEntry} entry */
|
|
171
|
+
export function configDrift(config, entry) {
|
|
172
|
+
const drift = Object.entries(entry.expected ?? {}).flatMap(([key, expected]) =>
|
|
173
|
+
config[key] === expected ? [] : [{ key, expected, observed: config[key] ?? null }]
|
|
174
|
+
)
|
|
175
|
+
const observed = listValue(config.required_capabilities ?? '')
|
|
176
|
+
for (const capability of entry.requiredCapabilities ?? []) {
|
|
177
|
+
if (!observed.includes(capability))
|
|
178
|
+
drift.push({
|
|
179
|
+
key: `required_capabilities:${capability}`,
|
|
180
|
+
expected: 'declared',
|
|
181
|
+
observed: 'missing',
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
return drift
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** @param {string} path */
|
|
188
|
+
function canonicalPath(path) {
|
|
189
|
+
try {
|
|
190
|
+
return realpathSync(path)
|
|
191
|
+
} catch {
|
|
192
|
+
try {
|
|
193
|
+
return join(realpathSync(dirname(path)), basename(path))
|
|
194
|
+
} catch {
|
|
195
|
+
return resolve(path)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** @param {FleetEntry} entry @param {{path: string}|undefined} item @param {string[]} excluded */
|
|
201
|
+
function isExcluded(entry, item, excluded) {
|
|
202
|
+
return excluded.some(
|
|
203
|
+
(name) =>
|
|
204
|
+
name === entry.repository ||
|
|
205
|
+
name === entry.path ||
|
|
206
|
+
(isAbsolute(name) && canonicalPath(name) === item?.path)
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @param {string} root @param {Runner} [run] */
|
|
211
|
+
export function discoverManifestRepositories(root, run = executeFleetCommand) {
|
|
212
|
+
return readFleetManifest(root).repositories.map((entry) => {
|
|
213
|
+
const path = fleetPath(root, entry.path)
|
|
214
|
+
const configured = existsSync(join(path, '.github/code-foundry.yml'))
|
|
215
|
+
const config = configured ? readConfig(join(path, '.github/code-foundry.yml')) : {}
|
|
216
|
+
const git = existsSync(join(path, '.git'))
|
|
217
|
+
let remote = ''
|
|
218
|
+
let dirty = false
|
|
219
|
+
let error = ''
|
|
220
|
+
try {
|
|
221
|
+
if (git) {
|
|
222
|
+
remote = githubRemote(checked(run, path, ['git', 'remote', 'get-url', 'origin']))
|
|
223
|
+
dirty = Boolean(checked(run, path, ['git', 'status', '--porcelain']))
|
|
224
|
+
}
|
|
225
|
+
} catch (failure) {
|
|
226
|
+
error = failure instanceof Error ? failure.message : String(failure)
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
path,
|
|
230
|
+
repository: entry.repository,
|
|
231
|
+
runtimeRef: config.runtime_ref ?? '',
|
|
232
|
+
configured,
|
|
233
|
+
dirty,
|
|
234
|
+
gitWorkflow: config.git_workflow ?? 'direct',
|
|
235
|
+
cohort: entry.cohort,
|
|
236
|
+
profile: entry.profile ?? null,
|
|
237
|
+
remote,
|
|
238
|
+
drift: configDrift(config, entry),
|
|
239
|
+
exception: entry.exception ?? null,
|
|
240
|
+
status: entry.exception
|
|
241
|
+
? 'excepted'
|
|
242
|
+
: error || !git || !configured || remote.toLowerCase() !== entry.repository.toLowerCase()
|
|
243
|
+
? 'blocked'
|
|
244
|
+
: 'discovered',
|
|
245
|
+
reason:
|
|
246
|
+
error ||
|
|
247
|
+
(!git
|
|
248
|
+
? 'missing checkout'
|
|
249
|
+
: !configured
|
|
250
|
+
? 'missing configuration'
|
|
251
|
+
: remote.toLowerCase() !== entry.repository.toLowerCase()
|
|
252
|
+
? 'origin does not match inventory'
|
|
253
|
+
: dirty
|
|
254
|
+
? 'working tree is dirty'
|
|
255
|
+
: ''),
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** @param {FleetEntry} entry @param {string} version @param {string} base */
|
|
261
|
+
export function rolloutIdentity(entry, version, base) {
|
|
262
|
+
const id = createHash('sha256').update(JSON.stringify({ entry, version, base })).digest('hex')
|
|
263
|
+
return {
|
|
264
|
+
id,
|
|
265
|
+
branch: `codex/code-foundry-upgrade-${version.replace(/^v/, '')}-${id.slice(0, 10)}`,
|
|
266
|
+
marker: `<!-- code-foundry-rollout:${id} -->`,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** @param {any} pr @param {string[]} required */
|
|
271
|
+
export function verifiedPullRequest(pr, required) {
|
|
272
|
+
if (
|
|
273
|
+
pr?.state !== 'MERGED' ||
|
|
274
|
+
pr.isCrossRepository !== false ||
|
|
275
|
+
!/^[0-9a-f]{40}$/i.test(pr.headRefOid ?? '')
|
|
276
|
+
)
|
|
277
|
+
return false
|
|
278
|
+
const checks = pr.statusCheckRollup
|
|
279
|
+
if (!Array.isArray(checks) || !checks.length) return false
|
|
280
|
+
/** @param {any} check */
|
|
281
|
+
const state = (check) =>
|
|
282
|
+
check.status === 'COMPLETED' ? check.conclusion : (check.state ?? check.status)
|
|
283
|
+
if (checks.some((check) => !['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(state(check))))
|
|
284
|
+
return false
|
|
285
|
+
return required.every((name) =>
|
|
286
|
+
checks.some((check) => (check.name ?? check.context) === name && state(check) === 'SUCCESS')
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** @param {string} text */
|
|
291
|
+
function scalarConfig(text) {
|
|
292
|
+
return Object.fromEntries(
|
|
293
|
+
text.split(/\r?\n/).flatMap((line) => {
|
|
294
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/)
|
|
295
|
+
return match ? [[match[1], match[2].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '')]] : []
|
|
296
|
+
})
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** @param {Runner} run @param {string} path @param {FleetEntry} entry @param {string} version @param {string} base */
|
|
301
|
+
function inspectRollout(run, path, entry, version, base) {
|
|
302
|
+
const identity = rolloutIdentity(entry, version, base)
|
|
303
|
+
const prs = JSON.parse(
|
|
304
|
+
checked(run, path, [
|
|
305
|
+
'gh',
|
|
306
|
+
'pr',
|
|
307
|
+
'list',
|
|
308
|
+
'--repo',
|
|
309
|
+
entry.repository,
|
|
310
|
+
'--head',
|
|
311
|
+
identity.branch,
|
|
312
|
+
'--base',
|
|
313
|
+
base,
|
|
314
|
+
'--state',
|
|
315
|
+
'all',
|
|
316
|
+
'--limit',
|
|
317
|
+
'100',
|
|
318
|
+
'--json',
|
|
319
|
+
'number,url,body,state,headRefName,headRefOid,baseRefName,isCrossRepository,isDraft,statusCheckRollup',
|
|
320
|
+
])
|
|
321
|
+
)
|
|
322
|
+
if (!Array.isArray(prs) || prs.length > 1)
|
|
323
|
+
throw new Error('Ambiguous upgrade pull requests; reconcile them manually')
|
|
324
|
+
const pr = prs[0]
|
|
325
|
+
if (
|
|
326
|
+
pr &&
|
|
327
|
+
(!pr.body?.includes(identity.marker) ||
|
|
328
|
+
pr.isCrossRepository !== false ||
|
|
329
|
+
pr.headRefName !== identity.branch ||
|
|
330
|
+
pr.baseRefName !== base)
|
|
331
|
+
)
|
|
332
|
+
throw new Error('Existing pull request does not match this rollout policy')
|
|
333
|
+
if (pr?.state === 'OPEN') {
|
|
334
|
+
const remoteHead = remoteBranchHead(run, path, identity.branch)
|
|
335
|
+
if (!remoteHead || pr.headRefOid !== remoteHead)
|
|
336
|
+
throw new Error('Existing pull request head no longer matches its remote branch')
|
|
337
|
+
}
|
|
338
|
+
let complete = verifiedPullRequest(pr, entry.requiredChecks)
|
|
339
|
+
if (complete) {
|
|
340
|
+
const response = JSON.parse(
|
|
341
|
+
checked(run, path, [
|
|
342
|
+
'gh',
|
|
343
|
+
'api',
|
|
344
|
+
`repos/${entry.repository}/contents/.github/code-foundry.yml?ref=${encodeURIComponent(base)}`,
|
|
345
|
+
])
|
|
346
|
+
)
|
|
347
|
+
if (response.encoding !== 'base64' || typeof response.content !== 'string')
|
|
348
|
+
throw new Error('Unable to verify merged runtime configuration')
|
|
349
|
+
const config = scalarConfig(Buffer.from(response.content, 'base64').toString('utf8'))
|
|
350
|
+
complete = config.runtime_ref === version && configDrift(config, entry).length === 0
|
|
351
|
+
}
|
|
352
|
+
return { ...identity, pr, complete }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** @param {Runner} run @param {string} path @param {string[]} files */
|
|
356
|
+
function candidateFingerprint(run, path, files) {
|
|
357
|
+
// A validation command can commit its changes and leave an empty diff. Bind the
|
|
358
|
+
// fingerprint to HEAD as well so resumed candidates cannot publish that commit.
|
|
359
|
+
const hash = createHash('sha256')
|
|
360
|
+
.update(checked(run, path, ['git', 'rev-parse', '--verify', 'HEAD']))
|
|
361
|
+
.update('\0')
|
|
362
|
+
.update(checked(run, path, ['git', 'diff', '--binary', 'HEAD']))
|
|
363
|
+
// Sorting a copy preserves the caller's tracked file order.
|
|
364
|
+
// oxlint-disable-next-line unicorn/no-array-sort
|
|
365
|
+
for (const file of [...files].sort()) {
|
|
366
|
+
const target = join(path, file)
|
|
367
|
+
hash.update(file).update(existsSync(target) ? readFileSync(target) : '<deleted>')
|
|
368
|
+
}
|
|
369
|
+
return hash.digest('hex')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** @param {Runner} run @param {string} worktree @param {FleetEntry} entry @param {string[]} files */
|
|
373
|
+
function validateCandidate(run, worktree, entry, files) {
|
|
374
|
+
const before = candidateFingerprint(run, worktree, files)
|
|
375
|
+
const commands = []
|
|
376
|
+
for (const argv of entry.validation) {
|
|
377
|
+
const result = run(worktree, argv)
|
|
378
|
+
commands.push({ argv, status: result.status })
|
|
379
|
+
if (result.status !== 0)
|
|
380
|
+
throw new Error(
|
|
381
|
+
`Consumer validation failed: ${argv[0]} ${argv[1] ?? ''} (${result.status ?? 'signal'})`
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
if (candidateFingerprint(run, worktree, files) !== before)
|
|
385
|
+
throw new Error('Validation modified the candidate source; use read-only check commands')
|
|
386
|
+
return commands
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** @param {Runner} run @param {Synchronize} sync @param {string} path @param {string} source @param {FleetEntry} entry @param {string} version @param {string} base @param {ReturnType<typeof rolloutIdentity>} identity */
|
|
390
|
+
function upgradeCandidate(run, sync, path, source, entry, version, base, identity) {
|
|
391
|
+
const temporary = mkdtempSync(join(tmpdir(), 'foundry-rollout-'))
|
|
392
|
+
const worktree = join(temporary, 'repo')
|
|
393
|
+
let attached = false
|
|
394
|
+
try {
|
|
395
|
+
checked(run, path, ['git', 'fetch', 'origin', base, '--quiet'])
|
|
396
|
+
const remote = remoteBranchHead(run, path, identity.branch)
|
|
397
|
+
const local = run(path, [
|
|
398
|
+
'git',
|
|
399
|
+
'rev-parse',
|
|
400
|
+
'--verify',
|
|
401
|
+
'--quiet',
|
|
402
|
+
`refs/heads/${identity.branch}`,
|
|
403
|
+
])
|
|
404
|
+
if (local.status !== 0 && local.status !== 1)
|
|
405
|
+
throw new Error('Unable to inspect existing local upgrade branch')
|
|
406
|
+
const existing = remote || (local.status === 0 ? local.stdout.trim() : '')
|
|
407
|
+
const baseline = checked(run, path, ['git', 'rev-parse', `origin/${base}`])
|
|
408
|
+
if (remote) checked(run, path, ['git', 'fetch', 'origin', identity.branch, '--quiet'])
|
|
409
|
+
if (existing) {
|
|
410
|
+
const message = checked(run, path, ['git', 'show', '-s', '--format=%B', existing])
|
|
411
|
+
const tree = checked(run, path, ['git', 'rev-parse', `${existing}^{tree}`])
|
|
412
|
+
if (
|
|
413
|
+
!message.includes(`Code-Foundry-Rollout: ${identity.id}`) ||
|
|
414
|
+
!message.includes(`Code-Foundry-Tree: ${tree}`)
|
|
415
|
+
)
|
|
416
|
+
throw new Error(
|
|
417
|
+
'Existing branch does not match its recorded rollout/tree identity; refusing to overwrite it'
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
checked(run, path, ['git', 'worktree', 'add', '--detach', worktree, existing || baseline])
|
|
421
|
+
attached = true
|
|
422
|
+
/** @type {string[]} */
|
|
423
|
+
let changed = []
|
|
424
|
+
let validatedHead = existing
|
|
425
|
+
if (!existing) {
|
|
426
|
+
changed = sync({
|
|
427
|
+
target: worktree,
|
|
428
|
+
source,
|
|
429
|
+
force: false,
|
|
430
|
+
runtimeRef: version,
|
|
431
|
+
configureHooks: false,
|
|
432
|
+
}).changed
|
|
433
|
+
const hook = join(worktree, '.githooks/pre-commit')
|
|
434
|
+
if (existsSync(hook)) chmodSync(hook, 0o755)
|
|
435
|
+
const file = join(worktree, '.github/code-foundry.yml')
|
|
436
|
+
const text = readFileSync(file, 'utf8')
|
|
437
|
+
const updated = /^runtime_ref:.*$/m.test(text)
|
|
438
|
+
? text.replace(/^runtime_ref:.*$/m, `runtime_ref: ${version}`)
|
|
439
|
+
: `${text.trimEnd()}\nruntime_ref: ${version}\n`
|
|
440
|
+
if (text !== updated) {
|
|
441
|
+
writeFileSync(file, updated)
|
|
442
|
+
changed.push('.github/code-foundry.yml')
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
changed = [...new Set(changed)]
|
|
446
|
+
for (const file of changed) {
|
|
447
|
+
const rel = relative(worktree, resolve(worktree, file))
|
|
448
|
+
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(file))
|
|
449
|
+
throw new Error('Sync returned a path outside the isolated worktree')
|
|
450
|
+
}
|
|
451
|
+
const config = readConfig(join(worktree, '.github/code-foundry.yml'))
|
|
452
|
+
if (config.runtime_ref !== version || configDrift(config, entry).length)
|
|
453
|
+
throw new Error('Candidate configuration does not match the declared fleet policy')
|
|
454
|
+
const validation = validateCandidate(run, worktree, entry, changed)
|
|
455
|
+
if (!existing) {
|
|
456
|
+
if (!changed.length)
|
|
457
|
+
return {
|
|
458
|
+
status: 'unchanged',
|
|
459
|
+
validation,
|
|
460
|
+
reason: 'No upgrade diff; no managed PR evidence exists to unlock later cohorts',
|
|
461
|
+
}
|
|
462
|
+
checked(run, worktree, ['git', 'add', '--', ...changed])
|
|
463
|
+
if (!checked(run, worktree, ['git', 'diff', '--cached', '--name-only']))
|
|
464
|
+
return { status: 'unchanged', validation }
|
|
465
|
+
const tree = checked(run, worktree, ['git', 'write-tree'])
|
|
466
|
+
checked(run, worktree, [
|
|
467
|
+
'git',
|
|
468
|
+
'-c',
|
|
469
|
+
'core.hooksPath=.githooks',
|
|
470
|
+
'commit',
|
|
471
|
+
'-m',
|
|
472
|
+
`chore(code-foundry): upgrade runtime to ${version}`,
|
|
473
|
+
'-m',
|
|
474
|
+
`Code-Foundry-Rollout: ${identity.id}\nCode-Foundry-Tree: ${tree}`,
|
|
475
|
+
])
|
|
476
|
+
if (checked(run, worktree, ['git', 'rev-parse', 'HEAD^{tree}']) !== tree)
|
|
477
|
+
throw new Error('Commit hooks changed the validated tree; refusing to publish it')
|
|
478
|
+
const sha = checked(run, worktree, ['git', 'rev-parse', 'HEAD'])
|
|
479
|
+
checked(run, path, [
|
|
480
|
+
'git',
|
|
481
|
+
'update-ref',
|
|
482
|
+
`refs/heads/${identity.branch}`,
|
|
483
|
+
sha,
|
|
484
|
+
'0'.repeat(40),
|
|
485
|
+
])
|
|
486
|
+
validatedHead = sha
|
|
487
|
+
} else {
|
|
488
|
+
validatedHead = checked(run, worktree, ['git', 'rev-parse', 'HEAD'])
|
|
489
|
+
}
|
|
490
|
+
if (remote && remoteBranchHead(run, path, identity.branch) !== validatedHead)
|
|
491
|
+
throw new Error('Upgrade branch changed during validation; refusing to create a pull request')
|
|
492
|
+
if (!remote) {
|
|
493
|
+
checked(run, worktree, ['git', 'push', 'origin', `HEAD:refs/heads/${identity.branch}`])
|
|
494
|
+
if (remoteBranchHead(run, path, identity.branch) !== validatedHead)
|
|
495
|
+
throw new Error('Upgrade branch did not retain the validated commit')
|
|
496
|
+
}
|
|
497
|
+
const body = `${identity.marker}\n\nIsolated Code Foundry upgrade to ${version}.\n\nLocal consumer validation passed (${validation.length} commands). Review the declared policy and run the required GitHub checks before marking ready. No automated readiness or merge is requested.`
|
|
498
|
+
const pullRequest = checked(run, path, [
|
|
499
|
+
'gh',
|
|
500
|
+
'pr',
|
|
501
|
+
'create',
|
|
502
|
+
'--repo',
|
|
503
|
+
entry.repository,
|
|
504
|
+
'--head',
|
|
505
|
+
identity.branch,
|
|
506
|
+
'--base',
|
|
507
|
+
base,
|
|
508
|
+
'--draft',
|
|
509
|
+
'--title',
|
|
510
|
+
`chore(code-foundry): upgrade to ${version}`,
|
|
511
|
+
'--body',
|
|
512
|
+
body,
|
|
513
|
+
])
|
|
514
|
+
const created = JSON.parse(
|
|
515
|
+
checked(run, path, [
|
|
516
|
+
'gh',
|
|
517
|
+
'pr',
|
|
518
|
+
'view',
|
|
519
|
+
pullRequest,
|
|
520
|
+
'--repo',
|
|
521
|
+
entry.repository,
|
|
522
|
+
'--json',
|
|
523
|
+
'headRefOid,headRefName,baseRefName,isCrossRepository,isDraft',
|
|
524
|
+
])
|
|
525
|
+
)
|
|
526
|
+
if (
|
|
527
|
+
created.headRefOid !== validatedHead ||
|
|
528
|
+
created.headRefName !== identity.branch ||
|
|
529
|
+
created.baseRefName !== base ||
|
|
530
|
+
created.isCrossRepository !== false ||
|
|
531
|
+
created.isDraft !== true
|
|
532
|
+
)
|
|
533
|
+
throw new Error('Created pull request does not reference the validated rollout branch')
|
|
534
|
+
return {
|
|
535
|
+
status: existing ? 'pr-resumed' : 'pr-created',
|
|
536
|
+
branch: identity.branch,
|
|
537
|
+
pullRequest,
|
|
538
|
+
validation,
|
|
539
|
+
}
|
|
540
|
+
} finally {
|
|
541
|
+
if (attached) {
|
|
542
|
+
const cleanup = run(path, ['git', 'worktree', 'remove', '--force', worktree])
|
|
543
|
+
if (cleanup.status !== 0)
|
|
544
|
+
console.error(`Preserved isolated worktree after cleanup failure: ${worktree}`)
|
|
545
|
+
else rmSync(temporary, { recursive: true, force: true })
|
|
546
|
+
} else rmSync(temporary, { recursive: true, force: true })
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The first incomplete cohort is the only cohort allowed to create PRs. Earlier
|
|
552
|
+
* cohorts must have merged managed PRs, successful required checks, and matching
|
|
553
|
+
* current base-branch configuration. Unknown/failed state never unlocks rollout.
|
|
554
|
+
* @param {string} root @param {string} source
|
|
555
|
+
* @param {{createPr?: boolean, dryRun?: boolean, force?: boolean, version: string, exclude?: string[]}} options
|
|
556
|
+
* @param {Synchronize} sync @param {Runner} [run]
|
|
557
|
+
*/
|
|
558
|
+
export function upgradeManifestFleet(root, source, options, sync, run = executeFleetCommand) {
|
|
559
|
+
if (!/^v\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(options.version))
|
|
560
|
+
throw new Error('Fleet target must be an exact version')
|
|
561
|
+
if (!options.dryRun && !options.createPr)
|
|
562
|
+
throw new Error(
|
|
563
|
+
'Manifest rollouts require --create-pr or --dry-run; in-place fleet mutation is disabled'
|
|
564
|
+
)
|
|
565
|
+
const manifest = readFleetManifest(root)
|
|
566
|
+
const inventory = discoverManifestRepositories(root, run)
|
|
567
|
+
/** @type {any[]} */
|
|
568
|
+
const report = []
|
|
569
|
+
let active = false
|
|
570
|
+
let stopped = false
|
|
571
|
+
for (const cohort of manifest.cohorts) {
|
|
572
|
+
const entries = manifest.repositories.filter((entry) => entry.cohort === cohort)
|
|
573
|
+
let completed = 0
|
|
574
|
+
for (const entry of entries) {
|
|
575
|
+
const item = inventory.find((repo) => repo.repository === entry.repository)
|
|
576
|
+
if (!item) throw new Error('Inventory changed during rollout')
|
|
577
|
+
const record = { repository: entry.repository, cohort, path: item.path }
|
|
578
|
+
const excluded = isExcluded(entry, item, options.exclude ?? [])
|
|
579
|
+
if (entry.exception || excluded) {
|
|
580
|
+
report.push({
|
|
581
|
+
...record,
|
|
582
|
+
status: 'excepted',
|
|
583
|
+
reason: entry.exception?.reason ?? 'excluded by command',
|
|
584
|
+
})
|
|
585
|
+
continue
|
|
586
|
+
}
|
|
587
|
+
if (stopped || active) {
|
|
588
|
+
report.push({ ...record, status: 'blocked', reason: 'earlier cohort is incomplete' })
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
if (item.status === 'blocked' || item.dirty) {
|
|
592
|
+
report.push({ ...record, status: 'failed', reason: item.reason || 'invalid inventory' })
|
|
593
|
+
stopped = true
|
|
594
|
+
continue
|
|
595
|
+
}
|
|
596
|
+
if (options.dryRun) {
|
|
597
|
+
report.push({ ...record, status: 'preview', version: options.version, drift: item.drift })
|
|
598
|
+
continue
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
const base = item.gitWorkflow === 'staging-release' ? 'staging' : 'main'
|
|
602
|
+
const rollout = inspectRollout(run, item.path, entry, options.version, base)
|
|
603
|
+
if (rollout.complete) {
|
|
604
|
+
completed += 1
|
|
605
|
+
report.push({ ...record, status: 'verified-merged', pullRequest: rollout.pr.url })
|
|
606
|
+
continue
|
|
607
|
+
}
|
|
608
|
+
if (rollout.pr) {
|
|
609
|
+
if (rollout.pr.state !== 'OPEN')
|
|
610
|
+
throw new Error(
|
|
611
|
+
'Existing rollout PR closed or merged without valid gate/configuration evidence'
|
|
612
|
+
)
|
|
613
|
+
report.push({
|
|
614
|
+
...record,
|
|
615
|
+
status: 'pr-existing',
|
|
616
|
+
pullRequest: rollout.pr.url,
|
|
617
|
+
draft: rollout.pr.isDraft,
|
|
618
|
+
})
|
|
619
|
+
continue
|
|
620
|
+
}
|
|
621
|
+
report.push({
|
|
622
|
+
...record,
|
|
623
|
+
...upgradeCandidate(run, sync, item.path, source, entry, options.version, base, rollout),
|
|
624
|
+
})
|
|
625
|
+
} catch (error) {
|
|
626
|
+
report.push({
|
|
627
|
+
...record,
|
|
628
|
+
status: 'failed',
|
|
629
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
630
|
+
})
|
|
631
|
+
stopped = true
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const expected = entries.filter((entry) => {
|
|
635
|
+
const item = inventory.find((repo) => repo.repository === entry.repository)
|
|
636
|
+
return !entry.exception && !isExcluded(entry, item, options.exclude ?? [])
|
|
637
|
+
}).length
|
|
638
|
+
if (!options.dryRun && (completed !== expected || expected === 0)) active = true
|
|
639
|
+
}
|
|
640
|
+
console.log(
|
|
641
|
+
JSON.stringify(
|
|
642
|
+
{
|
|
643
|
+
schemaVersion: 1,
|
|
644
|
+
version: options.version,
|
|
645
|
+
status: stopped ? 'failed' : options.dryRun ? 'preview' : active ? 'pending' : 'complete',
|
|
646
|
+
repositories: report,
|
|
647
|
+
},
|
|
648
|
+
null,
|
|
649
|
+
2
|
|
650
|
+
)
|
|
651
|
+
)
|
|
652
|
+
if (stopped) process.exitCode = 1
|
|
653
|
+
return report
|
|
654
|
+
}
|