code-foundry 0.34.4 → 0.34.6

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.
@@ -11,7 +11,7 @@ dependency_review: auto
11
11
  package_manager: bun
12
12
  toolchain: auto
13
13
  runtime_repository: 0xPlayerOne/code-foundry
14
- runtime_ref: v0.32.3
14
+ runtime_ref: v0.34.4
15
15
  release_type: node
16
16
  npm_publish: true
17
17
  license: agpl-3.0-or-later
@@ -41,7 +41,9 @@ jobs:
41
41
  if: needs.detect.outputs.enabled == 'true' && needs.detect.outputs.token == 'true'
42
42
  uses: 0xPlayerOne/opencode-security/.github/workflows/opencode-security.yml@b3dce823322672b285fbe99b870ea984c01826cb # main
43
43
  with:
44
- source_ref: ${{ github.event.pull_request.head.ref || 'main' }}
44
+ # This is the scanner checkout ref, not the consumer PR ref. The
45
+ # reusable workflow already checks out the consumer repository.
46
+ source_ref: b3dce823322672b285fbe99b870ea984c01826cb
45
47
  runner: ubuntu-latest
46
48
  mode: standard
47
49
  fail_on_severity: high
@@ -44,7 +44,7 @@ env:
44
44
 
45
45
  jobs:
46
46
  release:
47
- name: Release / Version
47
+ name: Version
48
48
  runs-on: ${{ inputs.runner }}
49
49
  timeout-minutes: 20
50
50
  concurrency:
@@ -284,7 +284,7 @@ jobs:
284
284
  done
285
285
 
286
286
  reconcile:
287
- name: Release / Reconcile
287
+ name: Reconcile
288
288
  needs: release
289
289
  # A normal promotion push can successfully run Release Please without
290
290
  # creating a release. Reconciliation is only meaningful after a release
@@ -376,7 +376,7 @@ jobs:
376
376
  run: echo 'No staging branch exists; release reconciliation is not applicable.'
377
377
 
378
378
  post-release:
379
- name: Release / Post Hook
379
+ name: Post Hook
380
380
  needs: release
381
381
  if: needs.release.outputs.release_created == 'true'
382
382
  runs-on: ${{ inputs.runner }}
@@ -432,7 +432,7 @@ jobs:
432
432
  run: echo 'No post-release workflow configured; artifact delivery is intentionally skipped.'
433
433
 
434
434
  npm:
435
- name: Release / Publish npm
435
+ name: Publish npm
436
436
  needs: release
437
437
  if: needs.release.outputs.release_created == 'true' && needs.release.outputs.npm_publish == 'true'
