code-foundry 1.17.0 → 1.19.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,567 @@
1
+ // @ts-check
2
+
3
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+ import { spawnSync } from 'node:child_process'
7
+ import { readPackageVersion, syncRepository } from './sync.mjs'
8
+ import {
9
+ discoverManifestRepositories,
10
+ hasFleetManifest,
11
+ upgradeManifestFleet,
12
+ } from '../lib/fleet-manifest.mjs'
13
+
14
+ /** @typedef {{ path: string, repository: string, runtimeRef: string, dirty: boolean, configured: boolean, gitWorkflow: string }} FleetRepository */
15
+
16
+ /** @param {string} root @returns {FleetRepository[]} */
17
+ export function discoverRepositories(root) {
18
+ if (hasFleetManifest(root)) return discoverManifestRepositories(root)
19
+ /** @type {FleetRepository[]} */
20
+ const result = []
21
+ const candidates = [root, ...children(root), ...children(join(root, 'NiftyLeague'))]
22
+ for (const candidate of new Set(candidates)) {
23
+ if (!existsSync(join(candidate, '.git'))) continue
24
+ const configured = existsSync(join(candidate, '.github/code-foundry.yml'))
25
+ const config = readConfig(join(candidate, '.github/code-foundry.yml'))
26
+ const remote = git(candidate, ['remote', 'get-url', 'origin'])
27
+ result.push({
28
+ path: candidate,
29
+ repository: normalizeRemote(remote),
30
+ runtimeRef: config.runtime_ref ?? '',
31
+ dirty: Boolean(git(candidate, ['status', '--porcelain'])),
32
+ configured,
33
+ gitWorkflow: config.git_workflow ?? 'direct',
34
+ })
35
+ }
36
+ return result.sort((a, b) => a.path.localeCompare(b.path))
37
+ }
38
+
39
+ /** @param {string} root @param {string} source @param {{ createPr?: boolean, dryRun?: boolean, force?: boolean, version: string, exclude?: string[] }} options */
40
+ export function upgradeFleet(root, source, options) {
41
+ const sourceVersion = `v${readPackageVersion(source)}`
42
+ const version = options.version ?? sourceVersion
43
+ if (options.version && options.version !== sourceVersion) {
44
+ throw new Error(
45
+ `fleet upgrade target ${options.version} does not match the runtime source checkout (${sourceVersion}); refresh the code-foundry checkout to ${options.version} before upgrading so the rendered callers and the declared runtime agree.`
46
+ )
47
+ }
48
+ if (hasFleetManifest(root))
49
+ return upgradeManifestFleet(root, source, { ...options, version }, syncRepository)
50
+ const repositories = discoverRepositories(root)
51
+ const report = []
52
+ for (const repository of repositories) {
53
+ if (
54
+ (options.exclude ?? []).some(
55
+ (value) =>
56
+ repository.path === value ||
57
+ repository.repository === value ||
58
+ repository.path.endsWith(`/${value}`)
59
+ )
60
+ ) {
61
+ report.push({ path: repository.path, status: 'skipped', reason: 'excluded by fleet policy' })
62
+ continue
63
+ }
64
+ if (!repository.configured) {
65
+ report.push({
66
+ path: repository.path,
67
+ status: 'skipped',
68
+ reason: 'missing .github/code-foundry.yml',
69
+ })
70
+ continue
71
+ }
72
+ if (repository.dirty && !options.force) {
73
+ report.push({ path: repository.path, status: 'skipped', reason: 'working tree is dirty' })
74
+ continue
75
+ }
76
+ if (!options.createPr || options.dryRun) {
77
+ const result = syncRepository({
78
+ target: repository.path,
79
+ source,
80
+ dryRun: options.dryRun,
81
+ force: false,
82
+ })
83
+ report.push({
84
+ path: repository.path,
85
+ status: options.dryRun ? 'preview' : 'synced',
86
+ changed: result.changed,
87
+ })
88
+ continue
89
+ }
90
+ if (!repository.repository) {
91
+ report.push({
92
+ path: repository.path,
93
+ status: 'skipped',
94
+ reason: 'origin is not a GitHub remote',
95
+ })
96
+ continue
97
+ }
98
+ report.push(upgradeRepository(repository, source, version))
99
+ }
100
+ console.log(JSON.stringify(report, null, 2))
101
+ return report
102
+ }
103
+
104
+ /** @param {FleetRepository} repository @param {string} source @param {string} version */
105
+ function upgradeRepository(repository, source, version) {
106
+ const branch = `codex/code-foundry-upgrade-${version.replace(/^v/, '')}`
107
+ const base = repository.gitWorkflow === 'staging-release' ? 'staging' : 'main'
108
+ const temporary = mkdtempSync(join(tmpdir(), 'code-foundry-fleet-'))
109
+ let attached = false
110
+ try {
111
+ const fetch = spawnSync('git', ['-C', repository.path, 'fetch', 'origin', base, '--quiet'], {
112
+ encoding: 'utf8',
113
+ })
114
+ if (fetch.status !== 0)
115
+ return {
116
+ path: repository.path,
117
+ status: 'skipped',
118
+ reason: fetch.stderr.trim() || `unable to refresh ${base} baseline`,
119
+ }
120
+ const marker = `<!-- code-foundry-legacy-rollout:${repository.repository}:${version}:${base} -->`
121
+ const commitMarker = `Code-Foundry-Legacy-Rollout: ${repository.repository}:${version}:${base}`
122
+ const remoteBranch = spawnSync(
123
+ 'git',
124
+ ['-C', repository.path, 'ls-remote', '--heads', 'origin', `refs/heads/${branch}`],
125
+ { encoding: 'utf8' }
126
+ )
127
+ if (remoteBranch.status !== 0)
128
+ return {
129
+ path: repository.path,
130
+ status: 'failed',
131
+ branch,
132
+ reason: remoteBranch.stderr.trim() || 'unable to inspect existing upgrade branch',
133
+ }
134
+ const remote = remoteBranch.stdout.trim().split(/\s/)[0] ?? ''
135
+ const local = spawnSync(
136
+ 'git',
137
+ ['-C', repository.path, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`],
138
+ { encoding: 'utf8' }
139
+ )
140
+ if (local.status !== 0 && local.status !== 1)
141
+ return {
142
+ path: repository.path,
143
+ status: 'failed',
144
+ branch,
145
+ reason: 'unable to inspect existing local upgrade branch',
146
+ }
147
+ const existing = remote || (local.status === 0 ? local.stdout.trim() : '')
148
+ if (remote) {
149
+ const branchFetch = spawnSync(
150
+ 'git',
151
+ ['-C', repository.path, 'fetch', 'origin', branch, '--quiet'],
152
+ { encoding: 'utf8' }
153
+ )
154
+ if (branchFetch.status !== 0)
155
+ return {
156
+ path: repository.path,
157
+ status: 'failed',
158
+ branch,
159
+ reason: branchFetch.stderr.trim() || 'unable to fetch existing upgrade branch',
160
+ }
161
+ }
162
+ const baseline = spawnSync('git', ['-C', repository.path, 'rev-parse', `origin/${base}`], {
163
+ encoding: 'utf8',
164
+ })
165
+ if (baseline.status !== 0)
166
+ return {
167
+ path: repository.path,
168
+ status: 'skipped',
169
+ reason: `remote ${base} branch is unavailable`,
170
+ }
171
+ if (existing) {
172
+ const message = spawnSync(
173
+ 'git',
174
+ ['-C', repository.path, 'show', '-s', '--format=%B', existing],
175
+ { encoding: 'utf8' }
176
+ )
177
+ const tree = spawnSync('git', ['-C', repository.path, 'rev-parse', `${existing}^{tree}`], {
178
+ encoding: 'utf8',
179
+ })
180
+ const parent = spawnSync('git', ['-C', repository.path, 'rev-parse', `${existing}^`], {
181
+ encoding: 'utf8',
182
+ })
183
+ const subject = message.stdout.split(/\r?\n/, 1)[0]
184
+ const managed =
185
+ message.status === 0 &&
186
+ tree.status === 0 &&
187
+ message.stdout.includes(commitMarker) &&
188
+ message.stdout.includes(`Code-Foundry-Tree: ${tree.stdout.trim()}`)
189
+ const legacyManaged =
190
+ subject === `chore(code-foundry): upgrade runtime to ${version}` &&
191
+ parent.status === 0 &&
192
+ parent.stdout.trim() === baseline.stdout.trim()
193
+ if (!managed && !legacyManaged)
194
+ return {
195
+ path: repository.path,
196
+ status: 'failed',
197
+ branch,
198
+ reason: 'Existing upgrade branch is not a managed rollout; reconcile it manually',
199
+ }
200
+ }
201
+ const add = spawnSync(
202
+ 'git',
203
+ [
204
+ '-C',
205
+ repository.path,
206
+ 'worktree',
207
+ 'add',
208
+ '--detach',
209
+ temporary,
210
+ existing || baseline.stdout.trim(),
211
+ ],
212
+ { encoding: 'utf8' }
213
+ )
214
+ if (add.status !== 0)
215
+ return {
216
+ path: repository.path,
217
+ status: 'skipped',
218
+ reason: add.stderr.trim() || 'unable to create isolated worktree',
219
+ }
220
+ attached = true
221
+ let validatedHead = existing
222
+ if (!existing) {
223
+ const result = syncRepository({
224
+ target: temporary,
225
+ source,
226
+ force: false,
227
+ runtimeRef: version,
228
+ })
229
+ const configFile = join(temporary, '.github/code-foundry.yml')
230
+ if (existsSync(configFile)) {
231
+ const current = readFileSync(configFile, 'utf8')
232
+ const updated = current.match(/^runtime_ref:\s*.*$/m)
233
+ ? current.replace(/^runtime_ref:\s*.*$/m, `runtime_ref: ${version}`)
234
+ : `${current.trimEnd()}\nruntime_ref: ${version}\n`
235
+ if (updated !== current) {
236
+ writeFileSync(configFile, updated)
237
+ if (!result.changed.includes('.github/code-foundry.yml'))
238
+ result.changed.push('.github/code-foundry.yml')
239
+ }
240
+ }
241
+ if (!result.changed.length) return { path: repository.path, status: 'unchanged', branch }
242
+ const stage = spawnSync('git', ['-C', temporary, 'add', '--', ...result.changed], {
243
+ encoding: 'utf8',
244
+ })
245
+ if (stage.status !== 0)
246
+ return {
247
+ path: repository.path,
248
+ status: 'failed',
249
+ branch,
250
+ reason: stage.stderr.trim() || 'unable to stage generated upgrade files',
251
+ }
252
+ const staged = spawnSync('git', ['-C', temporary, 'diff', '--cached', '--name-only'], {
253
+ encoding: 'utf8',
254
+ })
255
+ if (staged.status !== 0 || !staged.stdout.trim())
256
+ return { path: repository.path, status: 'unchanged', branch }
257
+ const tree = spawnSync('git', ['-C', temporary, 'write-tree'], { encoding: 'utf8' })
258
+ if (tree.status !== 0)
259
+ return {
260
+ path: repository.path,
261
+ status: 'failed',
262
+ branch,
263
+ reason: tree.stderr.trim() || 'unable to record generated upgrade tree',
264
+ }
265
+ const commit = spawnSync(
266
+ 'git',
267
+ [
268
+ '-C',
269
+ temporary,
270
+ '-c',
271
+ 'core.hooksPath=.githooks',
272
+ 'commit',
273
+ '-m',
274
+ `chore(code-foundry): upgrade runtime to ${version}`,
275
+ '-m',
276
+ `${commitMarker}\nCode-Foundry-Tree: ${tree.stdout.trim()}`,
277
+ ],
278
+ { encoding: 'utf8' }
279
+ )
280
+ if (commit.status !== 0)
281
+ return {
282
+ path: repository.path,
283
+ status: 'failed',
284
+ branch,
285
+ reason: commit.stderr.trim() || 'commit failed',
286
+ }
287
+ const committedTree = spawnSync('git', ['-C', temporary, 'rev-parse', 'HEAD^{tree}'], {
288
+ encoding: 'utf8',
289
+ })
290
+ if (committedTree.status !== 0 || committedTree.stdout.trim() !== tree.stdout.trim())
291
+ return {
292
+ path: repository.path,
293
+ status: 'failed',
294
+ branch,
295
+ reason: 'Commit hook changed the generated upgrade tree',
296
+ }
297
+ const sha = spawnSync('git', ['-C', temporary, 'rev-parse', 'HEAD'], { encoding: 'utf8' })
298
+ if (sha.status !== 0)
299
+ return {
300
+ path: repository.path,
301
+ status: 'failed',
302
+ branch,
303
+ reason: 'unable to inspect generated upgrade commit',
304
+ }
305
+ const update = spawnSync(
306
+ 'git',
307
+ [
308
+ '-C',
309
+ repository.path,
310
+ 'update-ref',
311
+ `refs/heads/${branch}`,
312
+ sha.stdout.trim(),
313
+ '0'.repeat(40),
314
+ ],
315
+ { encoding: 'utf8' }
316
+ )
317
+ if (update.status !== 0)
318
+ return {
319
+ path: repository.path,
320
+ status: 'failed',
321
+ branch,
322
+ reason: update.stderr.trim() || 'unable to retain generated upgrade commit',
323
+ }
324
+ validatedHead = sha.stdout.trim()
325
+ }
326
+ if (remote && remote !== validatedHead)
327
+ return {
328
+ path: repository.path,
329
+ status: 'failed',
330
+ branch,
331
+ reason: 'Upgrade branch changed during validation; refusing to create a pull request',
332
+ }
333
+ if (!remote) {
334
+ const push = spawnSync(
335
+ 'git',
336
+ ['-C', temporary, 'push', '-u', 'origin', `HEAD:refs/heads/${branch}`],
337
+ { encoding: 'utf8' }
338
+ )
339
+ if (push.status !== 0)
340
+ return {
341
+ path: repository.path,
342
+ status: 'failed',
343
+ branch,
344
+ reason: push.stderr.trim() || 'push failed',
345
+ }
346
+ const pushed = spawnSync(
347
+ 'git',
348
+ ['-C', repository.path, 'ls-remote', '--heads', 'origin', `refs/heads/${branch}`],
349
+ { encoding: 'utf8' }
350
+ )
351
+ if (pushed.status !== 0 || pushed.stdout.trim().split(/\s/)[0] !== validatedHead)
352
+ return {
353
+ path: repository.path,
354
+ status: 'failed',
355
+ branch,
356
+ reason: 'Upgrade branch did not retain the validated commit',
357
+ }
358
+ }
359
+ const listed = spawnSync(
360
+ 'gh',
361
+ [
362
+ 'pr',
363
+ 'list',
364
+ '--repo',
365
+ repository.repository,
366
+ '--head',
367
+ branch,
368
+ '--base',
369
+ base,
370
+ '--state',
371
+ 'all',
372
+ '--limit',
373
+ '100',
374
+ '--json',
375
+ 'url,body,state,headRefName,headRefOid,baseRefName,isCrossRepository,isDraft',
376
+ ],
377
+ { encoding: 'utf8' }
378
+ )
379
+ if (listed.status !== 0)
380
+ return {
381
+ path: repository.path,
382
+ status: 'failed',
383
+ branch,
384
+ reason: listed.stderr.trim() || 'unable to inspect upgrade pull requests',
385
+ }
386
+ let pullRequests
387
+ try {
388
+ pullRequests = JSON.parse(listed.stdout)
389
+ } catch {
390
+ return {
391
+ path: repository.path,
392
+ status: 'failed',
393
+ branch,
394
+ reason: 'invalid pull request list',
395
+ }
396
+ }
397
+ if (!Array.isArray(pullRequests) || pullRequests.length > 1)
398
+ return {
399
+ path: repository.path,
400
+ status: 'failed',
401
+ branch,
402
+ reason: 'Ambiguous upgrade pull requests',
403
+ }
404
+ const existingPr = pullRequests[0]
405
+ if (existingPr) {
406
+ if (
407
+ !existingPr.body?.includes(marker) ||
408
+ existingPr.headRefName !== branch ||
409
+ existingPr.baseRefName !== base ||
410
+ existingPr.headRefOid !== validatedHead ||
411
+ existingPr.isCrossRepository !== false
412
+ )
413
+ return {
414
+ path: repository.path,
415
+ status: 'failed',
416
+ branch,
417
+ reason: 'Existing pull request does not match this rollout policy',
418
+ }
419
+ return {
420
+ path: repository.path,
421
+ status: 'pr-existing',
422
+ branch,
423
+ pullRequest: existingPr.url,
424
+ draft: existingPr.isDraft,
425
+ }
426
+ }
427
+ const pr = spawnSync(
428
+ 'gh',
429
+ [
430
+ 'pr',
431
+ 'create',
432
+ '--draft',
433
+ '--repo',
434
+ repository.repository,
435
+ '--base',
436
+ base,
437
+ '--head',
438
+ branch,
439
+ '--title',
440
+ `chore(code-foundry): upgrade to ${version}`,
441
+ '--body',
442
+ `${marker}\n\nAutomated isolated Code Foundry runtime upgrade to ${version}.\n\nThe sync preserved protected repository-owned documents and custom workflows.`,
443
+ ],
444
+ { encoding: 'utf8' }
445
+ )
446
+ if (pr.status !== 0)
447
+ return {
448
+ path: repository.path,
449
+ status: 'failed',
450
+ branch,
451
+ reason: pr.stderr.trim() || 'pull request creation failed',
452
+ }
453
+ const created = spawnSync(
454
+ 'gh',
455
+ [
456
+ 'pr',
457
+ 'view',
458
+ pr.stdout.trim(),
459
+ '--repo',
460
+ repository.repository,
461
+ '--json',
462
+ 'headRefOid,headRefName,baseRefName,isCrossRepository,isDraft',
463
+ ],
464
+ { encoding: 'utf8' }
465
+ )
466
+ if (created.status !== 0)
467
+ return {
468
+ path: repository.path,
469
+ status: 'failed',
470
+ branch,
471
+ reason: created.stderr.trim() || 'unable to verify created pull request',
472
+ }
473
+ let createdPr
474
+ try {
475
+ createdPr = JSON.parse(created.stdout)
476
+ } catch {
477
+ return {
478
+ path: repository.path,
479
+ status: 'failed',
480
+ branch,
481
+ reason: 'invalid created pull request',
482
+ }
483
+ }
484
+ if (
485
+ createdPr.headRefOid !== validatedHead ||
486
+ createdPr.headRefName !== branch ||
487
+ createdPr.baseRefName !== base ||
488
+ createdPr.isCrossRepository !== false ||
489
+ createdPr.isDraft !== true
490
+ )
491
+ return {
492
+ path: repository.path,
493
+ status: 'failed',
494
+ branch,
495
+ reason: 'Created pull request does not reference the validated draft rollout branch',
496
+ }
497
+ return {
498
+ path: repository.path,
499
+ status: existing ? 'pr-resumed' : 'pr-created',
500
+ branch,
501
+ pullRequest: pr.stdout.trim(),
502
+ }
503
+ } finally {
504
+ if (attached) {
505
+ const cleanup = spawnSync(
506
+ 'git',
507
+ ['-C', repository.path, 'worktree', 'remove', '--force', temporary],
508
+ { encoding: 'utf8' }
509
+ )
510
+ if (cleanup.status !== 0)
511
+ console.error(`Preserved isolated worktree after cleanup failure: ${temporary}`)
512
+ else rmSync(temporary, { recursive: true, force: true })
513
+ } else rmSync(temporary, { recursive: true, force: true })
514
+ }
515
+ }
516
+
517
+ /** @param {string} root @returns {string[]} */
518
+ function children(root) {
519
+ try {
520
+ return readdirSync(root, { withFileTypes: true })
521
+ .filter(
522
+ (entry) =>
523
+ entry.isDirectory() &&
524
+ !entry.name.startsWith('.') &&
525
+ !['node_modules', 'target'].includes(entry.name)
526
+ )
527
+ .map((entry) => join(root, entry.name))
528
+ } catch {
529
+ return []
530
+ }
531
+ }
532
+
533
+ /** @param {string} file @returns {Record<string, string>} */
534
+ function readConfig(file) {
535
+ try {
536
+ return Object.fromEntries(
537
+ requireText(file)
538
+ .split(/\r?\n/)
539
+ .flatMap((line) => {
540
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/)
541
+ return match
542
+ ? [[match[1], match[2].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '')]]
543
+ : []
544
+ })
545
+ )
546
+ } catch {
547
+ return {}
548
+ }
549
+ }
550
+
551
+ /** @param {string} file */
552
+ function requireText(file) {
553
+ return readFileSync(file, 'utf8')
554
+ }
555
+
556
+ /** @param {string} root @param {string[]} args */
557
+ function git(root, args) {
558
+ return spawnSync('git', args, { cwd: root, encoding: 'utf8' }).stdout?.trim() ?? ''
559
+ }
560
+
561
+ /** @param {string} remote */
562
+ function normalizeRemote(remote) {
563
+ return remote
564
+ .replace(/^git@github\.com:/, '')
565
+ .replace(/^https?:\/\/github\.com\//, '')
566
+ .replace(/\.git$/, '')
567
+ }