code-foundry 0.39.0 → 0.39.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.
- package/CHANGELOG.md +7 -0
- package/docs/RELEASES.md +11 -2
- package/docs/WORKFLOWS.md +8 -5
- package/package.json +1 -1
- package/src/commands/release.mjs +65 -18
- package/src/lib/release-policy.mjs +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.39.1](https://github.com/0xPlayerOne/code-foundry/compare/v0.39.0...v0.39.1) (2026-08-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **release:** build exact-tree reconciliation heads ([33b0151](https://github.com/0xPlayerOne/code-foundry/commit/33b015102f07ca03ffb677fe4b9f5e67bbf9a485))
|
|
9
|
+
|
|
3
10
|
## [0.39.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.38.2...v0.39.0) (2026-08-29)
|
|
4
11
|
|
|
5
12
|
|
package/docs/RELEASES.md
CHANGED
|
@@ -76,8 +76,17 @@ as aligned. When `staging` has pending commits that are not yet represented on
|
|
|
76
76
|
detached worktree rooted at `main`, and then updates `staging` with an exact
|
|
77
77
|
`--force-with-lease` to prevent
|
|
78
78
|
unintended branch rewrites. There is no unconditional mirror force-push and no
|
|
79
|
-
fallback synchronization commit path. Indeterminate history, replay
|
|
80
|
-
and stale leases fail closed. `direct` repositories skip this step
|
|
79
|
+
unreviewed fallback synchronization commit path. Indeterminate history, replay
|
|
80
|
+
conflicts, and stale leases fail closed. `direct` repositories skip this step
|
|
81
|
+
entirely.
|
|
82
|
+
|
|
83
|
+
When branch policy requires reconciliation through a pull request, Code
|
|
84
|
+
Foundry creates a deterministic one-commit delivery head instead of pointing
|
|
85
|
+
the pull request branch at the divergent `main` or replay history. Its parent
|
|
86
|
+
is the current protected `staging` tip and its tree is the exact reconciled
|
|
87
|
+
target tree. This keeps the review limited to release metadata and any genuine
|
|
88
|
+
replayed staging work, while preserving exact-lease updates and idempotent PR
|
|
89
|
+
reuse.
|
|
81
90
|
|
|
82
91
|
`auto` selects a supported manifest. Use `simple` with `version.txt` for a
|
|
83
92
|
repository without a package manifest and `none` for a repository that should
|
package/docs/WORKFLOWS.md
CHANGED
|
@@ -139,11 +139,14 @@ a branch-policy/ruleset/required-PR error, the reconcile job does not fail:
|
|
|
139
139
|
if the branch trees are already identical, it reports a content-aligned no-op
|
|
140
140
|
because a normal pull request cannot safely represent a history-only ref move.
|
|
141
141
|
Otherwise, it opens (or reuses) a generated `code-foundry/reconcile/main-to-staging`
|
|
142
|
-
pull request whose
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
142
|
+
pull request whose deterministic one-commit head is parented at the current
|
|
143
|
+
`staging` tip and contains the exact reconciled target tree (the `main` tree for
|
|
144
|
+
a fast-forward, the replay tree when staging-only commits are replayed). This
|
|
145
|
+
avoids exposing divergent squash/rebase ancestry as an enormous PR diff. The
|
|
146
|
+
body documents both the target snapshot and exact-tree delivery head. Reruns
|
|
147
|
+
reuse the open pull request instead of duplicating it. Validated changes that
|
|
148
|
+
landed directly on `main` are synced back into `staging`; unpromoted staging
|
|
149
|
+
commits are replayed on top. Indeterminate
|
|
147
150
|
history, replay conflicts, authentication errors, and ambiguous or stale pull
|
|
148
151
|
request state still fail the job closed, and exact-lease protection stays in
|
|
149
152
|
place for every direct push.
|
package/package.json
CHANGED
package/src/commands/release.mjs
CHANGED
|
@@ -247,12 +247,18 @@ function deliverReconciliationPullRequest(target, base, head, state, targetSha,
|
|
|
247
247
|
const prBase = head
|
|
248
248
|
const branch = reconciliationPullRequestBranch(base, head)
|
|
249
249
|
const title = reconciliationPullRequestTitle({ targetHead: head, sourceBase: base })
|
|
250
|
+
const prepared = prepareExactTreeReconciliationHead(target, state.stagingSha, targetSha, title)
|
|
251
|
+
if (prepared.status !== 0 || !prepared.sha) {
|
|
252
|
+
return failResult(`Failed to prepare the exact-tree reconciliation head ${branch}: ${prepared.message}`)
|
|
253
|
+
}
|
|
254
|
+
const deliverySha = prepared.sha
|
|
250
255
|
const body = buildReconciliationPullRequestBody({
|
|
251
256
|
sourceBase: base,
|
|
252
257
|
targetHead: head,
|
|
253
258
|
mainSha: state.mainSha,
|
|
254
259
|
stagingSha: state.stagingSha,
|
|
255
260
|
targetSha,
|
|
261
|
+
deliverySha,
|
|
256
262
|
action: state.plan.action,
|
|
257
263
|
pushError,
|
|
258
264
|
})
|
|
@@ -263,9 +269,9 @@ function deliverReconciliationPullRequest(target, base, head, state, targetSha,
|
|
|
263
269
|
if (selection.error) return failResult(selection.error)
|
|
264
270
|
if (!selection.create) {
|
|
265
271
|
if (!selection.reuse) return failResult(`No reusable reconciliation pull request for ${branch}.`)
|
|
266
|
-
return reuseReconciliationPullRequest(target, repository, selection.reuse,
|
|
272
|
+
return reuseReconciliationPullRequest(target, repository, selection.reuse, deliverySha, body)
|
|
267
273
|
}
|
|
268
|
-
const pushed = pushReconciliationHead(target, branch,
|
|
274
|
+
const pushed = pushReconciliationHead(target, branch, deliverySha)
|
|
269
275
|
if (pushed.status !== 0) {
|
|
270
276
|
const classification = classifyPushFailure(branch, pushed.message)
|
|
271
277
|
if (classification.category === 'authentication') {
|
|
@@ -279,7 +285,7 @@ function deliverReconciliationPullRequest(target, base, head, state, targetSha,
|
|
|
279
285
|
if (selection.error) return failResult(selection.error)
|
|
280
286
|
if (!selection.create) {
|
|
281
287
|
if (!selection.reuse) return failResult(`No reusable reconciliation pull request for ${branch} after pushing its head.`)
|
|
282
|
-
return reuseReconciliationPullRequest(target, repository, selection.reuse,
|
|
288
|
+
return reuseReconciliationPullRequest(target, repository, selection.reuse, deliverySha, body)
|
|
283
289
|
}
|
|
284
290
|
const created = ghSpawn(target, ['pr', 'create', '--repo', repository, '--base', prBase, '--head', branch, '--title', title, '--body', body])
|
|
285
291
|
if (created.status !== 0) {
|
|
@@ -293,7 +299,7 @@ function deliverReconciliationPullRequest(target, base, head, state, targetSha,
|
|
|
293
299
|
return failResult(reconciliationPullRequestCreateError(branch, created.stderr))
|
|
294
300
|
}
|
|
295
301
|
if (!selection.reuse) return failResult(`No reusable reconciliation pull request for ${branch} after gh pr create failed.`)
|
|
296
|
-
return reuseReconciliationPullRequest(target, repository, selection.reuse,
|
|
302
|
+
return reuseReconciliationPullRequest(target, repository, selection.reuse, deliverySha, body)
|
|
297
303
|
}
|
|
298
304
|
const number = parsePullRequestNumber(created.stdout)
|
|
299
305
|
if (!number) return failResult(`Created the reconciliation pull request for ${branch} but could not parse its number.`)
|
|
@@ -308,6 +314,46 @@ function deliverReconciliationPullRequest(target, base, head, state, targetSha,
|
|
|
308
314
|
}
|
|
309
315
|
}
|
|
310
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Build a deterministic single-commit delivery head whose parent is the
|
|
319
|
+
* protected staging tip and whose tree is the fully reconciled target tree.
|
|
320
|
+
* GitHub therefore reviews only the real content delta even when main and
|
|
321
|
+
* staging have divergent squash/rebase ancestry.
|
|
322
|
+
* @param {string} target
|
|
323
|
+
* @param {string} stagingSha
|
|
324
|
+
* @param {string} targetSha
|
|
325
|
+
* @param {string} title
|
|
326
|
+
* @returns {{ status: number | null, sha: string, message: string }}
|
|
327
|
+
*/
|
|
328
|
+
function prepareExactTreeReconciliationHead(target, stagingSha, targetSha, title) {
|
|
329
|
+
const tree = git(target, ['rev-parse', `${targetSha}^{tree}`])
|
|
330
|
+
const timestamp = git(target, ['show', '-s', '--format=%cI', targetSha])
|
|
331
|
+
if (!tree || !timestamp) {
|
|
332
|
+
return { status: 1, sha: '', message: `could not resolve target tree or timestamp for ${targetSha}.` }
|
|
333
|
+
}
|
|
334
|
+
const message = `${title}\n\nReconciled from target snapshot ${targetSha.slice(0, 12)}.`
|
|
335
|
+
const result = spawnSync('git', ['commit-tree', tree, '-p', stagingSha, '-m', message], {
|
|
336
|
+
cwd: target,
|
|
337
|
+
encoding: 'utf8',
|
|
338
|
+
env: {
|
|
339
|
+
...process.env,
|
|
340
|
+
GIT_AUTHOR_NAME: 'github-actions[bot]',
|
|
341
|
+
GIT_AUTHOR_EMAIL: '41898282+github-actions[bot]@users.noreply.github.com',
|
|
342
|
+
GIT_AUTHOR_DATE: timestamp,
|
|
343
|
+
GIT_COMMITTER_NAME: 'github-actions[bot]',
|
|
344
|
+
GIT_COMMITTER_EMAIL: '41898282+github-actions[bot]@users.noreply.github.com',
|
|
345
|
+
GIT_COMMITTER_DATE: timestamp,
|
|
346
|
+
},
|
|
347
|
+
})
|
|
348
|
+
const sha = result.status === 0 ? result.stdout.trim() : ''
|
|
349
|
+
const detail = `${result.stdout?.trim() || ''}\n${result.stderr?.trim() || ''}`.trim()
|
|
350
|
+
return {
|
|
351
|
+
status: result.status,
|
|
352
|
+
sha,
|
|
353
|
+
message: result.status === 0 && sha ? '' : sanitizeReconcileOutput(detail) || 'git commit-tree failed.',
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
311
357
|
/** @param {string} branch @param {string} stderr */
|
|
312
358
|
function reconciliationPullRequestCreateError(branch, stderr) {
|
|
313
359
|
const detail = sanitizeReconcileOutput(stderr) || 'unknown error'
|
|
@@ -318,22 +364,22 @@ function reconciliationPullRequestCreateError(branch, stderr) {
|
|
|
318
364
|
}
|
|
319
365
|
|
|
320
366
|
/**
|
|
321
|
-
* Refresh an existing reconciliation pull request to the exact
|
|
322
|
-
* body. The head branch is pushed under an exact lease when
|
|
367
|
+
* Refresh an existing reconciliation pull request to the exact-tree delivery
|
|
368
|
+
* head and body. The head branch is pushed under an exact lease when stale,
|
|
323
369
|
* so a concurrent move fails closed instead of being clobbered.
|
|
324
370
|
* @param {string} target
|
|
325
371
|
* @param {string} repository
|
|
326
372
|
* @param {{number?: number, title?: string, headRefName?: string, headRefOid?: string, baseRefName?: string, url?: string}} pr
|
|
327
|
-
* @param {string}
|
|
373
|
+
* @param {string} deliverySha
|
|
328
374
|
* @param {string} body
|
|
329
375
|
* @returns {{ success: false, retry: false, error: string } | { success: true, result: ReconciliationMutationResult }}
|
|
330
376
|
*/
|
|
331
|
-
function reuseReconciliationPullRequest(target, repository, pr,
|
|
377
|
+
function reuseReconciliationPullRequest(target, repository, pr, deliverySha, body) {
|
|
332
378
|
const number = Number(pr.number)
|
|
333
|
-
if (String(pr.headRefOid ?? '') !==
|
|
334
|
-
const pushed = pushReconciliationHead(target, /** @type {string} */ (pr.headRefName),
|
|
379
|
+
if (String(pr.headRefOid ?? '') !== deliverySha) {
|
|
380
|
+
const pushed = pushReconciliationHead(target, /** @type {string} */ (pr.headRefName), deliverySha)
|
|
335
381
|
if (pushed.status !== 0) {
|
|
336
|
-
return failResult(`Failed to refresh the reconciliation branch ${pr.headRefName} to the
|
|
382
|
+
return failResult(`Failed to refresh the reconciliation branch ${pr.headRefName} to the exact-tree delivery head: ${sanitizeReconcileOutput(pushed.message)}`)
|
|
337
383
|
}
|
|
338
384
|
const edited = ghSpawn(target, ['pr', 'edit', String(number), '--repo', repository, '--body', body])
|
|
339
385
|
if (edited.status !== 0) {
|
|
@@ -358,20 +404,21 @@ function reuseReconciliationPullRequest(target, repository, pr, targetSha, body)
|
|
|
358
404
|
}
|
|
359
405
|
|
|
360
406
|
/**
|
|
361
|
-
* Push the exact
|
|
362
|
-
* creating it when absent and refreshing it under an exact lease
|
|
363
|
-
*
|
|
407
|
+
* Push the exact-tree delivery commit to the deterministic reconciliation
|
|
408
|
+
* head branch, creating it when absent and refreshing it under an exact lease
|
|
409
|
+
* otherwise.
|
|
410
|
+
* @param {string} target @param {string} branch @param {string} deliverySha
|
|
364
411
|
*/
|
|
365
|
-
function pushReconciliationHead(target, branch,
|
|
412
|
+
function pushReconciliationHead(target, branch, deliverySha) {
|
|
366
413
|
const existingTip = remoteRefSha(target, branch)
|
|
367
414
|
if (existingTip === null) return { status: 1, message: `could not resolve the remote reconciliation branch ${branch} before pushing.` }
|
|
368
|
-
if (existingTip ===
|
|
415
|
+
if (existingTip === deliverySha) return { status: 0, message: '' }
|
|
369
416
|
if (!existingTip) {
|
|
370
|
-
const result = spawnSync('git', ['push', 'origin', `${
|
|
417
|
+
const result = spawnSync('git', ['push', 'origin', `${deliverySha}:refs/heads/${branch}`], { cwd: target, encoding: 'utf8' })
|
|
371
418
|
const message = `${result.stdout?.trim() || ''}\n${result.stderr?.trim() || ''}`.trim() || `failed to create reconciliation branch ${branch}.`
|
|
372
419
|
return { status: result.status, message: sanitizeReconcileOutput(message) }
|
|
373
420
|
}
|
|
374
|
-
return pushWithLease(target, branch, existingTip,
|
|
421
|
+
return pushWithLease(target, branch, existingTip, deliverySha)
|
|
375
422
|
}
|
|
376
423
|
|
|
377
424
|
/**
|
|
@@ -412,27 +412,29 @@ export function reconciliationPullRequestTitle({ targetHead, sourceBase }) {
|
|
|
412
412
|
* mainSha: string,
|
|
413
413
|
* stagingSha: string,
|
|
414
414
|
* targetSha: string,
|
|
415
|
+
* deliverySha?: string,
|
|
415
416
|
* action: string,
|
|
416
417
|
* pushError?: string,
|
|
417
418
|
* }} input
|
|
418
419
|
*/
|
|
419
420
|
export function buildReconciliationPullRequestBody(input) {
|
|
420
|
-
const { sourceBase, targetHead, mainSha, stagingSha, targetSha, action, pushError = '' } = input
|
|
421
|
+
const { sourceBase, targetHead, mainSha, stagingSha, targetSha, deliverySha = '', action, pushError = '' } = input
|
|
421
422
|
const lines = [
|
|
422
423
|
'## Automated release reconciliation',
|
|
423
424
|
'',
|
|
424
|
-
`This pull request was generated by Code Foundry release reconciliation. The protected \`${targetHead}\` branch rejected the exact-lease synchronization push that carries release metadata published on \`${sourceBase}\` (branch policy, ruleset, or required pull request review), so this pull request delivers the exact
|
|
425
|
+
`This pull request was generated by Code Foundry release reconciliation. The protected \`${targetHead}\` branch rejected the exact-lease synchronization push that carries release metadata published on \`${sourceBase}\` (branch policy, ruleset, or required pull request review), so this pull request delivers the exact reconciled tree through the normal review flow instead.`,
|
|
425
426
|
'',
|
|
426
427
|
`- Base \`${targetHead}\`: \`${stagingSha}\``,
|
|
427
428
|
`- \`${sourceBase}\` tip: \`${mainSha}\``,
|
|
428
|
-
`-
|
|
429
|
+
`- Reconciled target snapshot: \`${targetSha}\``,
|
|
430
|
+
...(deliverySha ? [`- Exact-tree pull request head: \`${deliverySha}\``] : []),
|
|
429
431
|
`- Synchronization: \`${action}\``,
|
|
430
432
|
]
|
|
431
433
|
const reason = pushError.replace(/\s+/g, ' ').trim()
|
|
432
434
|
if (reason) lines.push(`- Direct push rejection: \`${reason}\``)
|
|
433
435
|
lines.push(
|
|
434
436
|
'',
|
|
435
|
-
`Merging this pull request synchronizes \`${targetHead}\` with the exact
|
|
437
|
+
`Merging this pull request synchronizes \`${targetHead}\` with the exact reconciled tree above; no unrelated commits are introduced.`,
|
|
436
438
|
'',
|
|
437
439
|
`Code Foundry treats validated \`${sourceBase}\` as the shipped source of truth and preserves unpromoted \`${targetHead}\` work by replaying it on top. Indeterminate history, replay conflicts, authentication failures, and ambiguous or stale state fail the release job closed and are never pushed around branch protection. Later releases retry the direct push first; when branch policy still rejects it, this pull request is updated instead of duplicated.`,
|
|
438
440
|
)
|