438
438
  runs-on: ${{ inputs.runner }}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.34.6](https://github.com/0xPlayerOne/code-foundry/compare/v0.34.5...v0.34.6) (2026-08-02)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **sync:** keep prettier ignores idempotent ([5db5307](https://github.com/0xPlayerOne/code-foundry/commit/5db53072fded27a57cca3cedf7c40527cd238507))
9
+
10
+ ## [0.34.5](https://github.com/0xPlayerOne/code-foundry/compare/v0.34.4...v0.34.5) (2026-08-02)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **release:** reconcile protected staging branches ([fa18bfb](https://github.com/0xPlayerOne/code-foundry/commit/fa18bfbb90aaed5c78ff70dbb3dc17d5633e7e5f))
16
+
3
17
  ## [0.34.4](https://github.com/0xPlayerOne/code-foundry/compare/v0.34.3...v0.34.4) (2026-08-02)
4
18
 
5
19
 
package/docs/WORKFLOWS.md CHANGED
@@ -79,6 +79,16 @@ after the step.
79
79
  When absent, the job keeps `GH_TOKEN = github.token` and runs `gh auth setup-git`
80
80
  so repositories without a Deploy Key ruleset bypass still reconcile successfully.
81
81
 
82
+ When the protected `staging` branch rejects the exact-lease reconcile push with
83
+ a branch-policy/ruleset/required-PR error, the reconcile job does not fail:
84
+ it opens (or reuses) a generated `code-foundry/reconcile/main-to-staging`
85
+ pull request whose head contains the exact target tip (the `main` tip for a
86
+ fast-forward, the replay tip when staging-only commits are replayed) and whose
87
+ body documents the reconciliation. Reruns reuse the open pull request instead
88
+ of duplicating it; unexpected code divergence, replay conflicts, authentication
89
+ errors, and ambiguous or stale pull request state still fail the job closed,
90
+ and exact-lease protection stays in place for every direct push.
91
+
82
92
  For this reconciliation path, maintainer PATs and administrator roles are not
83
93
  authorized bypasses; the job deliberately authenticates with `github.token`,
84
94
  not `CODE_FOUNDRY_TOKEN` or `RELEASE_PLEASE_TOKEN`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "0.34.4",
3
+ "version": "0.34.6",
4
4
  "description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
@@ -4,7 +4,7 @@ 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, classifyReconciliation, readReleaseConfig, selectGeneratedReleasePrs, validateReleasePullRequests } from '../lib/release-policy.mjs'
7
+ import { approvedReleaseFiles, buildReleaseRecoveryPlan, buildReconciliationPullRequestBody, classifyReconciliation, readReleaseConfig, reconciliationPullRequestBranch, reconciliationPullRequestTitle, selectGeneratedReleasePrs, selectReconciliationPullRequest, validateReleasePullRequests } from '../lib/release-policy.mjs'
8
8
  import { hasDeliveredHook, releaseDeliveryKey, selectHookDelivery } from '../lib/release-hook.mjs'
9
9
 
10
10
  /** @typedef {{ target: string, dryRun: boolean, github: boolean, base: string, head: string }} ReleaseOptions */
@@ -14,7 +14,10 @@ import { hasDeliveredHook, releaseDeliveryKey, selectHookDelivery } from '../lib
14
14
  *
15
15
  * Local mode is deterministic and suitable for CI; --github uses strict
16
16
  * lease-based mirror retries with fresh refetch/reclassification and fails
17
- * closed if classification or remote mutation fails.
17
+ * closed if classification or remote mutation fails. When the protected
18
+ * staging branch rejects the exact lease push with a branch-policy/ruleset/
19
+ * required-PR error, the mutation is delivered through an idempotent
20
+ * automated synchronization pull request instead of failing the job.
18
21
  * @param {string} root
19
22
  * @param {ReleaseOptions} options
20
23
  */
@@ -38,14 +41,14 @@ export function reconcileRelease(root, options) {
38
41
  state = resolveReconciliationState(target, base, head, allowed, true)
39
42
  if (state.plan.action === 'fail') throw new Error(formatReconciliationFailure(state.plan))
40
43
  if (state.plan.action === 'aligned' && state.mainSha === state.stagingSha) return state.plan
41
- const mutation = executeReconciliationMutation(target, head, state)
42
- if (mutation.success) return { ...state.plan, ...mutation.result }
43
- if (!mutation.retry) throw new Error(mutation.error)
44
+ const mutation = executeReconciliationMutation(target, base, head, state)
45
+ if (mutation.success === true) return { ...state.plan, ...(mutation.result ?? {}) }
46
+ if (mutation.retry !== true) throw new Error(mutation.error ?? `${head} reconciliation failed.`)
44
47
  const remoteSha = remoteRefSha(target, head)
45
48
  if (remoteSha === state.stagingSha) {
46
49
  throw new Error(`${head} synchronization was rejected by an exact lease while remote ${head} tip remained ${state.stagingSha}. ` +
47
50
  'Update branch protection or remote policy to permit this mutation, then retry. ' +
48
- `Last failure detail: ${mutation.error}`)
51
+ `Last failure detail: ${mutation.error ?? 'unknown synchronization failure'}`)
49
52
  }
50
53
  }
51
54
  throw new Error(`Reconciliation of ${head} was retried but failed while the branch moved concurrently.`)
@@ -128,10 +131,10 @@ function refreshRemoteRefs(target, base, head) {
128
131
  if (result.status !== 0) throw new Error(`Failed to refresh origin/${base} and origin/${head} before reconciliation.`)
129
132
  }
