code-foundry 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,18 @@ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join, resolve } from 'node:path'
6
6
  import { spawnSync } from 'node:child_process'
7
- import { approvedReleaseFiles, buildReleaseRecoveryPlan, buildReconciliationPullRequestBody, classifyReconciliation, readReleaseConfig, reconciliationPullRequestBranch, reconciliationPullRequestTitle, selectGeneratedReleasePrs, selectReconciliationPullRequest, validateReleasePullRequests } from '../lib/release-policy.mjs'
7
+ import {
8
+ approvedReleaseFiles,
9
+ buildReleaseRecoveryPlan,
10
+ buildReconciliationPullRequestBody,
11
+ classifyReconciliation,
12
+ readReleaseConfig,
13
+ reconciliationPullRequestBranch,
14
+ reconciliationPullRequestTitle,
15
+ selectGeneratedReleasePrs,
16
+ selectReconciliationPullRequest,
17
+ validateReleasePullRequests,
18
+ } from '../lib/release-policy.mjs'
8
19
  import { hasDeliveredHook, releaseDeliveryKey, selectHookDelivery } from '../lib/release-hook.mjs'
9
20
 
10
21
  /** @typedef {{ target: string, dryRun: boolean, github: boolean, base: string, head: string }} ReleaseOptions */
@@ -31,9 +42,15 @@ export function reconcileRelease(root, options) {
31
42
  throw new Error(formatReconciliationFailure(state.plan))
32
43
  }
33
44
  console.log(JSON.stringify({ base, head, ...state.plan }, null, 2))
34
- if (!['fast-forward', 'rebase-staging', 'aligned'].includes(state.plan.action) || !options.github || options.dryRun) return state.plan
45
+ if (
46
+ !['fast-forward', 'rebase-staging', 'aligned'].includes(state.plan.action) ||
47
+ !options.github ||
48
+ options.dryRun
49
+ )
50
+ return state.plan
35
51
  if (state.plan.action === 'aligned' && state.mainSha === state.stagingSha) return state.plan
36
- if (!process.env.GITHUB_REPOSITORY) throw new Error('GITHUB_REPOSITORY is required for --github reconciliation.')
52
+ if (!process.env.GITHUB_REPOSITORY)
53
+ throw new Error('GITHUB_REPOSITORY is required for --github reconciliation.')
37
54
  const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
38
55
  if (!token) throw new Error('GH_TOKEN or GITHUB_TOKEN is required for --github reconciliation.')
39
56
  const maxAttempts = 3
@@ -42,16 +59,20 @@ export function reconcileRelease(root, options) {
42
59
  if (state.plan.action === 'fail') throw new Error(formatReconciliationFailure(state.plan))
43
60
  if (state.plan.action === 'aligned' && state.mainSha === state.stagingSha) return state.plan
44
61
  const mutation = executeReconciliationMutation(target, base, head, state)
45
- if (mutation.success === true) return { ...state.plan, ...(mutation.result ?? {}) }
62
+ if (mutation.success === true) return { ...state.plan, ...mutation.result }
46
63
  if (mutation.retry !== true) throw new Error(mutation.error ?? `${head} reconciliation failed.`)
47
64
  const remoteSha = remoteRefSha(target, head)
48
65
  if (remoteSha === state.stagingSha) {
49
- throw new Error(`${head} synchronization was rejected by an exact lease while remote ${head} tip remained ${state.stagingSha}. ` +
50
- 'Update branch protection or remote policy to permit this mutation, then retry. ' +
51
- `Last failure detail: ${mutation.error ?? 'unknown synchronization failure'}`)
66
+ throw new Error(
67
+ `${head} synchronization was rejected by an exact lease while remote ${head} tip remained ${state.stagingSha}. ` +
68
+ 'Update branch protection or remote policy to permit this mutation, then retry. ' +
69
+ `Last failure detail: ${mutation.error ?? 'unknown synchronization failure'}`
70
+ )
52
71
  }
53
72
  }
54
- throw new Error(`Reconciliation of ${head} was retried but failed while the branch moved concurrently.`)
73
+ throw new Error(
74
+ `Reconciliation of ${head} was retried but failed while the branch moved concurrently.`
75
+ )
55
76
  }
56
77
 
57
78
  /**
@@ -130,13 +151,20 @@ function resolveRef(target, ref, requireRemote) {
130
151
 
131
152
  /** @param {string} target @param {string} base @param {string} head */
132
153
  function refreshRemoteRefs(target, base, head) {
133
- const result = spawnSync('git', ['fetch', 'origin', base, head], { cwd: target, encoding: 'utf8' })
134
- if (result.status !== 0) throw new Error(`Failed to refresh origin/${base} and origin/${head} before reconciliation.`)
154
+ const result = spawnSync('git', ['fetch', 'origin', base, head], {
155
+ cwd: target,
156
+ encoding: 'utf8',
157
+ })
158
+ if (result.status !== 0)
159
+ throw new Error(`Failed to refresh origin/${base} and origin/${head} before reconciliation.`)
135
160
  }
136
161
 
137
162
  /** @param {string} target @param {string} branch @returns {string | null} */
