code-foundry 0.32.3 → 0.33.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/CONTRIBUTING.md +17 -14
- package/.github/code-foundry.yml +7 -1
- package/.github/workflows/ci.yml +25 -8
- package/.github/workflows/codeql.yml +28 -14
- package/.github/workflows/draft-pr.yml +25 -18
- package/.github/workflows/draft-pr_self-ci.yml +3 -1
- package/.github/workflows/opencode-security_self-ci.yml +4 -2
- package/.github/workflows/release-pr.yml +31 -9
- package/.github/workflows/release-pr_self-ci.yml +4 -7
- package/.github/workflows/release.yml +136 -23
- package/.github/workflows/release_self-ci.yml +5 -1
- package/.github/workflows/security.yml +18 -11
- package/.github/workflows/test.yml +28 -9
- package/.github/workflows/validation.yml +183 -0
- package/.github/workflows/validation_self-ci.yml +82 -0
- package/CHANGELOG.md +21 -0
- package/README.md +7 -4
- package/docs/CONFIGURATION.md +2 -1
- package/docs/RELEASES.md +26 -18
- package/docs/WORKFLOWS.md +54 -4
- package/package.json +1 -1
- package/src/commands/doctor.mjs +53 -3
- package/src/commands/release.mjs +220 -68
- package/src/commands/sync.mjs +105 -6
- package/src/lib/github-doctor.mjs +10 -4
- package/src/lib/release-manifest.mjs +1 -1
- package/src/lib/release-policy.mjs +88 -19
- package/src/lib/validation-policy.mjs +127 -0
- package/src/runtime.mjs +54 -0
- package/.github/workflows/ci_self-ci.yml +0 -21
- package/.github/workflows/codeql_self-ci.yml +0 -29
- package/.github/workflows/security_self-ci.yml +0 -21
- package/.github/workflows/test_self-ci.yml +0 -22
package/src/commands/release.mjs
CHANGED
|
@@ -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.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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,194 @@ 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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`Reconciliation of ${head} was retried but failed while the branch moved concurrently.`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve current reconciliation state and plan from current refs.
|
|
55
|
+
* @param {string} target
|
|
56
|
+
* @param {string} base
|
|
57
|
+
* @param {string} head
|
|
58
|
+
* @param {Set<string>} allowed
|
|
59
|
+
* @param {boolean} requireRemote
|
|
60
|
+
*/
|
|
61
|
+
function resolveReconciliationState(target, base, head, allowed, requireRemote) {
|
|
62
|
+
validateRefName(base)
|
|
63
|
+
validateRefName(head)
|
|
64
|
+
if (requireRemote) refreshRemoteRefs(target, base, head)
|
|
65
|
+
const mainSha = resolveRef(target, base, requireRemote)
|
|
66
|
+
const stagingSha = resolveRef(target, head, requireRemote)
|
|
67
|
+
if (!mainSha || !stagingSha) {
|
|
68
|
+
return {
|
|
69
|
+
plan: {
|
|
70
|
+
action: 'fail',
|
|
71
|
+
reason: `Missing ${!mainSha ? `main (${base})` : `staging (${head})`} or staged branch ref during reconciliation.`,
|
|
72
|
+
},
|
|
73
|
+
mainSha,
|
|
74
|
+
stagingSha,
|
|
75
|
+
mainOnlyCommits: [],
|
|
76
|
+
stagingOnlyCommits: [],
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const mainOnlyCommits = divergentCommits(target, stagingSha, mainSha)
|
|
81
|
+
const stagingOnlyCommits = divergentCommits(target, mainSha, stagingSha)
|
|
82
|
+
const plan = classifyReconciliation({
|
|
83
|
+
mainSha,
|
|
84
|
+
stagingSha,
|
|
85
|
+
mainOnlyCommits,
|
|
86
|
+
stagingOnlyCommits,
|
|
87
|
+
allowed,
|
|
88
|
+
})
|
|
89
|
+
return { plan, mainSha, stagingSha, mainOnlyCommits, stagingOnlyCommits }
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
92
|
+
return {
|
|
93
|
+
plan: {
|
|
94
|
+
action: 'fail',
|
|
95
|
+
reason: `Failed to inspect divergent commits: ${message}`,
|
|
96
|
+
},
|
|
97
|
+
mainSha,
|
|
98
|
+
stagingSha,
|
|
99
|
+
mainOnlyCommits: [],
|
|
100
|
+
stagingOnlyCommits: [],
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @param {{ reason: string, unexpected?: string[] }} plan */
|
|
106
|
+
function formatReconciliationFailure(plan) {
|
|
107
|
+
return `${plan.reason}${plan.unexpected?.length ? ` Unexpected paths: ${plan.unexpected.join(', ')}` : ''}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @param {string} ref */
|
|
111
|
+
function validateRefName(ref) {
|
|
112
|
+
const result = spawnSync('git', ['check-ref-format', '--branch', ref], { encoding: 'utf8' })
|
|
113
|
+
if (result.status !== 0) throw new Error(`Invalid branch reference: ${ref}`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @param {string} target @param {string} ref @param {boolean} requireRemote @returns {string} */
|
|
117
|
+
function resolveRef(target, ref, requireRemote) {
|
|
118
|
+
const remote = git(target, ['rev-parse', `origin/${ref}`])
|
|
119
|
+
if (remote) return remote
|
|
120
|
+
if (!requireRemote) return git(target, ['rev-parse', ref])
|
|
121
|
+
return ''
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @param {string} target @param {string} base @param {string} head */
|
|
125
|
+
function refreshRemoteRefs(target, base, head) {
|
|
126
|
+
const result = spawnSync('git', ['fetch', 'origin', base, head], { cwd: target, encoding: 'utf8' })
|
|
127
|
+
if (result.status !== 0) throw new Error(`Failed to refresh origin/${base} and origin/${head} before reconciliation.`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {string} target @param {string} branch */
|
|
131
|
+
function remoteRefSha(target, branch) {
|
|
132
|
+
const result = spawnSync('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { cwd: target, encoding: 'utf8' })
|
|
133
|
+
if (result.status !== 0) return ''
|
|
134
|
+
const [sha] = result.stdout.trim().split(/\t/, 1)
|
|
135
|
+
return sha || ''
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Reconcile a single attempt from fresh state.
|
|
140
|
+
* @param {string} target
|
|
141
|
+
* @param {string} head
|
|
142
|
+
* @param {{
|
|
143
|
+
* plan: ReturnType<typeof classifyReconciliation>,
|
|
144
|
+
* mainSha: string,
|
|
145
|
+
* stagingSha: string,
|
|
146
|
+
* mainOnlyCommits: Array<{ sha: string, changedPaths: string[] }>,
|
|
147
|
+
* stagingOnlyCommits: Array<{ sha: string, changedPaths: string[] }>,
|
|
148
|
+
* }} state
|
|
149
|
+
*/
|
|
150
|
+
function executeReconciliationMutation(target, head, state) {
|
|
151
|
+
if (!state.plan.targetSha) return { success: false, retry: false, error: `No target SHA for ${head} reconciliation.` }
|
|
152
|
+
if (state.plan.action === 'rebase-staging') {
|
|
153
|
+
const replaySha = replayOntoMain(target, state.mainSha, state.stagingOnlyCommits)
|
|
154
|
+
const push = pushWithLease(target, head, state.stagingSha, replaySha)
|
|
155
|
+
if (push.status === 0) return { success: true, result: { synchronization: 'replay', replaySha } }
|
|
156
|
+
return { success: false, retry: true, error: `Git push of replayed ${head} history failed: ${push.message}` }
|
|
157
|
+
}
|
|
158
|
+
const targetSha = /** @type {string} */ (state.plan.targetSha)
|
|
159
|
+
const leased = pushWithLease(target, head, state.stagingSha, targetSha)
|
|
160
|
+
if (leased.status === 0) return { success: true, result: { synchronization: state.plan.action === 'aligned' ? 'synced' : 'leased' } }
|
|
161
|
+
return { success: false, retry: true, error: `${head} synchronization failed with lease: ${leased.message}` }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** @param {string} target @param {string} head @param {string} expectedSha @param {string} tipSha */
|
|
165
|
+
function pushWithLease(target, head, expectedSha, tipSha) {
|
|
166
|
+
const result = spawnSync('git', [
|
|
167
|
+
'push',
|
|
168
|
+
'origin',
|
|
169
|
+
`--force-with-lease=refs/heads/${head}:${expectedSha}`,
|
|
170
|
+
`${tipSha}:refs/heads/${head}`,
|
|
171
|
+
], { cwd: target, encoding: 'utf8' })
|
|
172
|
+
return {
|
|
173
|
+
status: result.status,
|
|
174
|
+
message: result.stdout?.trim() || result.stderr?.trim() || `push with lease failed for ${head}.`,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Replay staging-only commits onto a detached worktree at main.
|
|
180
|
+
* @param {string} target
|
|
181
|
+
* @param {string} mainSha
|
|
182
|
+
* @param {Array<{ sha: string, changedPaths: string[] }>} commits
|
|
183
|
+
*/
|
|
184
|
+
function replayOntoMain(target, mainSha, commits) {
|
|
185
|
+
const orderedCommits = commits.map((commit) => commit.sha).filter(Boolean)
|
|
186
|
+
if (!orderedCommits.length) return mainSha
|
|
187
|
+
const workspace = mkdtempSync(join(tmpdir(), 'code-foundry-reconcile-'))
|
|
188
|
+
const identityArgs = [
|
|
189
|
+
'-c',
|
|
190
|
+
'user.name=github-actions[bot]',
|
|
191
|
+
'-c',
|
|
192
|
+
'user.email=41898282+github-actions[bot]@users.noreply.github.com',
|
|
193
|
+
]
|
|
194
|
+
try {
|
|
195
|
+
const added = spawnSync('git', ['worktree', 'add', '--detach', workspace, mainSha], { cwd: target, encoding: 'utf8' })
|
|
196
|
+
if (added.status !== 0) throw new Error(`Failed to create temporary replay worktree: ${added.stderr?.trim() || added.stdout?.trim()}`)
|
|
197
|
+
for (const sha of orderedCommits) {
|
|
198
|
+
const cherryPick = spawnSync('git', [...identityArgs, 'cherry-pick', sha], { cwd: workspace, encoding: 'utf8' })
|
|
199
|
+
if (cherryPick.status !== 0) {
|
|
200
|
+
spawnSync('git', ['cherry-pick', '--abort'], { cwd: workspace, encoding: 'utf8' })
|
|
201
|
+
throw new Error(`Cherry-pick of ${sha} failed while replaying staging commits.`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const replayTip = git(workspace, ['rev-parse', 'HEAD'])
|
|
205
|
+
if (!replayTip) throw new Error('Failed to resolve replay tip SHA.')
|
|
206
|
+
const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', mainSha, replayTip], { cwd: workspace, encoding: 'utf8' })
|
|
207
|
+
if (ancestry.status !== 0) throw new Error('Replayed head is not based on the current main tip.')
|
|
208
|
+
return replayTip
|
|
209
|
+
} finally {
|
|
210
|
+
spawnSync('git', ['worktree', 'remove', '--force', workspace], { cwd: target, encoding: 'utf8' })
|
|
211
|
+
rmSync(workspace, { recursive: true, force: true })
|
|
212
|
+
}
|
|
68
213
|
}
|
|
69
214
|
|
|
70
215
|
/**
|
|
@@ -148,29 +293,36 @@ function git(root, args) {
|
|
|
148
293
|
return result.status === 0 ? result.stdout.trim() : ''
|
|
149
294
|
}
|
|
150
295
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return result.stdout
|
|
296
|
+
/** @param {string} root @param {string} from @param {string} to @returns {Array<{ sha: string, changedPaths: string[] }>} */
|
|
297
|
+
function divergentCommits(root, from, to) {
|
|
298
|
+
if (!from || !to || from === to) return []
|
|
299
|
+
const result = spawnSync('git', [
|
|
300
|
+
'log',
|
|
301
|
+
'--cherry-pick',
|
|
302
|
+
'--no-merges',
|
|
303
|
+
'--left-right',
|
|
304
|
+
'--reverse',
|
|
305
|
+
'--pretty=tformat:%m%H',
|
|
306
|
+
`${from}...${to}`,
|
|
307
|
+
], { cwd: root, encoding: 'utf8' })
|
|
308
|
+
if (result.status !== 0) {
|
|
309
|
+
throw new Error(`Failed to enumerate divergent commits between ${from} and ${to}. ${result.stderr?.trim() || result.stdout?.trim()}`)
|
|
310
|
+
}
|
|
311
|
+
return result.stdout
|
|
312
|
+
.split(/\r?\n/)
|
|
313
|
+
.filter((line) => line.startsWith('>'))
|
|
314
|
+
.map((line) => ({
|
|
315
|
+
sha: line.slice(1),
|
|
316
|
+
changedPaths: commitChangedPaths(root, line.slice(1)),
|
|
317
|
+
}))
|
|
318
|
+
.filter((entry) => entry.sha)
|
|
167
319
|
}
|
|
168
320
|
|
|
169
|
-
/** @param {string} root @param {string}
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return result.
|
|
321
|
+
/** @param {string} root @param {string} commitSha @returns {string[]} */
|
|
322
|
+
function commitChangedPaths(root, commitSha) {
|
|
323
|
+
const result = spawnSync('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', commitSha], { cwd: root, encoding: 'utf8' })
|
|
324
|
+
if (result.status !== 0) throw new Error(`Unable to read changed paths for commit ${commitSha}. ${result.stderr?.trim() || result.stdout?.trim()}`)
|
|
325
|
+
return result.stdout.split(/\r?\n/).filter(Boolean)
|
|
174
326
|
}
|
|
175
327
|
|
|
176
328
|
/** @param {string} root @param {string[]} args @returns {unknown} */
|
package/src/commands/sync.mjs
CHANGED
|
@@ -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/
|
|
19
|
+
'.github/workflows/validation.yml', '.github/workflows/draft-pr.yml',
|
|
20
20
|
'.github/workflows/release-pr.yml', '.github/workflows/release.yml',
|
|
21
|
-
'.github/workflows/
|
|
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
|
-
|
|
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
|
|
|
@@ -182,18 +217,27 @@ export function syncRepository(options) {
|
|
|
182
217
|
return { changed, config }
|
|
183
218
|
}
|
|
184
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Baseline keys that are safe to merge into a repository's release config.
|
|
222
|
+
* Package and release-type policy is detected from the repository itself and
|
|
223
|
+
* must never leak from the runtime template.
|
|
224
|
+
*/
|
|
225
|
+
const RELEASE_BASELINE_KEYS = ['$schema', 'bump-minor-pre-major', 'changelog-sections', 'include-component-in-tag']
|
|
226
|
+
|
|
185
227
|
/** @param {string} target @param {string} sourceFile @returns {Record<string, any>} */
|
|
186
228
|
function mergeReleaseConfig(target, sourceFile) {
|
|
229
|
+
/** @type {Record<string, any>} */
|
|
187
230
|
let baseline = {}
|
|
188
231
|
try { baseline = JSON.parse(readFileSync(sourceFile, 'utf8')) }
|
|
189
232
|
catch { baseline = {} }
|
|
233
|
+
const safeBaseline = Object.fromEntries(RELEASE_BASELINE_KEYS.filter((key) => key in baseline).map((key) => [key, baseline[key]]))
|
|
190
234
|
const destination = join(target, 'release-please-config.json')
|
|
191
235
|
let existing = baseline
|
|
192
236
|
if (existsSync(destination)) {
|
|
193
237
|
try { existing = JSON.parse(readFileSync(destination, 'utf8')) }
|
|
194
238
|
catch { existing = baseline }
|
|
195
239
|
}
|
|
196
|
-
return buildReleaseConfig(target, { ...
|
|
240
|
+
return buildReleaseConfig(target, { ...safeBaseline, ...existing })
|
|
197
241
|
}
|
|
198
242
|
|
|
199
243
|
/** @param {string} target @param {string} sourceFile @returns {string} */
|
|
@@ -208,6 +252,11 @@ function shouldInclude(file, languages, features, config) {
|
|
|
208
252
|
if (file === '.github/dependabot.yml') return includesValue(features, 'dependabot')
|
|
209
253
|
if (file === '.github/workflows/opencode-security.yml') return ['true', 'auto'].includes(config.opencode_security ?? 'false')
|
|
210
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
|
+
}
|
|
211
260
|
return !workflow || includesValue(features, workflow)
|
|
212
261
|
}
|
|
213
262
|
|
|
@@ -236,7 +285,13 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
|
|
|
236
285
|
const remotePrefix = `uses: ${repository}/.github/workflows/`
|
|
237
286
|
let rendered = content.replaceAll(localPrefix, remotePrefix)
|
|
238
287
|
rendered = rendered.replace(new RegExp(`${escapeRegExp(remotePrefix)}([^\\s@]+)`, 'g'), `$&@${ref}`)
|
|
239
|
-
|
|
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}`)
|
|
240
295
|
/** @type {Record<string, string|undefined>} */
|
|
241
296
|
const runners = {
|
|
242
297
|
ci: config.ci_runner ?? config.runner,
|
|
@@ -253,6 +308,23 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
|
|
|
253
308
|
if (workflow === 'test' && config.unit_runner) {
|
|
254
309
|
rendered = rendered.replace(/^(\s+unit-runner:)\s+.*$/m, `$1 ${config.unit_runner}`)
|
|
255
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
|
+
}
|
|
256
328
|
if (workflow === 'codeql') {
|
|
257
329
|
rendered = rendered.replace(/^(\s+rust-shards:)\s+.*$/m, `$1 '${rustCodeql.shards}'`)
|
|
258
330
|
rendered = rendered.replace(/^(\s+rust-threads:)\s+.*$/m, `$1 '${rustCodeql.threads}'`)
|
|
@@ -310,6 +382,27 @@ function validateRustCodeqlConfig(config) {
|
|
|
310
382
|
/** @param {string} value */
|
|
311
383
|
function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }
|
|
312
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
|
+
|
|
313
406
|
/** @param {string} baseline @param {string} existing */
|
|
314
407
|
function mergeGitignore(baseline, existing) {
|
|
315
408
|
const marker = '# Repository-specific rules'
|
|
@@ -371,7 +464,13 @@ function createDefaultConfig(root, source) {
|
|
|
371
464
|
/** @param {Record<string,string>} config */
|
|
372
465
|
function renderConfig(config) { return `${Object.entries(config).map(([key, value]) => renderConfigLine(key, value)).join('\n')}\n` }
|
|
373
466
|
/** @param {string} key @param {string} value */
|
|
374
|
-
function renderConfigLine(key, value) {
|
|
467
|
+
function renderConfigLine(key, value) {
|
|
468
|
+
if (value === '') return `${key}:`
|
|
469
|
+
// Quote values that YAML would parse as collections or that would trip
|
|
470
|
+
// prettier's formatter (e.g. codeql_rust_shards: '["all"]').
|
|
471
|
+
const needsQuotes = /^[\[\]{]|[:,#]\s|\s#/.test(value)
|
|
472
|
+
return needsQuotes ? `${key}: '${value.replace(/'/g, "''")}'` : `${key}: ${value}`
|
|
473
|
+
}
|
|
375
474
|
/** @param {string} root */
|
|
376
475
|
function readPackageVersion(root) {
|
|
377
476
|
try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version ?? '0.0.0' } catch { return '0.0.0' }
|
|
@@ -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 = {
|
|
45
|
-
|
|
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'),
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
|
|
6
|
-
const ignored = new Set(['.git', '.code-foundry', '.venv', 'node_modules', 'target', 'vendor', 'dist', 'build'])
|
|
6
|
+
const ignored = new Set(['.git', '.code-foundry', '.venv', 'node_modules', 'target', 'vendor', 'dist', 'build', '.next', '.kilo', '.turbo', '.cache', '.vercel', '.output', '.nuxt', '.svelte-kit', '.parcel-cache', 'out', 'coverage'])
|
|
7
7
|
|
|
8
8
|
/** @typedef {{ directory: string, manifest: string, releaseType: 'node'|'python'|'rust', packageName?: string, extraFiles: string[] }} ReleasePackage */
|
|
9
9
|
|