130
133
 
131
- /** @param {string} target @param {string} branch */
134
+ /** @param {string} target @param {string} branch @returns {string | null} */
132
135
  function remoteRefSha(target, branch) {
133
136
  const result = spawnSync('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { cwd: target, encoding: 'utf8' })
134
- if (result.status !== 0) return ''
137
+ if (result.status !== 0) return null
135
138
  const [sha] = result.stdout.trim().split(/\t/, 1)
136
139
  return sha || ''
137
140
  }
@@ -139,6 +142,7 @@ function remoteRefSha(target, branch) {
139
142
  /**
140
143
  * Reconcile a single attempt from fresh state.
141
144
  * @param {string} target
145
+ * @param {string} base
142
146
  * @param {string} head
143
147
  * @param {{
144
148
  * plan: ReturnType<typeof classifyReconciliation>,
@@ -147,8 +151,9 @@ function remoteRefSha(target, branch) {
147
151
  * mainOnlyCommits: Array<{ sha: string, changedPaths: string[] }>,
148
152
  * stagingOnlyCommits: Array<{ sha: string, changedPaths: string[] }>,
149
153
  * }} state
154
+ * @returns {{ success: false, retry: boolean, error: string } | { success: true, result: ReconciliationMutationResult }}
150
155
  */
151
- function executeReconciliationMutation(target, head, state) {
156
+ function executeReconciliationMutation(target, base, head, state) {
152
157
  if (!state.plan.targetSha) return { success: false, retry: false, error: `No target SHA for ${head} reconciliation.` }
153
158
  if (state.plan.action === 'rebase-staging') {
154
159
  let replaySha
@@ -173,11 +178,7 @@ function executeReconciliationMutation(target, head, state) {
173
178
  }
174
179
  }
175
180
  if (classification.category === 'policy') {
176
- return {
177
- success: false,
178
- retry: false,
179
- error: `${head} reconciliation was blocked by branch policy: ${classification.message}`,
180
- }
181
+ return deliverReconciliationPullRequest(target, base, head, state, replaySha, { synchronization: 'replay', replaySha }, classification.message)
181
182
  }
182
183
  return { success: false, retry: true, error: `${head} synchronization failed with lease: ${classification.message}` }
183
184
  }
@@ -193,15 +194,189 @@ function executeReconciliationMutation(target, head, state) {
193
194
  }
194
195
  }
195
196
  if (classification.category === 'policy') {
196
- return {
197
- success: false,
198
- retry: false,
199
- error: `${head} reconciliation was blocked by branch policy: ${classification.message}`,
200
- }
197
+ return deliverReconciliationPullRequest(target, base, head, state, targetSha, {}, classification.message)
201
198
  }
202
199
  return { success: false, retry: true, error: `${head} synchronization failed with lease: ${classification.message}` }
203
200
  }
204
201
 
202
+ /** @typedef {{ number: number, url: string, base: string, head: string, branch: string, title: string, body: string }} ReconciliationPullRequest */
203
+
204
+ /** @typedef {{ synchronization: string, replaySha?: string, pullRequest?: ReconciliationPullRequest }} ReconciliationMutationResult */
205
+
206
+ /** @param {string} error @returns {{ success: false, retry: false, error: string }} */
207
+ function failResult(error) {
208
+ return { success: false, retry: false, error }
209
+ }
210
+
211
+ /**
212
+ * Deliver reconciliation through an automated synchronization pull request
213
+ * when staging branch policy rejects the exact lease push. The head branch is
214
+ * deterministic and namespaced, and reuse is keyed on the exact branch, base,
215
+ * and title so stale or ambiguous state fails closed.
216
+ * @param {string} target
217
+ * @param {string} base
218
+ * @param {string} head
219
+ * @param {{
220
+ * plan: ReturnType<typeof classifyReconciliation>,
221
+ * mainSha: string,
222
+ * stagingSha: string,
223
+ * }} state
224
+ * @param {string} targetSha
225
+ * @param {Record<string, unknown>} extra
226
+ * @param {string} pushError
227
+ * @returns {{ success: false, retry: false, error: string } | { success: true, result: ReconciliationMutationResult }}
228
+ */
229
+ function deliverReconciliationPullRequest(target, base, head, state, targetSha, extra, pushError) {
230
+ const repository = process.env.GITHUB_REPOSITORY
231
+ if (!repository) return failResult('GITHUB_REPOSITORY is required to open a reconciliation pull request.')
232
+ // base (main) is only the reconciliation source; the generated pull
233
+ // request must target the protected head branch (staging), so the PR base
234
+ // is always the reconcile head. Keep one named source of truth so the gh
235
+ // create call, the reuse selector, and the result metadata cannot drift.
236
+ const prBase = head
237
+ const branch = reconciliationPullRequestBranch(base, head)
238
+ const title = reconciliationPullRequestTitle({ targetHead: head, sourceBase: base })
239
+ const body = buildReconciliationPullRequestBody({
240
+ sourceBase: base,
241
+ targetHead: head,
242
+ mainSha: state.mainSha,
243
+ stagingSha: state.stagingSha,
244
+ targetSha,
245
+ action: state.plan.action,
246
+ pushError,
247
+ })
248
+ const expected = { targetBase: prBase, branch, title }
249
+ let prs = listOpenHeadPullRequests(target, repository, branch)
250
+ if (!prs) return failResult(`Failed to list open pull requests for reconciliation branch ${branch}.`)
251
+ let selection = selectReconciliationPullRequest(prs, expected)
252
+ if (selection.error) return failResult(selection.error)
253
+ if (!selection.create) {
254
+ if (!selection.reuse) return failResult(`No reusable reconciliation pull request for ${branch}.`)
255
+ return reuseReconciliationPullRequest(target, repository, selection.reuse, targetSha, body)
256
+ }
257
+ const pushed = pushReconciliationHead(target, branch, targetSha)
258
+ if (pushed.status !== 0) {
259
+ const classification = classifyPushFailure(branch, pushed.message)
260
+ if (classification.category === 'authentication') {
261
+ return failResult(`${branch} authentication failed while pushing the reconciliation head: ${classification.message}`)
262
+ }
263
+ return failResult(`Failed to push the reconciliation head ${branch}: ${classification.message}`)
264
+ }
265
+ prs = listOpenHeadPullRequests(target, repository, branch)
266
+ if (!prs) return failResult(`Failed to re-list open pull requests for reconciliation branch ${branch} after pushing its head.`)
267
+ selection = selectReconciliationPullRequest(prs, expected)
268
+ if (selection.error) return failResult(selection.error)
269
+ if (!selection.create) {
270
+ if (!selection.reuse) return failResult(`No reusable reconciliation pull request for ${branch} after pushing its head.`)
271
+ return reuseReconciliationPullRequest(target, repository, selection.reuse, targetSha, body)
272
+ }
273
+ const created = ghSpawn(target, ['pr', 'create', '--repo', repository, '--base', prBase, '--head', branch, '--title', title, '--body', body])
274
+ if (created.status !== 0) {
275
+ // A concurrent run may have created the pull request between our list
276
+ // and create; reuse it when it is exactly ours, otherwise fail closed.
277
+ prs = listOpenHeadPullRequests(target, repository, branch)
278
+ if (!prs) return failResult(`Failed to create the reconciliation pull request and to re-list open pull requests for ${branch}.`)
279
+ selection = selectReconciliationPullRequest(prs, expected)
280
+ if (selection.error) return failResult(selection.error)
281
+ if (selection.create) {
282
+ return failResult(`gh pr create failed for ${branch}: ${sanitizeReconcileOutput(created.stderr) || 'unknown error'}`)
283
+ }
284
+ if (!selection.reuse) return failResult(`No reusable reconciliation pull request for ${branch} after gh pr create failed.`)
285
+ return reuseReconciliationPullRequest(target, repository, selection.reuse, targetSha, body)
286
+ }
287
+ const number = parsePullRequestNumber(created.stdout)
288
+ if (!number) return failResult(`Created the reconciliation pull request for ${branch} but could not parse its number.`)
289
+ const url = created.stdout.trim()
290
+ return {
291
+ success: true,
292
+ result: {
293
+ ...extra,
294
+ synchronization: 'pull-request',
295
+ pullRequest: { number, url, base: prBase, head: branch, branch, title, body },
296
+ },
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Refresh an existing reconciliation pull request to the exact target tip and
302
+ * body. The head branch is pushed under an exact lease when its tip is stale,
303
+ * so a concurrent move fails closed instead of being clobbered.
304
+ * @param {string} target
305
+ * @param {string} repository
306
+ * @param {{number?: number, title?: string, headRefName?: string, headRefOid?: string, baseRefName?: string, url?: string}} pr
307
+ * @param {string} targetSha
308
+ * @param {string} body
309
+ * @returns {{ success: false, retry: false, error: string } | { success: true, result: ReconciliationMutationResult }}
310
+ */
311
+ function reuseReconciliationPullRequest(target, repository, pr, targetSha, body) {
312
+ const number = Number(pr.number)
313
+ if (String(pr.headRefOid ?? '') !== targetSha) {
314
+ const pushed = pushReconciliationHead(target, /** @type {string} */ (pr.headRefName), targetSha)
315
+ if (pushed.status !== 0) {
316
+ return failResult(`Failed to refresh the reconciliation branch ${pr.headRefName} to the target tip: ${sanitizeReconcileOutput(pushed.message)}`)
317
+ }
318
+ const edited = ghSpawn(target, ['pr', 'edit', String(number), '--repo', repository, '--body', body])
319
+ if (edited.status !== 0) {
320
+ return failResult(`Failed to update reconciliation pull request #${number}: ${sanitizeReconcileOutput(edited.stderr) || 'unknown error'}`)
321
+ }
322
+ }
323
+ return {
324
+ success: true,
325
+ result: {
326
+ synchronization: 'pull-request',
327
+ pullRequest: {
328
+ number,
329
+ url: pr.url ?? '',
330
+ base: pr.baseRefName ?? '',
331
+ head: pr.headRefName ?? '',
332
+ branch: pr.headRefName ?? '',
333
+ title: pr.title ?? '',
334
+ body,
335
+ },
336
+ },
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Push the exact target tip to the deterministic reconciliation head branch,
342
+ * creating it when absent and refreshing it under an exact lease otherwise.
343
+ * @param {string} target @param {string} branch @param {string} targetSha
344
+ */
345
+ function pushReconciliationHead(target, branch, targetSha) {
346
+ const existingTip = remoteRefSha(target, branch)
347
+ if (existingTip === null) return { status: 1, message: `could not resolve the remote reconciliation branch ${branch} before pushing.` }
348
+ if (existingTip === targetSha) return { status: 0, message: '' }
349
+ if (!existingTip) {
350
+ const result = spawnSync('git', ['push', 'origin', `${targetSha}:refs/heads/${branch}`], { cwd: target, encoding: 'utf8' })
351
+ const message = `${result.stdout?.trim() || ''}\n${result.stderr?.trim() || ''}`.trim() || `failed to create reconciliation branch ${branch}.`
352
+ return { status: result.status, message: sanitizeReconcileOutput(message) }
353
+ }
354
+ return pushWithLease(target, branch, existingTip, targetSha)
355
+ }
356
+
357
+ /**
358
+ * @param {string} target
359
+ * @param {string} repository
360
+ * @param {string} branch
361
+ * @returns {Array<{number?: number, title?: string, headRefName?: string, headRefOid?: string, baseRefName?: string, url?: string}> | null}
362
+ */
363
+ function listOpenHeadPullRequests(target, repository, branch) {
364
+ const result = ghSpawn(target, ['pr', 'list', '--repo', repository, '--state', 'open', '--head', branch, '--json', 'number,title,headRefName,headRefOid,baseRefName,url'])
365
+ if (result.status !== 0) return null
366
+ try {
367
+ const prs = JSON.parse(result.stdout)
368
+ return Array.isArray(prs) ? prs : null
369
+ } catch {
370
+ return null
371
+ }
372
+ }
373
+
374
+ /** @param {string} stdout @returns {number | null} */
375
+ function parsePullRequestNumber(stdout) {
376
+ const match = stdout.trim().match(/\/pull\/(\d+)\/?$/)
377
+ return match ? Number(match[1]) : null
378
+ }
379
+
205
380
  /** @param {string} target @param {string} head @param {string} expectedSha @param {string} tipSha */
206
381
  function pushWithLease(target, head, expectedSha, tipSha) {
207
382
  const result = spawnSync('git', [
@@ -230,7 +405,7 @@ function classifyPushFailure(branch, raw) {
230
405
  if (/permission denied|authentication failed|could not read from remote repository|publickey|not authorized|bad credentials/.test(lower)) {
231
406
  return { category: 'authentication', message }
232
407
  }
233
- if (/remote:\s*error|protected branch|required status checks|pre-receive hook|branch policy|ruleset|gh006|gh007|gh008/.test(lower)) {
408
+ 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)) {
234
409
  return { category: 'policy', message }
235
410
  }
236
411
  return { category: 'other', message }
@@ -395,10 +570,15 @@ function commitChangedPaths(root, commitSha) {
395
570
  return result.stdout.split(/\r?\n/).filter(Boolean)
396
571
  }
397
572
 
573
+ /** @param {string} root @param {string[]} args @returns {{ status: number | null, stdout: string, stderr: string }} */
574
+ function ghSpawn(root, args) {
575
+ const token = process.env.RELEASE_PLEASE_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN
576
+ return spawnSync('gh', args, { cwd: resolve(root), encoding: 'utf8', env: { ...process.env, ...(token ? { GH_TOKEN: token } : {}) } })
577
+ }
578
+
398
579
  /** @param {string} root @param {string[]} args @returns {unknown} */
399
580
  function ghJson(root, args) {
400
- const token = process.env.RELEASE_PLEASE_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN
401
- const result = spawnSync('gh', args, { cwd: resolve(root), encoding: 'utf8', env: { ...process.env, ...(token ? { GH_TOKEN: token } : {}) } })
581
+ const result = ghSpawn(root, args)
402
582
  if (result.status !== 0) return []
403
583
  try { return JSON.parse(result.stdout) }
404
584
  catch { return [] }
@@ -412,9 +412,12 @@ function mergeGitignore(baseline, existing) {
412
412
 
413
413
  /** @param {string} baseline @param {string} existing */
414
414
  function mergeIgnoreFile(baseline, existing) {
415
+ const marker = '# Repository-specific rules'
415
416
  const baselineLines = new Set(baseline.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))
416
- const custom = existing.split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim() && !baselineLines.has(line.trim()))
417
- return custom.length ? `${baseline.trimEnd()}\n\n# Repository-specific rules\n${custom.join('\n')}\n` : baseline
417
+ const custom = existing.split(/\r?\n/)
418
+ .map((line) => line.trimEnd())
419
+ .filter((line) => line.trim() && line.trim() !== marker && !baselineLines.has(line.trim()))
420
+ return custom.length ? `${baseline.trimEnd()}\n\n${marker}\n${custom.join('\n')}\n` : baseline
418
421
  }
419
422
 
420
423
  /** @param {string} file @param {string} content */
@@ -273,3 +273,89 @@ export function classifyReconciliation(input) {
273
273
  }
274
274
 
275
275
  export { DEFAULT_RELEASE_FILES }
276
+
277
+ /**
278
+ * Deterministic head branch for the automated synchronization pull request
279
+ * used when branch policy rejects the direct reconcile push. The branch is
280
+ * namespaced under code-foundry/ so it never collides with release-please or
281
+ * user branches, and the name is a pure function of the reconciled refs so
282
+ * reruns converge on the same branch and PR.
283
+ * @param {string} sourceBase
284
+ * @param {string} targetHead
285
+ */
286
+ export function reconciliationPullRequestBranch(sourceBase, targetHead) {
287
+ return `code-foundry/reconcile/${sourceBase}-to-${targetHead}`
288
+ }
289
+
290
+ /** @param {{ targetHead: string, sourceBase: string }} input */
291
+ export function reconciliationPullRequestTitle({ targetHead, sourceBase }) {
292
+ return `chore(${targetHead}): reconcile release metadata from ${sourceBase}`
293
+ }
294
+
295
+ /**
296
+ * Deterministic pull request body for the automated synchronization PR. The
297
+ * body explains that this is generated reconciliation and preserves the
298
+ * fail-closed contract: unexpected divergence, conflicts, authentication
299
+ * errors, and ambiguous state are never pushed around branch protection.
300
+ * @param {{
301
+ * sourceBase: string,
302
+ * targetHead: string,
303
+ * mainSha: string,
304
+ * stagingSha: string,
305
+ * targetSha: string,
306
+ * action: string,
307
+ * pushError?: string,
308
+ * }} input
309
+ */
310
+ export function buildReconciliationPullRequestBody(input) {
311
+ const { sourceBase, targetHead, mainSha, stagingSha, targetSha, action, pushError = '' } = input
312
+ const lines = [
313
+ '## Automated release reconciliation',
314
+ '',
315
+ `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 target tip through the normal review flow instead.`,
316
+ '',
317
+ `- Base \`${targetHead}\`: \`${stagingSha}\``,
318
+ `- \`${sourceBase}\` tip: \`${mainSha}\``,
319
+ `- Target tip in this branch: \`${targetSha}\``,
320
+ `- Synchronization: \`${action}\``,
321
+ ]
322
+ const reason = pushError.replace(/\s+/g, ' ').trim()
323
+ if (reason) lines.push(`- Direct push rejection: \`${reason}\``)
324
+ lines.push(
325
+ '',
326
+ `Merging this pull request synchronizes \`${targetHead}\` with the exact target tip above; no other commits are introduced.`,
327
+ '',
328
+ 'Code Foundry reconciles only release metadata. Unexpected code divergence, 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.',
329
+ )
330
+ return lines.join('\n')
331
+ }
332
+
333
+ /**
334
+ * Select the single reusable automated synchronization pull request from the
335
+ * open pull requests that use the deterministic reconciliation branch. Reuse
336
+ * requires the exact generated target base and title; foreign or ambiguous
337
+ * state fails closed so a generated PR is never reused or overwritten by
338
+ * accident. targetBase is the PR base branch (the protected branch being
339
+ * synchronized, e.g. staging), branch is the generated head branch, and
340
+ * title is the exact generated title.
341
+ * @param {Array<{number?: number, title?: string, headRefName?: string, baseRefName?: string}>} prs
342
+ * @param {{ targetBase: string, branch: string, title: string }} input
343
+ * @returns {{ create: boolean, reuse?: {number?: number, title?: string, headRefName?: string, headRefOid?: string, baseRefName?: string, url?: string}, error?: string }}
344
+ */
345
+ export function selectReconciliationPullRequest(prs, input) {
346
+ const { targetBase, branch, title } = input
347
+ const owned = prs.filter((pr) => String(pr.headRefName ?? '') === branch)
348
+ if (!owned.length) return { create: true }
349
+ const reusable = owned.filter((pr) => String(pr.baseRefName ?? '') === targetBase && String(pr.title ?? '') === title)
350
+ if (reusable.length === 1) return { create: false, reuse: reusable[0] }
351
+ if (reusable.length > 1) {
352
+ return { create: false, error: `Multiple open pull requests use the reconciliation branch ${branch}; refusing to pick one.` }
353
+ }
354
+ const foreign = owned
355
+ .map((pr) => `#${pr.number ?? '?'} (base ${pr.baseRefName ?? '?'}, title ${JSON.stringify(pr.title ?? '')})`)
356
+ .join(', ')
357
+ return {
358
+ create: false,
359
+ error: `Open pull request ${foreign} uses the reconciliation branch ${branch} with an unexpected base or title; refusing to reuse or overwrite it.`,
360
+ }
361
+ }