code-foundry 1.18.0 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,567 +1,14 @@
1
1
  // @ts-check
2
+ import { upgradeFleet as upgrade } from './fleet-core.mjs'
3
+ import { readPackageVersion } from './sync.mjs'
4
+ import { guardedFleetUpgrade } from '../lib/fleet-eligibility.mjs'
2
5
 
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'
6
+ export * from './fleet-core.mjs'
13
7
 
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 */
8
+ /** @param {string} root @param {string} source
9
+ * @param {Parameters<typeof upgrade>[2]} options
10
+ */
40
11
  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$/, '')
12
+ const version = options.version ?? `v${readPackageVersion(source)}`
13
+ return guardedFleetUpgrade(root, source, version, () => upgrade(root, source, options))
567
14
  }
@@ -18,6 +18,13 @@ import {
18
18
  } from '../lib/config.mjs'
19
19
  import { buildReleaseConfig, buildReleaseManifest } from '../lib/release-manifest.mjs'
20
20
  import { customWorkflowFiles, overlayPolicy } from '../lib/overlay.mjs'
21
+ import {
22
+ inspectQueueCaller,
23
+ mergeQueueEnabled,
24
+ queueRuntimeRef,
25
+ renderMergeQueueCaller,
26
+ syncQueueCaller,
27
+ } from '../lib/merge-queue.mjs'
21
28
 
22
29
  const standardFiles = [
23
30
  '.editorconfig',
@@ -109,7 +116,7 @@ const legacyFiles = [
109
116
  /** @typedef {{ target: string, source: string, dryRun?: boolean, force?: boolean, init?: boolean, runtimeRef?: string, configureHooks?: boolean }} SyncOptions */
110
117
 
111
118
  /** @param {SyncOptions} options */
112
- export function syncRepository(options) {
119
+ function synchronize(options) {
113
120
  const target = resolve(options.target)
114
121
  const source = resolve(options.source)
115
122
  const dryRun = options.dryRun ?? false
@@ -441,6 +448,42 @@ export function syncRepository(options) {
441
448
  return { changed, config }
442
449
  }
443
450
 
451
+ /** @param {Parameters<typeof synchronize>[0]} options */
452
+ export function syncRepository(options) {
453
+ const target = resolve(options.target)
454
+ const source = resolve(options.source)
455
+ const existing = readConfig(join(target, '.github/code-foundry.yml'))
456
+ const enabled = mergeQueueEnabled(existing.merge_queue)
457
+ const ref = queueRuntimeRef(existing.runtime_ref, readPackageVersion(source), options.runtimeRef)
458
+ // Reject malformed opt-in settings or a user-owned destination before the
459
+ // original synchronizer changes any files. It remains the owner of defaults.
460
+ if (enabled) {
461
+ const preflight = renderMergeQueueCaller(
462
+ {
463
+ ...existing,
464
+ runtime_repository: configured(existing.runtime_repository, '0xPlayerOne/code-foundry'),
465
+ },
466
+ ref
467
+ )
468
+ inspectQueueCaller(target, preflight)
469
+ } else inspectQueueCaller(target, null)
470
+ const result = synchronize(options)
471
+ const content = enabled
472
+ ? renderMergeQueueCaller(
473
+ {
474
+ ...result.config,
475
+ runtime_repository: configured(
476
+ result.config.runtime_repository,
477
+ '0xPlayerOne/code-foundry'
478
+ ),
479
+ },
480
+ ref
481
+ )
482
+ : null
483
+ const changed = syncQueueCaller(target, content, options.dryRun ?? false)
484
+ return { ...result, changed: [...new Set([...result.changed, ...changed])] }
485
+ }
486
+
444
487
  /**
445
488
  * Baseline keys that are safe to merge into a repository's release config.
446
489
  * Package and release-type policy is detected from the repository itself and