138
163
  function remoteRefSha(target, branch) {
139
- const result = spawnSync('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { cwd: target, encoding: 'utf8' })
164
+ const result = spawnSync('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], {
165
+ cwd: target,
166
+ encoding: 'utf8',
167
+ })
140
168
  if (result.status !== 0) return null
141
169
  const [sha] = result.stdout.trim().split(/\t/, 1)
142
170
  return sha || ''
@@ -157,7 +185,8 @@ function remoteRefSha(target, branch) {
157
185
  * @returns {{ success: false, retry: boolean, error: string } | { success: true, result: ReconciliationMutationResult }}
158
186
  */
159
187
  function executeReconciliationMutation(target, base, head, state) {
160
- if (!state.plan.targetSha) return { success: false, retry: false, error: `No target SHA for ${head} reconciliation.` }
188
+ if (!state.plan.targetSha)
189
+ return { success: false, retry: false, error: `No target SHA for ${head} reconciliation.` }
161
190
  if (state.plan.action === 'rebase-staging') {
162
191
  let replaySha
163
192
  try {
@@ -171,7 +200,8 @@ function executeReconciliationMutation(target, base, head, state) {
171
200
  }
172
201
 
173
202
  const push = pushWithLease(target, head, state.stagingSha, replaySha)
174
- if (push.status === 0) return { success: true, result: { synchronization: 'replay', replaySha } }
203
+ if (push.status === 0)
204
+ return { success: true, result: { synchronization: 'replay', replaySha } }
175
205
  const classification = classifyPushFailure(head, push.message)
176
206
  if (classification.category === 'authentication') {
177
207
  return {
@@ -181,13 +211,29 @@ function executeReconciliationMutation(target, base, head, state) {
181
211
  }
182
212
  }
183
213
  if (classification.category === 'policy') {
184
- return deliverReconciliationPullRequest(target, base, head, state, replaySha, { synchronization: 'replay', replaySha }, classification.message)
214
+ return deliverReconciliationPullRequest(
215
+ target,
216
+ base,
217
+ head,
218
+ state,
219
+ replaySha,
220
+ { synchronization: 'replay', replaySha },
221
+ classification.message
222
+ )
223
+ }
224
+ return {
225
+ success: false,
226
+ retry: true,
227
+ error: `${head} synchronization failed with lease: ${classification.message}`,
185
228
  }
186
- return { success: false, retry: true, error: `${head} synchronization failed with lease: ${classification.message}` }
187
229
  }
188
230
  const targetSha = /** @type {string} */ (state.plan.targetSha)
189
231
  const leased = pushWithLease(target, head, state.stagingSha, targetSha)
190
- if (leased.status === 0) return { success: true, result: { synchronization: state.plan.action === 'aligned' ? 'synced' : 'leased' } }
232
+ if (leased.status === 0)
233
+ return {
234
+ success: true,
235
+ result: { synchronization: state.plan.action === 'aligned' ? 'synced' : 'leased' },
236
+ }
191
237
  const classification = classifyPushFailure(head, leased.message)
192
238
  if (classification.category === 'authentication') {
193
239
  return {
@@ -205,9 +251,21 @@ function executeReconciliationMutation(target, base, head, state) {
205
251
  if (state.plan.action === 'aligned') {
206
252
  return { success: true, result: { synchronization: 'content-aligned' } }
207
253
  }
208
- return deliverReconciliationPullRequest(target, base, head, state, targetSha, {}, classification.message)
254
+ return deliverReconciliationPullRequest(
255
+ target,
256
+ base,
257
+ head,
258
+ state,
259
+ targetSha,
260
+ {},
261
+ classification.message
262
+ )
263
+ }
264
+ return {
265
+ success: false,
266
+ retry: true,
267
+ error: `${head} synchronization failed with lease: ${classification.message}`,
209
268
  }
210
- return { success: false, retry: true, error: `${head} synchronization failed with lease: ${classification.message}` }
211
269
  }
212
270
 
213
271
  /** @typedef {{ number: number, url: string, base: string, head: string, branch: string, title: string, body: string }} ReconciliationPullRequest */
@@ -239,7 +297,8 @@ function failResult(error) {
239
297
  */
240
298
  function deliverReconciliationPullRequest(target, base, head, state, targetSha, extra, pushError) {
241
299
  const repository = process.env.GITHUB_REPOSITORY
242
- if (!repository) return failResult('GITHUB_REPOSITORY is required to open a reconciliation pull request.')
300
+ if (!repository)
301
+ return failResult('GITHUB_REPOSITORY is required to open a reconciliation pull request.')
243
302
  // base (main) is only the reconciliation source; the generated pull
244
303
  // request must target the protected head branch (staging), so the PR base
245
304
  // is always the reconcile head. Keep one named source of truth so the gh
@@ -249,7 +308,9 @@ function deliverReconciliationPullRequest(target, base, head, state, targetSha,
249
308
  const title = reconciliationPullRequestTitle({ targetHead: head, sourceBase: base })
250
309
  const prepared = prepareExactTreeReconciliationHead(target, state.stagingSha, targetSha, title)
251
310
  if (prepared.status !== 0 || !prepared.sha) {
252
- return failResult(`Failed to prepare the exact-tree reconciliation head ${branch}: ${prepared.message}`)
311
+ return failResult(
312
+ `Failed to prepare the exact-tree reconciliation head ${branch}: ${prepared.message}`
313
+ )
253
314
  }
254
315
  const deliverySha = prepared.sha
255
316
  const body = buildReconciliationPullRequestBody({
@@ -264,45 +325,77 @@ function deliverReconciliationPullRequest(target, base, head, state, targetSha,
264
325
  })
265
326
  const expected = { targetBase: prBase, branch, title }
266
327
  let prs = listOpenHeadPullRequests(target, repository, branch)
267
- if (!prs) return failResult(`Failed to list open pull requests for reconciliation branch ${branch}.`)
328
+ if (!prs)
329
+ return failResult(`Failed to list open pull requests for reconciliation branch ${branch}.`)
268
330
  let selection = selectReconciliationPullRequest(prs, expected)
269
331
  if (selection.error) return failResult(selection.error)
270
332
  if (!selection.create) {
271
- if (!selection.reuse) return failResult(`No reusable reconciliation pull request for ${branch}.`)
333
+ if (!selection.reuse)
334
+ return failResult(`No reusable reconciliation pull request for ${branch}.`)
272
335
  return reuseReconciliationPullRequest(target, repository, selection.reuse, deliverySha, body)
273
336
  }
274
337
  const pushed = pushReconciliationHead(target, branch, deliverySha)
275
338
  if (pushed.status !== 0) {
276
339
  const classification = classifyPushFailure(branch, pushed.message)
277
340
  if (classification.category === 'authentication') {
278
- return failResult(`${branch} authentication failed while pushing the reconciliation head: ${classification.message}`)
341
+ return failResult(
342
+ `${branch} authentication failed while pushing the reconciliation head: ${classification.message}`
343
+ )
279
344
  }
280
345
  return failResult(`Failed to push the reconciliation head ${branch}: ${classification.message}`)
281
346
  }
282
347
  prs = listOpenHeadPullRequests(target, repository, branch)
283
- if (!prs) return failResult(`Failed to re-list open pull requests for reconciliation branch ${branch} after pushing its head.`)
348
+ if (!prs)
349
+ return failResult(
350
+ `Failed to re-list open pull requests for reconciliation branch ${branch} after pushing its head.`
351
+ )
284
352
  selection = selectReconciliationPullRequest(prs, expected)
285
353
  if (selection.error) return failResult(selection.error)
286
354
  if (!selection.create) {
287
- if (!selection.reuse) return failResult(`No reusable reconciliation pull request for ${branch} after pushing its head.`)
355
+ if (!selection.reuse)
356
+ return failResult(
357
+ `No reusable reconciliation pull request for ${branch} after pushing its head.`
358
+ )
288
359
  return reuseReconciliationPullRequest(target, repository, selection.reuse, deliverySha, body)
289
360
  }
290
- const created = ghSpawn(target, ['pr', 'create', '--repo', repository, '--base', prBase, '--head', branch, '--title', title, '--body', body])
361
+ const created = ghSpawn(target, [
362
+ 'pr',
363
+ 'create',
364
+ '--repo',
365
+ repository,
366
+ '--base',
367
+ prBase,
368
+ '--head',
369
+ branch,
370
+ '--title',
371
+ title,
372
+ '--body',
373
+ body,
374
+ ])
291
375
  if (created.status !== 0) {
292
376
  // A concurrent run may have created the pull request between our list
293
377
  // and create; reuse it when it is exactly ours, otherwise fail closed.
294
378
  prs = listOpenHeadPullRequests(target, repository, branch)
295
- if (!prs) return failResult(`Failed to create the reconciliation pull request and to re-list open pull requests for ${branch}.`)
379
+ if (!prs)
380
+ return failResult(
381
+ `Failed to create the reconciliation pull request and to re-list open pull requests for ${branch}.`
382
+ )
296
383
  selection = selectReconciliationPullRequest(prs, expected)
297
384
  if (selection.error) return failResult(selection.error)
298
385
  if (selection.create) {
299
386
  return failResult(reconciliationPullRequestCreateError(branch, created.stderr))
300
387
  }
301
- if (!selection.reuse) return failResult(`No reusable reconciliation pull request for ${branch} after gh pr create failed.`)
388
+ if (!selection.reuse)
389
+ return failResult(
390
+ `No reusable reconciliation pull request for ${branch} after gh pr create failed.`
391
+ )
302
392
  return reuseReconciliationPullRequest(target, repository, selection.reuse, deliverySha, body)
303
393
  }
304
394
  const number = parsePullRequestNumber(created.stdout)
305
- if (!number) return failResult(`Created the reconciliation pull request for ${branch} but could not parse its number.`)
395
+ if (!number)
396
+ return failResult(
397
+ `Created the reconciliation pull request for ${branch} but could not parse its number.`
398
+ )
306
399
  const url = created.stdout.trim()
307
400
  return {
308
401
  success: true,
@@ -329,7 +422,11 @@ function prepareExactTreeReconciliationHead(target, stagingSha, targetSha, title
329
422
  const tree = git(target, ['rev-parse', `${targetSha}^{tree}`])
330
423
  const timestamp = git(target, ['show', '-s', '--format=%cI', targetSha])
331
424
  if (!tree || !timestamp) {
332
- return { status: 1, sha: '', message: `could not resolve target tree or timestamp for ${targetSha}.` }
425
+ return {
426
+ status: 1,
427
+ sha: '',
428
+ message: `could not resolve target tree or timestamp for ${targetSha}.`,
429
+ }
333
430
  }
334
431
  const message = `${title}\n\nReconciled from target snapshot ${targetSha.slice(0, 12)}.`
335
432
  const result = spawnSync('git', ['commit-tree', tree, '-p', stagingSha, '-m', message], {
@@ -350,16 +447,22 @@ function prepareExactTreeReconciliationHead(target, stagingSha, targetSha, title
350
447
  return {
351
448
  status: result.status,
352
449
  sha,
353
- message: result.status === 0 && sha ? '' : sanitizeReconcileOutput(detail) || 'git commit-tree failed.',
450
+ message:
451
+ result.status === 0 && sha
452
+ ? ''
453
+ : sanitizeReconcileOutput(detail) || 'git commit-tree failed.',
354
454
  }
355
455
  }
356
456
 
357
457
  /** @param {string} branch @param {string} stderr */
358
458
  function reconciliationPullRequestCreateError(branch, stderr) {
359
459
  const detail = sanitizeReconcileOutput(stderr) || 'unknown error'
360
- const permissionHint = /github actions is not permitted to create or approve pull requests|resource not accessible by integration/i.test(detail)
361
- ? ' Enable Settings > Actions > General > Workflow permissions > Allow GitHub Actions to create and approve pull requests, then rerun the workflow.'
362
- : ''
460
+ const permissionHint =
461
+ /github actions is not permitted to create or approve pull requests|resource not accessible by integration/i.test(
462
+ detail
463
+ )
464
+ ? ' Enable Settings > Actions > General > Workflow permissions > Allow GitHub Actions to create and approve pull requests, then rerun the workflow.'
465
+ : ''
363
466
  return `gh pr create failed for ${branch}: ${detail}${permissionHint}`
364
467
  }
365
468
 
@@ -377,13 +480,29 @@ function reconciliationPullRequestCreateError(branch, stderr) {
377
480
  function reuseReconciliationPullRequest(target, repository, pr, deliverySha, body) {
378
481
  const number = Number(pr.number)
379
482
  if (String(pr.headRefOid ?? '') !== deliverySha) {
380
- const pushed = pushReconciliationHead(target, /** @type {string} */ (pr.headRefName), deliverySha)
483
+ const pushed = pushReconciliationHead(
484
+ target,
485
+ /** @type {string} */ (pr.headRefName),
486
+ deliverySha
487
+ )
381
488
  if (pushed.status !== 0) {
382
- return failResult(`Failed to refresh the reconciliation branch ${pr.headRefName} to the exact-tree delivery head: ${sanitizeReconcileOutput(pushed.message)}`)
489
+ return failResult(
490
+ `Failed to refresh the reconciliation branch ${pr.headRefName} to the exact-tree delivery head: ${sanitizeReconcileOutput(pushed.message)}`
491
+ )
383
492
  }
384
- const edited = ghSpawn(target, ['pr', 'edit', String(number), '--repo', repository, '--body', body])
493
+ const edited = ghSpawn(target, [
494
+ 'pr',
495
+ 'edit',
496
+ String(number),
497
+ '--repo',
498
+ repository,
499
+ '--body',
500
+ body,
501
+ ])
385
502
  if (edited.status !== 0) {
386
- return failResult(`Failed to update reconciliation pull request #${number}: ${sanitizeReconcileOutput(edited.stderr) || 'unknown error'}`)
503
+ return failResult(
504
+ `Failed to update reconciliation pull request #${number}: ${sanitizeReconcileOutput(edited.stderr) || 'unknown error'}`
505
+ )
387
506
  }
388
507
  }
389
508
  return {
@@ -411,11 +530,20 @@ function reuseReconciliationPullRequest(target, repository, pr, deliverySha, bod
411
530
  */
412
531
  function pushReconciliationHead(target, branch, deliverySha) {
413
532
  const existingTip = remoteRefSha(target, branch)
414
- if (existingTip === null) return { status: 1, message: `could not resolve the remote reconciliation branch ${branch} before pushing.` }
533
+ if (existingTip === null)
534
+ return {
535
+ status: 1,
536
+ message: `could not resolve the remote reconciliation branch ${branch} before pushing.`,
537
+ }
415
538
  if (existingTip === deliverySha) return { status: 0, message: '' }
416
539
  if (!existingTip) {
417
- const result = spawnSync('git', ['push', 'origin', `${deliverySha}:refs/heads/${branch}`], { cwd: target, encoding: 'utf8' })
418
- const message = `${result.stdout?.trim() || ''}\n${result.stderr?.trim() || ''}`.trim() || `failed to create reconciliation branch ${branch}.`
540
+ const result = spawnSync('git', ['push', 'origin', `${deliverySha}:refs/heads/${branch}`], {
541
+ cwd: target,
542
+ encoding: 'utf8',
543
+ })
544
+ const message =
545
+ `${result.stdout?.trim() || ''}\n${result.stderr?.trim() || ''}`.trim() ||
546
+ `failed to create reconciliation branch ${branch}.`
419
547
  return { status: result.status, message: sanitizeReconcileOutput(message) }
420
548
  }
421
549
  return pushWithLease(target, branch, existingTip, deliverySha)
@@ -428,7 +556,18 @@ function pushReconciliationHead(target, branch, deliverySha) {
428
556
  * @returns {Array<{number?: number, title?: string, headRefName?: string, headRefOid?: string, baseRefName?: string, url?: string}> | null}
429
557
  */
430
558
  function listOpenHeadPullRequests(target, repository, branch) {
431
- const result = ghSpawn(target, ['pr', 'list', '--repo', repository, '--state', 'open', '--head', branch, '--json', 'number,title,headRefName,headRefOid,baseRefName,url'])
559
+ const result = ghSpawn(target, [
560
+ 'pr',
561
+ 'list',
562
+ '--repo',
563
+ repository,
564
+ '--state',
565
+ 'open',
566
+ '--head',
567
+ branch,
568
+ '--json',
569
+ 'number,title,headRefName,headRefOid,baseRefName,url',
570
+ ])
432
571
  if (result.status !== 0) return null
433
572
  try {
434
573
  const prs = JSON.parse(result.stdout)
@@ -446,13 +585,19 @@ function parsePullRequestNumber(stdout) {
446
585
 
447
586
  /** @param {string} target @param {string} head @param {string} expectedSha @param {string} tipSha */
448
587
  function pushWithLease(target, head, expectedSha, tipSha) {
449
- const result = spawnSync('git', [
450
- 'push',
451
- 'origin',
452
- `--force-with-lease=refs/heads/${head}:${expectedSha}`,
453
- `${tipSha}:refs/heads/${head}`,
454
- ], { cwd: target, encoding: 'utf8' })
455
- const message = `${result.stdout?.trim() || ''}\n${result.stderr?.trim() || ''}`.trim() || `push with lease failed for ${head}.`
588
+ const result = spawnSync(
589
+ 'git',
590
+ [
591
+ 'push',
592
+ 'origin',
593
+ `--force-with-lease=refs/heads/${head}:${expectedSha}`,
594
+ `${tipSha}:refs/heads/${head}`,
595
+ ],
596
+ { cwd: target, encoding: 'utf8' }
597
+ )
598
+ const message =
599
+ `${result.stdout?.trim() || ''}\n${result.stderr?.trim() || ''}`.trim() ||
600
+ `push with lease failed for ${head}.`
456
601
  return {
457
602
  status: result.status,
458
603
  message: sanitizeReconcileOutput(message),
@@ -469,10 +614,18 @@ function classifyPushFailure(branch, raw) {
469
614
  const message = sanitizeReconcileOutput(raw)
470
615
  if (!message) return { category: 'other', message: `push with lease failed for ${branch}.` }
471
616
  const lower = message.toLowerCase()
472
- if (/permission denied|authentication failed|could not read from remote repository|publickey|not authorized|bad credentials/.test(lower)) {
617
+ if (
618
+ /permission denied|authentication failed|could not read from remote repository|publickey|not authorized|bad credentials/.test(
619
+ lower
620
+ )
621
+ ) {
473
622
  return { category: 'authentication', message }
474
623
  }
475
- if (/changes must be made through a pull request|protected branch|protected branch hook declined|required status checks|pre-receive hook|branch policy|repository rule|ruleset|push declined due to repository rule|gh006|gh007|gh008|gh013/.test(lower)) {
624
+ if (
625
+ /changes must be made through a pull request|protected branch|protected branch hook declined|required status checks|pre-receive hook|branch policy|repository rule|ruleset|push declined due to repository rule|gh006|gh007|gh008|gh013/.test(
626
+ lower
627
+ )
628
+ ) {
476
629
  return { category: 'policy', message }
477
630
  }
478
631
  return { category: 'other', message }
@@ -503,23 +656,43 @@ function replayOntoMain(target, mainSha, commits) {
503
656
  'user.email=41898282+github-actions[bot]@users.noreply.github.com',
504
657
  ]
505
658
  try {
506
- const added = spawnSync('git', ['worktree', 'add', '--detach', workspace, mainSha], { cwd: target, encoding: 'utf8' })
507
- if (added.status !== 0) throw new Error(`Failed to create temporary replay worktree: ${added.stderr?.trim() || added.stdout?.trim()}`)
659
+ const added = spawnSync('git', ['worktree', 'add', '--detach', workspace, mainSha], {
660
+ cwd: target,
661
+ encoding: 'utf8',
662
+ })
663
+ if (added.status !== 0)
664
+ throw new Error(
665
+ `Failed to create temporary replay worktree: ${added.stderr?.trim() || added.stdout?.trim()}`
666
+ )
508
667
  for (const sha of orderedCommits) {
509
- const cherryPick = spawnSync('git', [...identityArgs, 'cherry-pick', sha], { cwd: workspace, encoding: 'utf8' })
668
+ const cherryPick = spawnSync('git', [...identityArgs, 'cherry-pick', sha], {
669
+ cwd: workspace,
670
+ encoding: 'utf8',
671
+ })
510
672
  if (cherryPick.status !== 0) {
511
673
  spawnSync('git', ['cherry-pick', '--abort'], { cwd: workspace, encoding: 'utf8' })
512
- const details = [cherryPick.stdout?.trim(), cherryPick.stderr?.trim()].filter(Boolean).join('\n')
513
- throw new Error(`Cherry-pick of ${sha} failed while replaying staging commits: ${sanitizeReconcileOutput(details)}`)
674
+ const details = [cherryPick.stdout?.trim(), cherryPick.stderr?.trim()]
675
+ .filter(Boolean)
676
+ .join('\n')
677
+ throw new Error(
678
+ `Cherry-pick of ${sha} failed while replaying staging commits: ${sanitizeReconcileOutput(details)}`
679
+ )
514
680
  }
515
681
  }
516
682
  const replayTip = git(workspace, ['rev-parse', 'HEAD'])
517
683
  if (!replayTip) throw new Error('Failed to resolve replay tip SHA.')
518
- const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', mainSha, replayTip], { cwd: workspace, encoding: 'utf8' })
519
- if (ancestry.status !== 0) throw new Error('Replayed head is not based on the current main tip.')
684
+ const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', mainSha, replayTip], {
685
+ cwd: workspace,
686
+ encoding: 'utf8',
687
+ })
688
+ if (ancestry.status !== 0)
689
+ throw new Error('Replayed head is not based on the current main tip.')
520
690
  return replayTip
521
691
  } finally {
522
- spawnSync('git', ['worktree', 'remove', '--force', workspace], { cwd: target, encoding: 'utf8' })
692
+ spawnSync('git', ['worktree', 'remove', '--force', workspace], {
693
+ cwd: target,
694
+ encoding: 'utf8',
695
+ })
523
696
  rmSync(workspace, { recursive: true, force: true })
524
697
  }
525
698
  }
@@ -533,26 +706,63 @@ export function dispatchPostReleaseHook(root, options) {
533
706
  const repository = process.env.GITHUB_REPOSITORY
534
707
  if (!repository) throw new Error('GITHUB_REPOSITORY is required for post-release hooks.')
535
708
  if (!options.tag) throw new Error('--tag is required for post-release hooks.')
536
- const decision = selectHookDelivery({ mode: options.mode, tokenPresent: Boolean(process.env.CODE_FOUNDRY_TOKEN) })
537
- console.log(JSON.stringify({ repository, tag: options.tag, workflow: options.workflow, ...decision }, null, 2))
709
+ const decision = selectHookDelivery({
710
+ mode: options.mode,
711
+ tokenPresent: Boolean(process.env.CODE_FOUNDRY_TOKEN),
712
+ })
713
+ console.log(
714
+ JSON.stringify(
715
+ { repository, tag: options.tag, workflow: options.workflow, ...decision },
716
+ null,
717
+ 2
718
+ )
719
+ )
538
720
  if (decision.delivery === 'disabled' || decision.delivery === 'release-event') return decision
539
721
  if (decision.delivery === 'unavailable') throw new Error(decision.reason)
540
722
  if (!options.workflow) throw new Error('--workflow is required for workflow-dispatch hooks.')
541
723
  const key = releaseDeliveryKey(repository, options.tag)
542
- const runs = ghJson(root, ['run', 'list', '--repo', repository, '--workflow', options.workflow, '--limit', '100', '--json', 'headBranch,displayTitle,status'])
724
+ const runs = ghJson(root, [
725
+ 'run',
726
+ 'list',
727
+ '--repo',
728
+ repository,
729
+ '--workflow',
730
+ options.workflow,
731
+ '--limit',
732
+ '100',
733
+ '--json',
734
+ 'headBranch,displayTitle,status',
735
+ ])
543
736
  if (hasDeliveredHook(Array.isArray(runs) ? runs : [], options.tag)) {
544
- console.log(`Post-release hook already delivered for ${options.tag}; skipping duplicate dispatch.`)
737
+ console.log(
738
+ `Post-release hook already delivered for ${options.tag}; skipping duplicate dispatch.`
739
+ )
545
740
  return { ...decision, deliveryKey: key, skipped: true }
546
741
  }
547
742
  if (options.dryRun) return { ...decision, deliveryKey: key, dispatched: false }
548
- const result = spawnSync('gh', [
549
- 'workflow', 'run', options.workflow,
550
- '--repo', repository,
551
- '--ref', options.tag,
552
- '--field', `release-tag=${options.tag}`,
553
- '--field', `delivery-key=${key}`,
554
- ], { cwd: resolve(root), stdio: 'inherit', env: { ...process.env, GH_TOKEN: process.env.CODE_FOUNDRY_TOKEN } })
555
- if (result.status !== 0) throw new Error(`Failed to dispatch post-release workflow ${options.workflow}.`)
743
+ const result = spawnSync(
744
+ 'gh',
745
+ [
746
+ 'workflow',
747
+ 'run',
748
+ options.workflow,
749
+ '--repo',
750
+ repository,
751
+ '--ref',
752
+ options.tag,
753
+ '--field',
754
+ `release-tag=${options.tag}`,
755
+ '--field',
756
+ `delivery-key=${key}`,
757
+ ],
758
+ {
759
+ cwd: resolve(root),
760
+ stdio: 'inherit',
761
+ env: { ...process.env, GH_TOKEN: process.env.CODE_FOUNDRY_TOKEN },
762
+ }
763
+ )
764
+ if (result.status !== 0)
765
+ throw new Error(`Failed to dispatch post-release workflow ${options.workflow}.`)
556
766
  return { ...decision, deliveryKey: key, dispatched: true }
557
767
  }
558
768
 
@@ -562,7 +772,18 @@ export function validateReleasePullRequestDiffs(root) {
562
772
  if (!repository) throw new Error('GITHUB_REPOSITORY is required for release PR validation.')
563
773
  let prs = []
564
774
  for (let attempt = 0; attempt < 5; attempt += 1) {
565
- const result = ghJson(root, ['pr', 'list', '--repo', repository, '--state', 'open', '--base', 'main', '--json', 'number,title,headRefName'])
775
+ const result = ghJson(root, [
776
+ 'pr',
777
+ 'list',
778
+ '--repo',
779
+ repository,
780
+ '--state',
781
+ 'open',
782
+ '--base',
783
+ 'main',
784
+ '--json',
785
+ 'number,title,headRefName',
786
+ ])
566
787
  prs = Array.isArray(result) ? result : []
567
788
  if (selectGeneratedReleasePrs(prs).length) break
568
789
  if (attempt < 4) spawnSync('sleep', ['2'])
@@ -572,10 +793,18 @@ export function validateReleasePullRequestDiffs(root) {
572
793
  const paths = new Map()
573
794
  for (const pr of generated) {
574
795
  const number = Number(pr.number)
575
- const result = spawnSync('gh', ['pr', 'diff', String(number), '--repo', repository, '--name-only'], { cwd: resolve(root), encoding: 'utf8' })
796
+ const result = spawnSync(
797
+ 'gh',
798
+ ['pr', 'diff', String(number), '--repo', repository, '--name-only'],
799
+ { cwd: resolve(root), encoding: 'utf8' }
800
+ )
576
801
  paths.set(number, result.status === 0 ? result.stdout.split(/\r?\n/).filter(Boolean) : [])
577
802
  }
578
- const validation = validateReleasePullRequests(Array.isArray(prs) ? prs : [], paths, approvedReleaseFiles(readReleaseConfig(root)))
803
+ const validation = validateReleasePullRequests(
804
+ Array.isArray(prs) ? prs : [],
805
+ paths,
806
+ approvedReleaseFiles(readReleaseConfig(root))
807
+ )
579
808
  console.log(JSON.stringify(validation, null, 2))
580
809
  if (!validation.valid) throw new Error(validation.errors.join(' '))
581
810
  return validation
@@ -586,13 +815,35 @@ export function releaseRecoveryPlan(root) {
586
815
  const repository = process.env.GITHUB_REPOSITORY
587
816
  if (!repository) throw new Error('GITHUB_REPOSITORY is required for release recovery planning.')
588
817
  const tags = ghJson(root, ['api', `repos/${repository}/tags?per_page=100`])
589
- const releases = ghJson(root, ['release', 'list', '--repo', repository, '--limit', '100', '--json', 'tagName,name,isDraft,isPrerelease'])
590
- const releasePrs = ghJson(root, ['pr', 'list', '--repo', repository, '--state', 'open', '--base', 'main', '--json', 'number,title'])
818
+ const releases = ghJson(root, [
819
+ 'release',
820
+ 'list',
821
+ '--repo',
822
+ repository,
823
+ '--limit',
824
+ '100',
825
+ '--json',
826
+ 'tagName,name,isDraft,isPrerelease',
827
+ ])
828
+ const releasePrs = ghJson(root, [
829
+ 'pr',
830
+ 'list',
831
+ '--repo',
832
+ repository,
833
+ '--state',
834
+ 'open',
835
+ '--base',
836
+ 'main',
837
+ '--json',
838
+ 'number,title',
839
+ ])
591
840
  const packageVersions = localPackageVersions(root)
592
841
  const plan = buildReleaseRecoveryPlan({
593
842
  tags: Array.isArray(tags) ? tags.map((tag) => tag.name).filter(Boolean) : [],
594
843
  releases: Array.isArray(releases) ? releases : [],
595
- releasePrs: Array.isArray(releasePrs) ? releasePrs.filter((pr) => /^chore\(main\): release /.test(pr.title ?? '')) : [],
844
+ releasePrs: Array.isArray(releasePrs)
845
+ ? releasePrs.filter((pr) => String(pr.title ?? '').startsWith('chore(main): release '))
846
+ : [],
596
847
  packageVersions,
597
848
  })
598
849
  console.log(JSON.stringify(plan, null, 2))
@@ -608,17 +859,23 @@ function git(root, args) {
608
859
  /** @param {string} root @param {string} from @param {string} to @returns {Array<{ sha: string, changedPaths: string[] }>} */
609
860
  function divergentCommits(root, from, to) {
610
861
  if (!from || !to || from === to) return []
611
- const result = spawnSync('git', [
612
- 'log',
613
- '--cherry-pick',
614
- '--no-merges',
615
- '--left-right',
616
- '--reverse',
617
- '--pretty=tformat:%m%H',
618
- `${from}...${to}`,
619
- ], { cwd: root, encoding: 'utf8' })
862
+ const result = spawnSync(
863
+ 'git',
864
+ [
865
+ 'log',
866
+ '--cherry-pick',
867
+ '--no-merges',
868
+ '--left-right',
869
+ '--reverse',
870
+ '--pretty=tformat:%m%H',
871
+ `${from}...${to}`,
872
+ ],
873
+ { cwd: root, encoding: 'utf8' }
874
+ )
620
875
  if (result.status !== 0) {
621
- throw new Error(`Failed to enumerate divergent commits between ${from} and ${to}. ${result.stderr?.trim() || result.stdout?.trim()}`)
876
+ throw new Error(
877
+ `Failed to enumerate divergent commits between ${from} and ${to}. ${result.stderr?.trim() || result.stdout?.trim()}`
878
+ )
622
879
  }
623
880
  return result.stdout
624
881
  .split(/\r?\n/)
@@ -632,16 +889,27 @@ function divergentCommits(root, from, to) {
632
889
 
633
890
  /** @param {string} root @param {string} commitSha @returns {string[]} */
634
891
  function commitChangedPaths(root, commitSha) {
635
- const result = spawnSync('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', commitSha], { cwd: root, encoding: 'utf8' })
636
- if (result.status !== 0) throw new Error(`Unable to read changed paths for commit ${commitSha}. ${result.stderr?.trim() || result.stdout?.trim()}`)
892
+ const result = spawnSync('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', commitSha], {
893
+ cwd: root,
894
+ encoding: 'utf8',
895
+ })
896
+ if (result.status !== 0)
897
+ throw new Error(
898
+ `Unable to read changed paths for commit ${commitSha}. ${result.stderr?.trim() || result.stdout?.trim()}`
899
+ )
637
900
  return result.stdout.split(/\r?\n/).filter(Boolean)
638
901
  }
639
902
 
640
903
  /** @param {string} root @param {string} from @param {string} to @returns {string[]} */
641
904
  function treeChangedPaths(root, from, to) {
642
- const result = spawnSync('git', ['diff', '--name-only', from, to], { cwd: root, encoding: 'utf8' })
905
+ const result = spawnSync('git', ['diff', '--name-only', from, to], {
906
+ cwd: root,
907
+ encoding: 'utf8',
908
+ })
643
909
  if (result.status !== 0) {
644
- throw new Error(`Failed to compare branch trees between ${from} and ${to}. ${result.stderr?.trim() || result.stdout?.trim()}`)
910
+ throw new Error(
911
+ `Failed to compare branch trees between ${from} and ${to}. ${result.stderr?.trim() || result.stdout?.trim()}`
912
+ )
645
913
  }
646
914
  return result.stdout.split(/\r?\n/).filter(Boolean)
647
915
  }
@@ -649,15 +917,22 @@ function treeChangedPaths(root, from, to) {
649
917
  /** @param {string} root @param {string[]} args @returns {{ status: number | null, stdout: string, stderr: string }} */
650
918
  function ghSpawn(root, args) {
651
919
  const token = process.env.CODE_FOUNDRY_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN
652
- return spawnSync('gh', args, { cwd: resolve(root), encoding: 'utf8', env: { ...process.env, ...(token ? { GH_TOKEN: token } : {}) } })
920
+ return spawnSync('gh', args, {
921
+ cwd: resolve(root),
922
+ encoding: 'utf8',
923
+ env: { ...process.env, ...(token ? { GH_TOKEN: token } : {}) },
924
+ })
653
925
  }
654
926
 
655
927
  /** @param {string} root @param {string[]} args @returns {unknown} */
656
928
  function ghJson(root, args) {
657
929
  const result = ghSpawn(root, args)
658
930
  if (result.status !== 0) return []
659
- try { return JSON.parse(result.stdout) }
660
- catch { return [] }
931
+ try {
932
+ return JSON.parse(result.stdout)
933
+ } catch {
934
+ return []
935
+ }
661
936
  }
662
937
 
663
938
  /** @param {string} root @returns {string[]} */
@@ -665,10 +940,17 @@ function localPackageVersions(root) {
665
940
  const versions = []
666
941
  const packageJson = join(resolve(root), 'package.json')
667
942
  if (existsSync(packageJson)) {
668
- try { versions.push(JSON.parse(readFileSync(packageJson, 'utf8')).version) } catch { /* doctor handles malformed manifests */ }
943
+ try {
944
+ versions.push(JSON.parse(readFileSync(packageJson, 'utf8')).version)
945
+ } catch {
946
+ /* doctor handles malformed manifests */
947
+ }
669
948
  }
670
949
  /** @type {Array<[string, RegExp]>} */
671
- const manifests = [['Cargo.toml', /^version\s*=\s*["']([^"']+)["']/m], ['pyproject.toml', /^(?:version|version\s*)\s*=\s*["']([^"']+)["']/m]]
950
+ const manifests = [
951
+ ['Cargo.toml', /^version\s*=\s*["']([^"']+)["']/m],
952
+ ['pyproject.toml', /^(?:version|version\s*)\s*=\s*["']([^"']+)["']/m],
953
+ ]
672
954
  for (const [file, pattern] of manifests) {
673
955
  const path = join(resolve(root), file)
674
956
  if (!existsSync(path)) continue