code-foundry 0.32.4 → 0.34.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.
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
- import { existsSync, readFileSync } from 'node:fs'
3
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
4
5
  import { join, resolve } from 'node:path'
5
6
  import { spawnSync } from 'node:child_process'
6
7
  import { approvedReleaseFiles, buildReleaseRecoveryPlan, classifyReconciliation, readReleaseConfig, selectGeneratedReleasePrs, validateReleasePullRequests } from '../lib/release-policy.mjs'
@@ -9,10 +10,11 @@ import { hasDeliveredHook, releaseDeliveryKey, selectHookDelivery } from '../lib
9
10
  /** @typedef {{ target: string, dryRun: boolean, github: boolean, base: string, head: string }} ReleaseOptions */
10
11
 
11
12
  /**
12
- * Reconcile staging after Release Please updates main. The local mode is
13
- * deterministic and suitable for CI; --github mirrors staging onto main's
14
- * tip through the GitHub API (fast-forward, then forced, then a linear sync
15
- * commit) and otherwise fails closed.
13
+ * Reconcile staging after Release Please updates main.
14
+ *
15
+ * Local mode is deterministic and suitable for CI; --github uses strict
16
+ * lease-based mirror retries with fresh refetch/reclassification and fails
17
+ * closed if classification or remote mutation fails.
16
18
  * @param {string} root
17
19
  * @param {ReleaseOptions} options
18
20
  */
