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.
@@ -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
+ }