code-foundry 1.2.0 → 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.
- package/.github/code-foundry.yml +1 -1
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/codeql.yml +7 -2
- package/.oxfmtrc.json +7 -0
- package/.oxlintrc.json +5 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +14 -0
- package/README.md +3 -2
- package/docs/CONFIGURATION.md +24 -24
- package/docs/WORKFLOWS.md +16 -16
- package/package.json +20 -17
- package/src/cli.mjs +60 -14
- package/src/commands/ci.mjs +166 -57
- package/src/commands/doctor.mjs +98 -31
- package/src/commands/fleet.mjs +139 -29
- package/src/commands/release.mjs +378 -96
- package/src/commands/sync.mjs +332 -104
- package/src/lib/config.mjs +8 -2
- package/src/lib/github-doctor.mjs +153 -51
- package/src/lib/overlay.mjs +6 -2
- package/src/lib/profile.mjs +26 -8
- package/src/lib/release-hook.mjs +17 -5
- package/src/lib/release-manifest.mjs +81 -19
- package/src/lib/release-policy.mjs +157 -55
- package/src/lib/test-discovery.mjs +7 -2
- package/src/runtime.mjs +259 -61
- package/.prettierignore +0 -9
- package/.prettierrc +0 -6
package/src/commands/release.mjs
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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)
|
|
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, ...
|
|
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(
|
|
50
|
-
|
|
51
|
-
|
|
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(
|
|
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], {
|
|
134
|
-
|
|
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}`], {
|
|
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)
|
|
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)
|
|
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(
|
|
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)
|
|
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(
|
|
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)
|
|
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(
|
|
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)
|
|
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)
|
|
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(
|
|
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)
|
|
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)
|
|
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, [
|
|
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)
|
|
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)
|
|
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)
|
|
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 {
|
|
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:
|
|
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 =
|
|
361
|
-
|
|
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(
|
|
483
|
+
const pushed = pushReconciliationHead(
|
|
484
|
+
target,
|
|
485
|
+
/** @type {string} */ (pr.headRefName),
|
|
486
|
+
deliverySha
|
|
487
|
+
)
|
|
381
488
|
if (pushed.status !== 0) {
|
|
382
|
-
return failResult(
|
|
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, [
|
|
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(
|
|
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)
|
|
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}`], {
|
|
418
|
-
|
|
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, [
|
|
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(
|
|
450
|
-
'
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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 (
|
|
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 (
|
|
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], {
|
|
507
|
-
|
|
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], {
|
|
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()]
|
|
513
|
-
|
|
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], {
|
|
519
|
-
|
|
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], {
|
|
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({
|
|
537
|
-
|
|
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, [
|
|
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(
|
|
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(
|
|
549
|
-
'
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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, [
|
|
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(
|
|
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(
|
|
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, [
|
|
590
|
-
|
|
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)
|
|
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(
|
|
612
|
-
'
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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(
|
|
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], {
|
|
636
|
-
|
|
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], {
|
|
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(
|
|
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, {
|
|
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 {
|
|
660
|
-
|
|
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 {
|
|
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 = [
|
|
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
|