@@ -20,51 +22,264 @@ export function reconcileRelease(root, options) {
20
22
  const target = resolve(root)
21
23
  const base = options.base || 'main'
22
24
  const head = options.head || 'staging'
23
- const mainSha = git(target, ['rev-parse', `origin/${base}`]) || git(target, ['rev-parse', base])
24
- const stagingSha = git(target, ['rev-parse', `origin/${head}`]) || git(target, ['rev-parse', head])
25
- const mergeBaseSha = git(target, ['merge-base', mainSha, stagingSha])
26
- // Compare the branch tips directly. Release Please may rebase or recreate
27
- // commits during promotion, leaving equivalent code with different ancestry.
28
- // Comparing both tips preserves the fail-closed behavior for real content
29
- // differences without mistaking that normal history rewrite for a change.
30
- const mainChangedPaths = diffNames(target, stagingSha, mainSha)
31
- const stagingChangedPaths = diffNames(target, mainSha, stagingSha)
32
- const plan = classifyReconciliation({
33
- mainSha,
34
- stagingSha,
35
- mergeBaseSha,
36
- mainChangedPaths,
37
- stagingChangedPaths,
38
- allowed: approvedReleaseFiles(readReleaseConfig(target)),
39
- })
40
- console.log(JSON.stringify({ base, head, ...plan }, null, 2))
41
- if (plan.action === 'fail') throw new Error(plan.reason + (plan.unexpected?.length ? ` Unexpected paths: ${plan.unexpected.join(', ')}` : ''))
42
- if (!['fast-forward', 'pull-request', 'aligned'].includes(plan.action) || !options.github || options.dryRun) return plan
43
- if (!plan.targetSha) return plan
25
+ const allowed = approvedReleaseFiles(readReleaseConfig(target))
26
+ let state = resolveReconciliationState(target, base, head, allowed, options.github)
27
+ if (state.plan.action === 'fail') {
28
+ throw new Error(formatReconciliationFailure(state.plan))
29
+ }
30
+ console.log(JSON.stringify({ base, head, ...state.plan }, null, 2))
31
+ if (!['fast-forward', 'rebase-staging', 'aligned'].includes(state.plan.action) || !options.github || options.dryRun) return state.plan
32
+ if (state.plan.action === 'aligned' && state.mainSha === state.stagingSha) return state.plan
44
33
  if (!process.env.GITHUB_REPOSITORY) throw new Error('GITHUB_REPOSITORY is required for --github reconciliation.')
45
34
  const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
46
35
  if (!token) throw new Error('GH_TOKEN or GITHUB_TOKEN is required for --github reconciliation.')
47
- const targetSha = /** @type {string} */ (plan.targetSha)
48
- // 1. Verified fast-forward through the API (linear, no force).
49
- const fastForward = spawnSync('gh', [
50
- 'api', '--method', 'PATCH', `repos/${process.env.GITHUB_REPOSITORY}/git/refs/heads/${head}`,
51
- '-f', `sha=${targetSha}`, '-F', 'force=false',
52
- ], { cwd: target, stdio: 'inherit', env: { ...process.env, GH_TOKEN: token } })
53
- if (fastForward.status === 0) return { ...plan, synchronization: 'fast-forward' }
54
- // 2. Forced update when staging may be rewritten without violating the
55
- // linear-history rule (main's tip is linear and already contains staging's
56
- // content, so nothing unpromoted is lost).
57
- const forced = spawnSync('gh', [
58
- 'api', '--method', 'PATCH', `repos/${process.env.GITHUB_REPOSITORY}/git/refs/heads/${head}`,
59
- '-f', `sha=${targetSha}`, '-F', 'force=true',
60
- ], { cwd: target, stdio: 'inherit', env: { ...process.env, GH_TOKEN: token } })
61
- if (forced.status === 0) return { ...plan, synchronization: 'forced' }
62
- // 3. Linear sync commit fallback: staging keeps its history and receives
63
- // main's tree as a single-parent commit, which the ruleset always accepts.
64
- const syncSha = createLinearSyncCommit(target, stagingSha, targetSha)
65
- const push = spawnSync('git', ['push', 'origin', `${syncSha}:refs/heads/${head}`], { cwd: target, stdio: 'inherit', env: { ...process.env, GH_TOKEN: token } })
66
- if (push.status !== 0) throw new Error(`GitHub refused the linear synchronization of ${head}.`)
67
- return { ...plan, syncSha, synchronization: 'linear-commit' }
36
+ const maxAttempts = 3
37
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
38
+ state = resolveReconciliationState(target, base, head, allowed, true)
39
+ if (state.plan.action === 'fail') throw new Error(formatReconciliationFailure(state.plan))
40
+ 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 remoteSha = remoteRefSha(target, head)
45
+ if (remoteSha === state.stagingSha) {
46
+ throw new Error(`${head} synchronization was rejected by an exact lease while remote ${head} tip remained ${state.stagingSha}. ` +
47
+ 'Update branch protection or remote policy to permit this mutation, then retry. ' +
48
+ `Last failure detail: ${mutation.error}`)
49
+ }
50
+ }
51
+ throw new Error(`Reconciliation of ${head} was retried but failed while the branch moved concurrently.`)
52
+ }
53
+
54
+ /**
55
+ * Resolve current reconciliation state and plan from current refs.
56
+ * @param {string} target
57
+ * @param {string} base
58
+ * @param {string} head
59
+ * @param {Set<string>} allowed
60
+ * @param {boolean} requireRemote
61
+ */
62
+ function resolveReconciliationState(target, base, head, allowed, requireRemote) {
63
+ validateRefName(base)
64
+ validateRefName(head)
65
+ if (requireRemote) refreshRemoteRefs(target, base, head)
66
+ const mainSha = resolveRef(target, base, requireRemote)
67
+ const stagingSha = resolveRef(target, head, requireRemote)
68
+ if (!mainSha || !stagingSha) {
69
+ return {
70
+ plan: {
71
+ action: 'fail',
72
+ reason: `Missing ${!mainSha ? `main (${base})` : `staging (${head})`} or staged branch ref during reconciliation.`,
73
+ },
74
+ mainSha,
75
+ stagingSha,
76
+ mainOnlyCommits: [],
77
+ stagingOnlyCommits: [],
78
+ }
79
+ }
80
+ try {
81
+ const mainOnlyCommits = divergentCommits(target, stagingSha, mainSha)
82
+ const stagingOnlyCommits = divergentCommits(target, mainSha, stagingSha)
83
+ const plan = classifyReconciliation({
84
+ mainSha,
85
+ stagingSha,
86
+ mainOnlyCommits,
87
+ stagingOnlyCommits,
88
+ allowed,
89
+ })
90
+ return { plan, mainSha, stagingSha, mainOnlyCommits, stagingOnlyCommits }
91
+ } catch (error) {
92
+ const message = error instanceof Error ? error.message : String(error)
93
+ return {
94
+ plan: {
95
+ action: 'fail',
96
+ reason: `Failed to inspect divergent commits: ${message}`,
97
+ },
98
+ mainSha,
99
+ stagingSha,
100
+ mainOnlyCommits: [],
101
+ stagingOnlyCommits: [],
102
+ }
103
+ }
104
+ }
105
+
106
+ /** @param {{ reason: string, unexpected?: string[] }} plan */
107
+ function formatReconciliationFailure(plan) {
108
+ return `${plan.reason}${plan.unexpected?.length ? ` Unexpected paths: ${plan.unexpected.join(', ')}` : ''}`
109
+ }
110
+
111
+ /** @param {string} ref */
112
+ function validateRefName(ref) {
113
+ const result = spawnSync('git', ['check-ref-format', '--branch', ref], { encoding: 'utf8' })
114
+ if (result.status !== 0) throw new Error(`Invalid branch reference: ${ref}`)
115
+ }
116
+
117
+ /** @param {string} target @param {string} ref @param {boolean} requireRemote @returns {string} */
118
+ function resolveRef(target, ref, requireRemote) {
119
+ const remote = git(target, ['rev-parse', `origin/${ref}`])
120
+ if (remote) return remote
121
+ if (!requireRemote) return git(target, ['rev-parse', ref])
122
+ return ''
123
+ }
124
+
125
+ /** @param {string} target @param {string} base @param {string} head */
126
+ function refreshRemoteRefs(target, base, head) {
127
+ const result = spawnSync('git', ['fetch', 'origin', base, head], { cwd: target, encoding: 'utf8' })
128
+ if (result.status !== 0) throw new Error(`Failed to refresh origin/${base} and origin/${head} before reconciliation.`)
129
+ }
130
+
131
+ /** @param {string} target @param {string} branch */
132
+ function remoteRefSha(target, branch) {
133
+ const result = spawnSync('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { cwd: target, encoding: 'utf8' })
134
+ if (result.status !== 0) return ''
135
+ const [sha] = result.stdout.trim().split(/\t/, 1)
136
+ return sha || ''
137
+ }
138
+
139
+ /**
140
+ * Reconcile a single attempt from fresh state.
141
+ * @param {string} target
142
+ * @param {string} head
143
+ * @param {{
144
+ * plan: ReturnType<typeof classifyReconciliation>,
145
+ * mainSha: string,
146
+ * stagingSha: string,
147
+ * mainOnlyCommits: Array<{ sha: string, changedPaths: string[] }>,
148
+ * stagingOnlyCommits: Array<{ sha: string, changedPaths: string[] }>,
149
+ * }} state
150
+ */
151
+ function executeReconciliationMutation(target, head, state) {
152
+ if (!state.plan.targetSha) return { success: false, retry: false, error: `No target SHA for ${head} reconciliation.` }
153
+ if (state.plan.action === 'rebase-staging') {
154
+ let replaySha
155
+ try {
156
+ replaySha = replayOntoMain(target, state.mainSha, state.stagingOnlyCommits)
157
+ } catch (error) {
158
+ return {
159
+ success: false,
160
+ retry: false,
161
+ error: `Replay conflict while applying staging-only commits before replay: ${String(error instanceof Error ? error.message : error)}`,
162
+ }
163
+ }
164
+
165
+ const push = pushWithLease(target, head, state.stagingSha, replaySha)
166
+ if (push.status === 0) return { success: true, result: { synchronization: 'replay', replaySha } }
167
+ const classification = classifyPushFailure(head, push.message)
168
+ if (classification.category === 'authentication') {
169
+ return {
170
+ success: false,
171
+ retry: false,
172
+ error: `${head} authentication failed during replay push: ${classification.message}`,
173
+ }
174
+ }
175
+ 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
+ }
182
+ return { success: false, retry: true, error: `${head} synchronization failed with lease: ${classification.message}` }
183
+ }
184
+ const targetSha = /** @type {string} */ (state.plan.targetSha)
185
+ const leased = pushWithLease(target, head, state.stagingSha, targetSha)
186
+ if (leased.status === 0) return { success: true, result: { synchronization: state.plan.action === 'aligned' ? 'synced' : 'leased' } }
187
+ const classification = classifyPushFailure(head, leased.message)
188
+ if (classification.category === 'authentication') {
189
+ return {
190
+ success: false,
191
+ retry: false,
192
+ error: `${head} authentication failed during push: ${classification.message}`,
193
+ }
194
+ }
195
+ if (classification.category === 'policy') {
196
+ return {
197
+ success: false,
198
+ retry: false,
199
+ error: `${head} reconciliation was blocked by branch policy: ${classification.message}`,
200
+ }
201
+ }
202
+ return { success: false, retry: true, error: `${head} synchronization failed with lease: ${classification.message}` }
203
+ }
204
+
205
+ /** @param {string} target @param {string} head @param {string} expectedSha @param {string} tipSha */
206
+ function pushWithLease(target, head, expectedSha, tipSha) {
207
+ const result = spawnSync('git', [
208
+ 'push',
209
+ 'origin',
210
+ `--force-with-lease=refs/heads/${head}:${expectedSha}`,
211
+ `${tipSha}:refs/heads/${head}`,
212
+ ], { cwd: target, encoding: 'utf8' })
213
+ const message = `${result.stdout?.trim() || ''}\n${result.stderr?.trim() || ''}`.trim() || `push with lease failed for ${head}.`
214
+ return {
215
+ status: result.status,
216
+ message: sanitizeReconcileOutput(message),
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Classify a push failure to avoid leaking sensitive details and provide useful
222
+ * diagnostics while preserving exact-lease behavior.
223
+ * @param {string} branch
224
+ * @param {string} raw
225
+ */
226
+ function classifyPushFailure(branch, raw) {
227
+ const message = sanitizeReconcileOutput(raw)
228
+ if (!message) return { category: 'other', message: `push with lease failed for ${branch}.` }
229
+ const lower = message.toLowerCase()
230
+ if (/permission denied|authentication failed|could not read from remote repository|publickey|not authorized|bad credentials/.test(lower)) {
231
+ return { category: 'authentication', message }
232
+ }
233
+ if (/remote:\s*error|protected branch|required status checks|pre-receive hook|branch policy|ruleset|gh006|gh007|gh008/.test(lower)) {
234
+ return { category: 'policy', message }
235
+ }
236
+ return { category: 'other', message }
237
+ }
238
+
239
+ /** @param {string} value */
240
+ function sanitizeReconcileOutput(value) {
241
+ return value
242
+ .replace(/https?:\/\/[^\s@]+:[^\s@]*@/g, 'https://***:***@')
243
+ .replace(/https?:\/\/[^\s@]+@/g, 'https://***@')
244
+ .trim()
245
+ }
246
+
247
+ /**
248
+ * Replay staging-only commits onto a detached worktree at main.
249
+ * @param {string} target
250
+ * @param {string} mainSha
251
+ * @param {Array<{ sha: string, changedPaths: string[] }>} commits
252
+ */
253
+ function replayOntoMain(target, mainSha, commits) {
254
+ const orderedCommits = commits.map((commit) => commit.sha).filter(Boolean)
255
+ if (!orderedCommits.length) return mainSha
256
+ const workspace = mkdtempSync(join(tmpdir(), 'code-foundry-reconcile-'))
257
+ const identityArgs = [
258
+ '-c',
259
+ 'user.name=github-actions[bot]',
260
+ '-c',
261
+ 'user.email=41898282+github-actions[bot]@users.noreply.github.com',
262
+ ]
263
+ try {
264
+ const added = spawnSync('git', ['worktree', 'add', '--detach', workspace, mainSha], { cwd: target, encoding: 'utf8' })
265
+ if (added.status !== 0) throw new Error(`Failed to create temporary replay worktree: ${added.stderr?.trim() || added.stdout?.trim()}`)
266
+ for (const sha of orderedCommits) {
267
+ const cherryPick = spawnSync('git', [...identityArgs, 'cherry-pick', sha], { cwd: workspace, encoding: 'utf8' })
268
+ if (cherryPick.status !== 0) {
269
+ spawnSync('git', ['cherry-pick', '--abort'], { cwd: workspace, encoding: 'utf8' })
270
+ const details = [cherryPick.stdout?.trim(), cherryPick.stderr?.trim()].filter(Boolean).join('\n')
271
+ throw new Error(`Cherry-pick of ${sha} failed while replaying staging commits: ${sanitizeReconcileOutput(details)}`)
272
+ }
273
+ }
274
+ const replayTip = git(workspace, ['rev-parse', 'HEAD'])
275
+ if (!replayTip) throw new Error('Failed to resolve replay tip SHA.')
276
+ const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', mainSha, replayTip], { cwd: workspace, encoding: 'utf8' })
277
+ if (ancestry.status !== 0) throw new Error('Replayed head is not based on the current main tip.')
278
+ return replayTip
279
+ } finally {
280
+ spawnSync('git', ['worktree', 'remove', '--force', workspace], { cwd: target, encoding: 'utf8' })
281
+ rmSync(workspace, { recursive: true, force: true })
282
+ }
68
283
  }
69
284
 
70
285
  /**
@@ -148,29 +363,36 @@ function git(root, args) {
148
363
  return result.status === 0 ? result.stdout.trim() : ''
149
364
  }
150
365
 
151
- /**
152
- * Build a linear commit whose tree equals main's tip and whose parent is the
153
- * current staging tip. Staging requires linear history and main's tip almost
154
- * always sits behind promotion merge commits, so a direct fast-forward to
155
- * main's tip is rejected by the branch ruleset ("must not contain merge
156
- * commits"). A single-parent commit is always pushable and keeps staging
157
- * content-identical to main.
158
- * @param {string} root @param {string} parentSha @param {string} mainSha @returns {string}
159
- */
160
- function createLinearSyncCommit(root, parentSha, mainSha) {
161
- const tree = git(root, ['rev-parse', `${mainSha}^{tree}`])
162
- if (!tree) throw new Error('Failed to resolve the target tree.')
163
- const identity = ['-c', 'user.name=github-actions[bot]', '-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com']
164
- const result = spawnSync('git', [...identity, 'commit-tree', tree, '-p', parentSha, '-m', 'chore(release): synchronize staging with main'], { cwd: resolve(root), encoding: 'utf8' })
165
- if (result.status !== 0) throw new Error(`Failed to create the synchronization commit: ${result.stderr.trim()}`)
166
- return result.stdout.trim()
366
+ /** @param {string} root @param {string} from @param {string} to @returns {Array<{ sha: string, changedPaths: string[] }>} */
367
+ function divergentCommits(root, from, to) {
368
+ if (!from || !to || from === to) return []
369
+ const result = spawnSync('git', [
370
+ 'log',
371
+ '--cherry-pick',
372
+ '--no-merges',
373
+ '--left-right',
374
+ '--reverse',
375
+ '--pretty=tformat:%m%H',
376
+ `${from}...${to}`,
377
+ ], { cwd: root, encoding: 'utf8' })
378
+ if (result.status !== 0) {
379
+ throw new Error(`Failed to enumerate divergent commits between ${from} and ${to}. ${result.stderr?.trim() || result.stdout?.trim()}`)
380
+ }
381
+ return result.stdout
382
+ .split(/\r?\n/)
383
+ .filter((line) => line.startsWith('>'))
384
+ .map((line) => ({
385
+ sha: line.slice(1),
386
+ changedPaths: commitChangedPaths(root, line.slice(1)),
387
+ }))
388
+ .filter((entry) => entry.sha)
167
389
  }
168
390
 
169
- /** @param {string} root @param {string} from @param {string} to @returns {string[]} */
170
- function diffNames(root, from, to) {
171
- if (!from || !to || from === to) return []
172
- const result = spawnSync('git', ['diff', '--name-only', from, to], { cwd: root, encoding: 'utf8' })
173
- return result.status === 0 ? result.stdout.split(/\r?\n/).filter(Boolean) : []
391
+ /** @param {string} root @param {string} commitSha @returns {string[]} */
392
+ function commitChangedPaths(root, commitSha) {
393
+ const result = spawnSync('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', commitSha], { cwd: root, encoding: 'utf8' })
394
+ if (result.status !== 0) throw new Error(`Unable to read changed paths for commit ${commitSha}. ${result.stderr?.trim() || result.stdout?.trim()}`)
395
+ return result.stdout.split(/\r?\n/).filter(Boolean)
174
396
  }
175
397
 
176
398
  /** @param {string} root @param {string[]} args @returns {unknown} */
@@ -16,11 +16,18 @@ const standardFiles = [
16
16
  '.github/PULL_REQUEST_TEMPLATE.md', '.github/SECURITY.md', '.github/dependabot.yml',
17
17
  '.github/ISSUE_TEMPLATE/bug_report.yml', '.github/ISSUE_TEMPLATE/config.yml',
18
18
  '.github/ISSUE_TEMPLATE/feature_request.yml',
19
- '.github/workflows/ci.yml', '.github/workflows/codeql.yml', '.github/workflows/draft-pr.yml',
19
+ '.github/workflows/validation.yml', '.github/workflows/draft-pr.yml',
20
20
  '.github/workflows/release-pr.yml', '.github/workflows/release.yml',
21
- '.github/workflows/security.yml', '.github/workflows/test.yml', '.github/workflows/opencode-security.yml',
21
+ '.github/workflows/opencode-security.yml',
22
22
  ]
23
23
 
24
+ /**
25
+ * Legacy event callers that the tiered validation caller replaces. Sync
26
+ * removes them only when they are recognized as Code Foundry-generated;
27
+ * custom workflows are always preserved byte-for-byte.
28
+ */
29
+ const LEGACY_GENERATED_CALLERS = ['ci', 'test', 'security', 'codeql']
30
+
24
31
  const protectedFiles = new Set([
25
32
  'AGENTS.md', '.github/CODE_OF_CONDUCT.md', '.github/CONTRIBUTING.md',
26
33
  '.github/PULL_REQUEST_TEMPLATE.md', '.github/SECURITY.md', 'NOTICE',
@@ -72,6 +79,14 @@ export function syncRepository(options) {
72
79
  if (!['auto', 'native', 'mise'].includes(toolchain)) {
73
80
  throw new Error(`Unsupported toolchain: ${toolchain}; use auto, native, or mise.`)
74
81
  }
82
+ const mergeStrategy = configured(config.merge_strategy, 'rebase')
83
+ if (mergeStrategy !== 'rebase') {
84
+ throw new Error(`Unsupported merge_strategy: ${mergeStrategy}; the staging-release topology requires rebase for staging to main promotions.`)
85
+ }
86
+ const releaseMergeStrategy = configured(config.release_merge_strategy, '')
87
+ if (includesValue(features, 'release') && releaseMergeStrategy !== 'squash') {
88
+ throw new Error(`Unsupported release_merge_strategy: ${releaseMergeStrategy || '(unset)'}; release automation requires squash for Release Please version pull requests and never defaults to merge.`)
89
+ }
75
90
  const license = configured(config.license, existsSync(join(target, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later')
76
91
  const changed = []
77
92
 
@@ -98,6 +113,10 @@ export function syncRepository(options) {
98
113
  }
99
114
  if ((file === 'LICENSE' || file === 'NOTICE') && license === 'preserve' && existsSync(destination)) continue
100
115
  if ((file === 'LICENSE' || file === 'NOTICE') && license === 'none') continue
116
+ // An explicit license policy makes the license block below the single
117
+ // owner of LICENSE; copying the runtime's own root LICENSE here would
118
+ // fight it and break idempotence on the next sync.
119
+ if (file === 'LICENSE' && license !== 'preserve' && license !== 'none') continue
101
120
  if (file === '.github/CODEOWNERS' && existsSync(destination)) continue
102
121
  let content = readFileSync(sourceFile)
103
122
  if (file === 'release-please-config.json') {
@@ -118,6 +137,18 @@ export function syncRepository(options) {
118
137
  }
119
138
  }
120
139
 
140
+ for (const stem of LEGACY_GENERATED_CALLERS) {
141
+ const destination = join(target, `.github/workflows/${stem}.yml`)
142
+ if (!existsSync(destination)) continue
143
+ if (isGeneratedEventCaller(readFileSync(destination, 'utf8'), stem, runtimeRepository)) {
144
+ changed.push(`.github/workflows/${stem}.yml`)
145
+ if (dryRun) console.log(`Would remove generated legacy caller ${stem}.yml; validation.yml replaces it.`)
146
+ else rmSync(destination, { force: true })
147
+ } else {
148
+ console.log(`Preserved ${stem}.yml: not recognized as a Code Foundry-generated caller.`)
149
+ }
150
+ }
151
+
121
152
  const releaseManifest = buildReleaseManifest(target, mergeReleaseConfig(target, sourcePath(source, 'release-please-config.json')))
122
153
  if (releaseManifest) {
123
154
  const manifestPath = join(target, '.release-please-manifest.json')
@@ -143,7 +174,11 @@ export function syncRepository(options) {
143
174
  const licenseFile = license === 'mit' ? 'MIT.txt' : license === 'agpl-3.0-or-later' ? 'AGPL-3.0-or-later.txt' : 'GPL-3.0-or-later.txt'
144
175
  const sourceLicense = join(source, '.github/licenses', licenseFile)
145
176
  if (!existsSync(sourceLicense)) throw new Error(`License template missing: ${sourceLicense}`)
146
- writeOrReport(join(target, 'LICENSE'), readFileSync(sourceLicense), dryRun)
177
+ const licenseContent = readFileSync(sourceLicense)
178
+ if (!existsSync(join(target, 'LICENSE')) || !buffersEqual(licenseContent, readFileSync(join(target, 'LICENSE')))) {
179
+ changed.push('LICENSE')
180
+ writeOrReport(join(target, 'LICENSE'), licenseContent, dryRun)
181
+ }
147
182
  if (!existsSync(join(target, 'NOTICE'))) writeOrReport(join(target, 'NOTICE'), readFileSync(join(source, 'NOTICE')), dryRun)
148
183
  }
149
184
 
@@ -217,6 +252,11 @@ function shouldInclude(file, languages, features, config) {
217
252
  if (file === '.github/dependabot.yml') return includesValue(features, 'dependabot')
218
253
  if (file === '.github/workflows/opencode-security.yml') return ['true', 'auto'].includes(config.opencode_security ?? 'false')
219
254
  const workflow = file.match(/^\.github\/workflows\/([^/]+)\.yml$/)?.[1]
255
+ // The tiered validation caller supersedes the legacy ci/test/security/codeql
256
+ // event callers, so legacy feature names keep selecting it.
257
+ if (workflow === 'validation') {
258
+ return includesValue(features, 'validation') || LEGACY_GENERATED_CALLERS.some((legacy) => includesValue(features, legacy))
259
+ }
220
260
  return !workflow || includesValue(features, workflow)
221
261
  }
222
262
 
@@ -245,7 +285,13 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
245
285
  const remotePrefix = `uses: ${repository}/.github/workflows/`
246
286
  let rendered = content.replaceAll(localPrefix, remotePrefix)
247
287
  rendered = rendered.replace(new RegExp(`${escapeRegExp(remotePrefix)}([^\\s@]+)`, 'g'), `$&@${ref}`)
248
- rendered = rendered.replace(/^(\s+runtime-ref:)\s+.*$/m, `$1 ${ref}`)
288
+ // Pin every runtime reference in the rendered caller: the orchestrator input
289
+ // in the `with:` block and the mode job's runtime checkout. Self templates
290
+ // use ${{ github.sha }} and are only rewritten when rendered for consumers.
291
+ rendered = rendered.replace(/^(\s+runtime-ref:)\s+.*$/gm, `$1 ${ref}`)
292
+ rendered = rendered.replace(/^(\s+ref:)\s+\$\{\{\s*github\.sha\s*\}\}\s*$/gm, `$1 ${ref}`)
293
+ rendered = rendered.replace(/^(\s+runtime-repository:)\s+.*$/gm, `$1 ${repository}`)
294
+ rendered = rendered.replace(new RegExp(`^(\\s+repository:)\\s+0xPlayerOne\\/code-foundry\\s*$`, 'gm'), `$1 ${repository}`)
249
295
  /** @type {Record<string, string|undefined>} */
250
296
  const runners = {
251
297
  ci: config.ci_runner ?? config.runner,
@@ -262,6 +308,23 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
262
308
  if (workflow === 'test' && config.unit_runner) {
263
309
  rendered = rendered.replace(/^(\s+unit-runner:)\s+.*$/m, `$1 ${config.unit_runner}`)
264
310
  }
311
+ if (workflow === 'validation') {
312
+ /** @type {Record<string, string|undefined>} */
313
+ const runnerInputs = {
314
+ 'ci-runner': config.ci_runner ?? config.runner,
315
+ 'test-runner': config.test_runner ?? config.runner,
316
+ 'security-runner': config.security_runner ?? config.runner,
317
+ 'codeql-runner': config.codeql_runner ?? config.runner,
318
+ 'unit-runner': config.unit_runner,
319
+ }
320
+ for (const [input, value] of Object.entries(runnerInputs)) {
321
+ if (!value) continue
322
+ rendered = rendered.replace(new RegExp(`^(\\s+${input}:)\\s+.*$`, 'm'), `$1 ${value}`)
323
+ }
324
+ rendered = rendered.replace(/^(\s+rust-shards:)\s+.*$/m, `$1 '${rustCodeql.shards}'`)
325
+ rendered = rendered.replace(/^(\s+rust-threads:)\s+.*$/m, `$1 '${rustCodeql.threads}'`)
326
+ rendered = rendered.replace(/^(\s+rust-max-parallel:)\s+.*$/m, `$1 ${rustCodeql.maxParallel}`)
327
+ }
265
328
  if (workflow === 'codeql') {
266
329
  rendered = rendered.replace(/^(\s+rust-shards:)\s+.*$/m, `$1 '${rustCodeql.shards}'`)
267
330
  rendered = rendered.replace(/^(\s+rust-threads:)\s+.*$/m, `$1 '${rustCodeql.threads}'`)
@@ -319,6 +382,27 @@ function validateRustCodeqlConfig(config) {
319
382
  /** @param {string} value */
320
383
  function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }
321
384
 
385
+ /**
386
+ * Recognize a Code Foundry-generated legacy event caller (the consumer copies
387
+ * of the old ci/test/security/codeql callers). Recognition is structural and
388
+ * end-to-end: the generated name, a thin caller with no steps or runs-on, the
389
+ * runtime wiring, a single job named after the workflow, and a pinned remote
390
+ * reference to the runtime's reusable workflow. Anything else is treated as a
391
+ * repository-owned workflow and preserved byte-for-byte.
392
+ * @param {string|Buffer} content
393
+ * @param {string} stem
394
+ * @param {string} runtimeRepository
395
+ * @returns {boolean}
396
+ */
397
+ export function isGeneratedEventCaller(content, stem, runtimeRepository) {
398
+ const text = Buffer.isBuffer(content) ? content.toString('utf8') : String(content)
399
+ if (!/^name:\s*Code Foundry\s*$/m.test(text)) return false
400
+ if (/^\s*(runs-on|steps):/m.test(text)) return false
401
+ if (!text.includes('runtime-repository:')) return false
402
+ if (!new RegExp(`^ ${stem}:`, 'm').test(text)) return false
403
+ return new RegExp(`uses:\\s*${escapeRegExp(runtimeRepository)}/\\.github/workflows/${stem}\\.yml@`).test(text)
404
+ }
405
+
322
406
  /** @param {string} baseline @param {string} existing */
323
407
  function mergeGitignore(baseline, existing) {
324
408
  const marker = '# Repository-specific rules'
@@ -41,12 +41,18 @@ export function doctorGithub(root) {
41
41
 
42
42
  const secrets = ghJson(['secret', 'list', '--repo', repository, '--json', 'name'])
43
43
  const secretNames = Array.isArray(secrets) ? secrets.map(/** @param {any} secret */ (secret) => secret.name) : []
44
- details.secrets = { releasePleaseTokenPresent: secretNames.includes('RELEASE_PLEASE_TOKEN') }
45
- if (!details.secrets.releasePleaseTokenPresent) warnings.push('RELEASE_PLEASE_TOKEN is not configured; Release Please auto-merge and direct post-release dispatch are unavailable.')
44
+ details.secrets = {
45
+ codeFoundryTokenPresent: secretNames.includes('CODE_FOUNDRY_TOKEN'),
46
+ releasePleaseTokenPresent: secretNames.includes('RELEASE_PLEASE_TOKEN'),
47
+ stagingDeployKeyPresent: secretNames.includes('STAGING_DEPLOY_KEY'),
48
+ }
49
+ if (!details.secrets.codeFoundryTokenPresent && !details.secrets.releasePleaseTokenPresent) {
50
+ warnings.push('CODE_FOUNDRY_TOKEN and RELEASE_PLEASE_TOKEN are both absent. PR workflow triggers and automation may require manual readiness.')
51
+ }
46
52
 
47
53
  const config = readConfig(root)
48
- if (['workflow-dispatch', 'dispatch'].includes(config.post_release_mode) && config.post_release !== 'false' && !details.secrets.releasePleaseTokenPresent) {
49
- errors.push('post-release workflow-dispatch mode requires RELEASE_PLEASE_TOKEN to be present.')
54
+ if (['workflow-dispatch', 'dispatch'].includes(config.post_release_mode) && config.post_release !== 'false' && !details.secrets.codeFoundryTokenPresent && !details.secrets.releasePleaseTokenPresent) {
55
+ errors.push('post-release workflow-dispatch mode requires CODE_FOUNDRY_TOKEN or RELEASE_PLEASE_TOKEN to be present.')
50
56
  }
51
57
  const credentialChecks = {
52
58
  npmTokenPresent: secretNames.includes('NPM_TOKEN'),