code-foundry 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/opencode-security_self-ci.yml +29 -20
- package/.oxfmtrc.json +7 -0
- package/.oxlintrc.json +5 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +14 -0
- package/README.md +3 -2
- package/docs/CONFIGURATION.md +24 -24
- package/docs/WORKFLOWS.md +16 -16
- package/package.json +20 -17
- package/src/cli.mjs +60 -14
- package/src/commands/ci.mjs +166 -57
- package/src/commands/doctor.mjs +98 -31
- package/src/commands/fleet.mjs +139 -29
- package/src/commands/release.mjs +378 -96
- package/src/commands/sync.mjs +338 -104
- package/src/lib/config.mjs +8 -2
- package/src/lib/github-doctor.mjs +153 -51
- package/src/lib/overlay.mjs +6 -2
- package/src/lib/profile.mjs +26 -8
- package/src/lib/release-hook.mjs +17 -5
- package/src/lib/release-manifest.mjs +81 -19
- package/src/lib/release-policy.mjs +157 -55
- package/src/lib/test-discovery.mjs +7 -2
- package/src/runtime.mjs +259 -61
- package/.prettierignore +0 -9
- package/.prettierrc +0 -6
package/src/commands/sync.mjs
CHANGED
|
@@ -3,21 +3,48 @@
|
|
|
3
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { dirname, join, resolve } from 'node:path'
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
detectLanguages,
|
|
8
|
+
detectPackageManager,
|
|
9
|
+
detectProfile,
|
|
10
|
+
recommendRunners,
|
|
11
|
+
} from '../lib/profile.mjs'
|
|
12
|
+
import {
|
|
13
|
+
configured,
|
|
14
|
+
gitWorkflow,
|
|
15
|
+
includesValue,
|
|
16
|
+
isStagingRelease,
|
|
17
|
+
readConfig,
|
|
18
|
+
} from '../lib/config.mjs'
|
|
8
19
|
import { buildReleaseConfig, buildReleaseManifest } from '../lib/release-manifest.mjs'
|
|
9
20
|
import { customWorkflowFiles, overlayPolicy } from '../lib/overlay.mjs'
|
|
10
21
|
|
|
11
22
|
const standardFiles = [
|
|
12
|
-
'.editorconfig',
|
|
23
|
+
'.editorconfig',
|
|
24
|
+
'.gitattributes',
|
|
25
|
+
'.gitignore',
|
|
26
|
+
'release-please-config.json',
|
|
13
27
|
'docs/EXTENSIONS.md',
|
|
14
|
-
'.githooks/pre-commit',
|
|
15
|
-
'.
|
|
16
|
-
'
|
|
17
|
-
'
|
|
28
|
+
'.githooks/pre-commit',
|
|
29
|
+
'AGENTS.md',
|
|
30
|
+
'LICENSE',
|
|
31
|
+
'NOTICE',
|
|
32
|
+
'ruff.toml',
|
|
33
|
+
'.oxfmtrc.json',
|
|
34
|
+
'.oxlintrc.json',
|
|
35
|
+
'.github/CODEOWNERS',
|
|
36
|
+
'.github/CODE_OF_CONDUCT.md',
|
|
37
|
+
'.github/CONTRIBUTING.md',
|
|
38
|
+
'.github/PULL_REQUEST_TEMPLATE.md',
|
|
39
|
+
'.github/SECURITY.md',
|
|
40
|
+
'.github/dependabot.yml',
|
|
41
|
+
'.github/ISSUE_TEMPLATE/bug_report.yml',
|
|
42
|
+
'.github/ISSUE_TEMPLATE/config.yml',
|
|
18
43
|
'.github/ISSUE_TEMPLATE/feature_request.yml',
|
|
19
|
-
'.github/workflows/validation.yml',
|
|
20
|
-
'.github/workflows/
|
|
44
|
+
'.github/workflows/validation.yml',
|
|
45
|
+
'.github/workflows/draft-pr.yml',
|
|
46
|
+
'.github/workflows/release-pr.yml',
|
|
47
|
+
'.github/workflows/release.yml',
|
|
21
48
|
'.github/workflows/opencode-security.yml',
|
|
22
49
|
]
|
|
23
50
|
|
|
@@ -29,8 +56,12 @@ const standardFiles = [
|
|
|
29
56
|
const LEGACY_GENERATED_CALLERS = ['ci', 'test', 'security', 'codeql']
|
|
30
57
|
|
|
31
58
|
const protectedFiles = new Set([
|
|
32
|
-
'AGENTS.md',
|
|
33
|
-
'.github/
|
|
59
|
+
'AGENTS.md',
|
|
60
|
+
'.github/CODE_OF_CONDUCT.md',
|
|
61
|
+
'.github/CONTRIBUTING.md',
|
|
62
|
+
'.github/PULL_REQUEST_TEMPLATE.md',
|
|
63
|
+
'.github/SECURITY.md',
|
|
64
|
+
'NOTICE',
|
|
34
65
|
])
|
|
35
66
|
|
|
36
67
|
/** @type {Record<string, string>} */
|
|
@@ -38,18 +69,31 @@ const licenseFiles = {
|
|
|
38
69
|
'gpl-3.0-or-later': 'GPL-3.0-or-later.txt',
|
|
39
70
|
'agpl-3.0-or-later': 'AGPL-3.0-or-later.txt',
|
|
40
71
|
'apache-2.0': 'APACHE-2.0.txt',
|
|
41
|
-
|
|
72
|
+
mit: 'MIT.txt',
|
|
42
73
|
}
|
|
43
74
|
|
|
44
75
|
const legacyFiles = [
|
|
45
|
-
'.github/code-foundry.yml.example',
|
|
46
|
-
'.github/
|
|
47
|
-
'.github/
|
|
48
|
-
'.github/scripts/
|
|
49
|
-
'.github/scripts/
|
|
50
|
-
'.github/scripts/
|
|
76
|
+
'.github/code-foundry.yml.example',
|
|
77
|
+
'.github/template.yml',
|
|
78
|
+
'.github/template.yml.example',
|
|
79
|
+
'.github/scripts/bootstrap.sh',
|
|
80
|
+
'.github/scripts/changed-files.sh',
|
|
81
|
+
'.github/scripts/ci.sh',
|
|
82
|
+
'.github/scripts/codeql-languages.sh',
|
|
83
|
+
'.github/scripts/doctor.sh',
|
|
84
|
+
'.github/scripts/format-fast-path.sh',
|
|
85
|
+
'.github/scripts/init-repo.sh',
|
|
86
|
+
'.github/scripts/pre-commit.sh',
|
|
87
|
+
'.github/scripts/profile.sh',
|
|
88
|
+
'.github/scripts/security.sh',
|
|
89
|
+
'.github/scripts/sitecustomize.py',
|
|
90
|
+
'.github/scripts/sync-codeowners.sh',
|
|
91
|
+
'.github/scripts/sync-protection.sh',
|
|
92
|
+
'.github/scripts/sync-template.sh',
|
|
93
|
+
'.github/scripts/turbo-cache-probe.sh',
|
|
51
94
|
'.github/licenses/MIT.txt',
|
|
52
|
-
'.github/licenses/GPL-3.0-or-later.txt',
|
|
95
|
+
'.github/licenses/GPL-3.0-or-later.txt',
|
|
96
|
+
'.github/licenses/AGPL-3.0-or-later.txt',
|
|
53
97
|
]
|
|
54
98
|
|
|
55
99
|
/** @typedef {{ target: string, source: string, dryRun?: boolean, force?: boolean, init?: boolean }} SyncOptions */
|
|
@@ -62,13 +106,17 @@ export function syncRepository(options) {
|
|
|
62
106
|
const force = options.force ?? false
|
|
63
107
|
const configPath = join(target, '.github/code-foundry.yml')
|
|
64
108
|
const existingConfig = readConfig(configPath)
|
|
65
|
-
if (!Object.keys(existingConfig).length && !options.init)
|
|
109
|
+
if (!Object.keys(existingConfig).length && !options.init)
|
|
110
|
+
throw new Error('Missing .github/code-foundry.yml; run init first.')
|
|
66
111
|
const defaults = createDefaultConfig(target, source)
|
|
67
112
|
let config = { ...defaults, ...existingConfig }
|
|
68
113
|
// Resolve and validate the license policy before any sync writes occur
|
|
69
114
|
// (including config/default additions) so an unsupported policy fails
|
|
70
115
|
// fast without leaving partially generated files behind.
|
|
71
|
-
const license = configured(
|
|
116
|
+
const license = configured(
|
|
117
|
+
config.license,
|
|
118
|
+
existsSync(join(target, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later'
|
|
119
|
+
)
|
|
72
120
|
const licenseFile = licenseFiles[license]
|
|
73
121
|
if (license !== 'preserve' && license !== 'none' && !licenseFile) {
|
|
74
122
|
const supported = [...Object.keys(licenseFiles), 'preserve', 'none'].join(', ')
|
|
@@ -108,17 +156,25 @@ export function syncRepository(options) {
|
|
|
108
156
|
}
|
|
109
157
|
const mergeStrategy = configured(config.merge_strategy, 'rebase')
|
|
110
158
|
if (workflow === 'staging-release' && mergeStrategy !== 'rebase') {
|
|
111
|
-
throw new Error(
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Unsupported merge_strategy: ${mergeStrategy}; the staging-release topology requires rebase for staging to main promotions.`
|
|
161
|
+
)
|
|
112
162
|
}
|
|
113
163
|
const releaseMergeStrategy = configured(config.release_merge_strategy, '')
|
|
114
164
|
if (includesValue(features, 'release') && releaseMergeStrategy !== 'rebase') {
|
|
115
|
-
throw new Error(
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Unsupported release_merge_strategy: ${releaseMergeStrategy || '(unset)'}; release automation requires rebase for Release Please version pull requests and never defaults to merge.`
|
|
167
|
+
)
|
|
116
168
|
}
|
|
117
169
|
const changed = []
|
|
118
170
|
|
|
119
171
|
// Keep normal semver pins current during sync while preserving intentional
|
|
120
172
|
// refs such as `main`, `staging`, or a custom immutable SHA.
|
|
121
|
-
if (
|
|
173
|
+
if (
|
|
174
|
+
existingConfig.runtime_ref &&
|
|
175
|
+
/^v\d+\.\d+\.\d+$/.test(existingConfig.runtime_ref) &&
|
|
176
|
+
existingConfig.runtime_ref !== sourceRuntimeRef
|
|
177
|
+
) {
|
|
122
178
|
runtimeRef = sourceRuntimeRef
|
|
123
179
|
const current = readFileSync(configPath, 'utf8')
|
|
124
180
|
const updated = current.replace(/^runtime_ref:\s*.*$/m, `runtime_ref: ${sourceRuntimeRef}`)
|
|
@@ -137,7 +193,12 @@ export function syncRepository(options) {
|
|
|
137
193
|
const existing = readFileSync(destination, 'utf8')
|
|
138
194
|
if (!isLegacyManagedDoc(file, existing)) continue
|
|
139
195
|
}
|
|
140
|
-
if (
|
|
196
|
+
if (
|
|
197
|
+
(file === 'LICENSE' || file === 'NOTICE') &&
|
|
198
|
+
license === 'preserve' &&
|
|
199
|
+
existsSync(destination)
|
|
200
|
+
)
|
|
201
|
+
continue
|
|
141
202
|
if ((file === 'LICENSE' || file === 'NOTICE') && license === 'none') continue
|
|
142
203
|
// An explicit license policy makes the license block below the single
|
|
143
204
|
// owner of LICENSE; copying the runtime's own root LICENSE here would
|
|
@@ -149,7 +210,9 @@ export function syncRepository(options) {
|
|
|
149
210
|
content = Buffer.from(renderReleaseConfig(target, sourceFile))
|
|
150
211
|
}
|
|
151
212
|
if (file.endsWith('.yml') && file.startsWith('.github/workflows/')) {
|
|
152
|
-
content = Buffer.from(
|
|
213
|
+
content = Buffer.from(
|
|
214
|
+
renderWorkflow(content.toString('utf8'), config, runtimeRepository, runtimeRef, rustCodeql)
|
|
215
|
+
)
|
|
153
216
|
}
|
|
154
217
|
if (file === '.github/dependabot.yml') {
|
|
155
218
|
content = Buffer.from(renderDependabot(content.toString('utf8'), config, languages))
|
|
@@ -158,10 +221,19 @@ export function syncRepository(options) {
|
|
|
158
221
|
content = Buffer.from(renderContributionDocs(content.toString('utf8'), file, config))
|
|
159
222
|
}
|
|
160
223
|
if (file === '.gitignore' && existsSync(destination)) {
|
|
161
|
-
content = Buffer.from(
|
|
224
|
+
content = Buffer.from(
|
|
225
|
+
mergeGitignore(content.toString('utf8'), readFileSync(destination, 'utf8'))
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
if (file === '.oxfmtrc.json' && existsSync(destination)) {
|
|
229
|
+
content = Buffer.from(
|
|
230
|
+
mergeIgnorePatternsConfig(content.toString('utf8'), readFileSync(destination, 'utf8'))
|
|
231
|
+
)
|
|
162
232
|
}
|
|
163
|
-
if (file === '.
|
|
164
|
-
content = Buffer.from(
|
|
233
|
+
if (file === '.oxlintrc.json' && existsSync(destination)) {
|
|
234
|
+
content = Buffer.from(
|
|
235
|
+
mergeIgnorePatternsConfig(content.toString('utf8'), readFileSync(destination, 'utf8'))
|
|
236
|
+
)
|
|
165
237
|
}
|
|
166
238
|
if (!existsSync(destination) || !buffersEqual(content, readFileSync(destination))) {
|
|
167
239
|
changed.push(file)
|
|
@@ -174,7 +246,8 @@ export function syncRepository(options) {
|
|
|
174
246
|
if (!existsSync(destination)) continue
|
|
175
247
|
if (isGeneratedEventCaller(readFileSync(destination, 'utf8'), stem, runtimeRepository)) {
|
|
176
248
|
changed.push(`.github/workflows/${stem}.yml`)
|
|
177
|
-
if (dryRun)
|
|
249
|
+
if (dryRun)
|
|
250
|
+
console.log(`Would remove generated legacy caller ${stem}.yml; validation.yml replaces it.`)
|
|
178
251
|
else rmSync(destination, { force: true })
|
|
179
252
|
} else {
|
|
180
253
|
console.log(`Preserved ${stem}.yml: not recognized as a Code Foundry-generated caller.`)
|
|
@@ -186,25 +259,39 @@ export function syncRepository(options) {
|
|
|
186
259
|
// linger dormant (it triggers on pushes to a branch that does not exist).
|
|
187
260
|
if (!isStagingRelease(config.git_workflow)) {
|
|
188
261
|
const promotion = join(target, '.github/workflows/release-pr.yml')
|
|
189
|
-
if (
|
|
262
|
+
if (
|
|
263
|
+
existsSync(promotion) &&
|
|
264
|
+
isGeneratedEventCaller(readFileSync(promotion, 'utf8'), 'release-pr', runtimeRepository)
|
|
265
|
+
) {
|
|
190
266
|
changed.push('.github/workflows/release-pr.yml')
|
|
191
|
-
if (dryRun)
|
|
267
|
+
if (dryRun)
|
|
268
|
+
console.log(
|
|
269
|
+
'Would remove generated release-pr caller; the direct topology targets pull requests at main.'
|
|
270
|
+
)
|
|
192
271
|
else rmSync(promotion, { force: true })
|
|
193
272
|
}
|
|
194
273
|
}
|
|
195
274
|
|
|
196
|
-
const releaseManifest = buildReleaseManifest(
|
|
275
|
+
const releaseManifest = buildReleaseManifest(
|
|
276
|
+
target,
|
|
277
|
+
mergeReleaseConfig(target, sourcePath(source, 'release-please-config.json'))
|
|
278
|
+
)
|
|
197
279
|
if (releaseManifest) {
|
|
198
280
|
const manifestPath = join(target, '.release-please-manifest.json')
|
|
199
281
|
/** @type {Record<string, string>} */
|
|
200
282
|
let existingManifest = {}
|
|
201
283
|
if (existsSync(manifestPath)) {
|
|
202
|
-
try {
|
|
203
|
-
|
|
284
|
+
try {
|
|
285
|
+
existingManifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
286
|
+
} catch {
|
|
287
|
+
existingManifest = {}
|
|
288
|
+
}
|
|
204
289
|
}
|
|
205
290
|
/** @type {Record<string, string>} */
|
|
206
291
|
const mergedManifest = {}
|
|
207
|
-
for (const directory of [
|
|
292
|
+
for (const directory of [
|
|
293
|
+
...new Set([...Object.keys(releaseManifest), ...Object.keys(existingManifest)]),
|
|
294
|
+
].sort()) {
|
|
208
295
|
mergedManifest[directory] = existingManifest[directory] ?? releaseManifest[directory]
|
|
209
296
|
}
|
|
210
297
|
const content = `${JSON.stringify(mergedManifest, null, 2)}\n`
|
|
@@ -218,11 +305,15 @@ export function syncRepository(options) {
|
|
|
218
305
|
const sourceLicense = join(source, '.github/licenses', licenseFile)
|
|
219
306
|
if (!existsSync(sourceLicense)) throw new Error(`License template missing: ${sourceLicense}`)
|
|
220
307
|
const licenseContent = readFileSync(sourceLicense)
|
|
221
|
-
if (
|
|
308
|
+
if (
|
|
309
|
+
!existsSync(join(target, 'LICENSE')) ||
|
|
310
|
+
!buffersEqual(licenseContent, readFileSync(join(target, 'LICENSE')))
|
|
311
|
+
) {
|
|
222
312
|
changed.push('LICENSE')
|
|
223
313
|
writeOrReport(join(target, 'LICENSE'), licenseContent, dryRun)
|
|
224
314
|
}
|
|
225
|
-
if (!existsSync(join(target, 'NOTICE')))
|
|
315
|
+
if (!existsSync(join(target, 'NOTICE')))
|
|
316
|
+
writeOrReport(join(target, 'NOTICE'), readFileSync(join(source, 'NOTICE')), dryRun)
|
|
226
317
|
}
|
|
227
318
|
|
|
228
319
|
for (const file of legacyFiles) {
|
|
@@ -233,21 +324,53 @@ export function syncRepository(options) {
|
|
|
233
324
|
else rmSync(destination, { force: true })
|
|
234
325
|
}
|
|
235
326
|
}
|
|
236
|
-
for (const file of ['ruff.toml', '.
|
|
237
|
-
const relevant =
|
|
327
|
+
for (const file of ['ruff.toml', '.oxfmtrc.json', '.oxlintrc.json']) {
|
|
328
|
+
const relevant =
|
|
329
|
+
file === 'ruff.toml'
|
|
330
|
+
? includesValue(languages, 'python')
|
|
331
|
+
: includesValue(languages, 'typescript')
|
|
238
332
|
if (!relevant && existsSync(join(target, file))) {
|
|
239
333
|
changed.push(file)
|
|
240
334
|
if (dryRun) console.log(`Would remove irrelevant language configuration ${file}`)
|
|
241
335
|
else rmSync(join(target, file), { force: true })
|
|
242
336
|
}
|
|
243
337
|
}
|
|
244
|
-
if (includesValue(languages, 'typescript')
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
338
|
+
if (includesValue(languages, 'typescript')) {
|
|
339
|
+
// The Oxfmt baseline supersedes .prettierrc/.prettierignore. Entries the
|
|
340
|
+
// consumer added beyond the baseline survive the migration inside the
|
|
341
|
+
// .oxfmtrc.json ignorePatterns list.
|
|
342
|
+
const customPatterns = []
|
|
343
|
+
const legacyIgnore = join(target, '.prettierignore')
|
|
344
|
+
if (existsSync(legacyIgnore)) {
|
|
345
|
+
const baselineEntries = new Set(['CHANGELOG.md', '.github/.code-foundry', '.github/actions/'])
|
|
346
|
+
for (const line of readFileSync(legacyIgnore, 'utf8').split(/\r?\n/)) {
|
|
347
|
+
const entry = line.trim()
|
|
348
|
+
if (!entry || entry.startsWith('#') || baselineEntries.has(entry)) continue
|
|
349
|
+
customPatterns.push(entry)
|
|
350
|
+
}
|
|
250
351
|
changed.push('.prettierignore')
|
|
352
|
+
if (dryRun) console.log('Would remove superseded .prettierignore')
|
|
353
|
+
else rmSync(legacyIgnore, { force: true })
|
|
354
|
+
}
|
|
355
|
+
const legacyConfig = join(target, '.prettierrc')
|
|
356
|
+
if (existsSync(legacyConfig)) {
|
|
357
|
+
changed.push('.prettierrc')
|
|
358
|
+
if (dryRun) console.log('Would remove superseded .prettierrc')
|
|
359
|
+
else rmSync(legacyConfig, { force: true })
|
|
360
|
+
}
|
|
361
|
+
ensureOxfmtIgnorePatterns(
|
|
362
|
+
target,
|
|
363
|
+
['.github/.code-foundry', '.github/actions/', ...customPatterns],
|
|
364
|
+
changed,
|
|
365
|
+
dryRun
|
|
366
|
+
)
|
|
367
|
+
} else {
|
|
368
|
+
for (const file of ['.prettierrc', '.prettierignore']) {
|
|
369
|
+
if (existsSync(join(target, file))) {
|
|
370
|
+
changed.push(file)
|
|
371
|
+
if (dryRun) console.log(`Would remove irrelevant language configuration ${file}`)
|
|
372
|
+
else rmSync(join(target, file), { force: true })
|
|
373
|
+
}
|
|
251
374
|
}
|
|
252
375
|
}
|
|
253
376
|
if (!dryRun && existsSync(join(target, '.githooks/pre-commit'))) {
|
|
@@ -267,20 +390,33 @@ export function syncRepository(options) {
|
|
|
267
390
|
* Package and release-type policy is detected from the repository itself and
|
|
268
391
|
* must never leak from the runtime template.
|
|
269
392
|
*/
|
|
270
|
-
const RELEASE_BASELINE_KEYS = [
|
|
393
|
+
const RELEASE_BASELINE_KEYS = [
|
|
394
|
+
'$schema',
|
|
395
|
+
'bump-minor-pre-major',
|
|
396
|
+
'changelog-sections',
|
|
397
|
+
'include-component-in-tag',
|
|
398
|
+
]
|
|
271
399
|
|
|
272
400
|
/** @param {string} target @param {string} sourceFile @returns {Record<string, any>} */
|
|
273
401
|
function mergeReleaseConfig(target, sourceFile) {
|
|
274
402
|
/** @type {Record<string, any>} */
|
|
275
403
|
let baseline = {}
|
|
276
|
-
try {
|
|
277
|
-
|
|
278
|
-
|
|
404
|
+
try {
|
|
405
|
+
baseline = JSON.parse(readFileSync(sourceFile, 'utf8'))
|
|
406
|
+
} catch {
|
|
407
|
+
baseline = {}
|
|
408
|
+
}
|
|
409
|
+
const safeBaseline = Object.fromEntries(
|
|
410
|
+
RELEASE_BASELINE_KEYS.filter((key) => key in baseline).map((key) => [key, baseline[key]])
|
|
411
|
+
)
|
|
279
412
|
const destination = join(target, 'release-please-config.json')
|
|
280
413
|
let existing = baseline
|
|
281
414
|
if (existsSync(destination)) {
|
|
282
|
-
try {
|
|
283
|
-
|
|
415
|
+
try {
|
|
416
|
+
existing = JSON.parse(readFileSync(destination, 'utf8'))
|
|
417
|
+
} catch {
|
|
418
|
+
existing = baseline
|
|
419
|
+
}
|
|
284
420
|
}
|
|
285
421
|
return buildReleaseConfig(target, { ...safeBaseline, ...existing })
|
|
286
422
|
}
|
|
@@ -293,9 +429,11 @@ function renderReleaseConfig(target, sourceFile) {
|
|
|
293
429
|
/** @param {string} file @param {string} languages @param {string} features @param {Record<string, string>} config */
|
|
294
430
|
function shouldInclude(file, languages, features, config) {
|
|
295
431
|
if (file === 'ruff.toml') return includesValue(languages, 'python')
|
|
296
|
-
if (file === '.
|
|
432
|
+
if (file === '.oxfmtrc.json' || file === '.oxlintrc.json')
|
|
433
|
+
return includesValue(languages, 'typescript')
|
|
297
434
|
if (file === '.github/dependabot.yml') return includesValue(features, 'dependabot')
|
|
298
|
-
if (file === '.github/workflows/opencode-security.yml')
|
|
435
|
+
if (file === '.github/workflows/opencode-security.yml')
|
|
436
|
+
return ['true', 'auto'].includes(config.opencode_security ?? 'false')
|
|
299
437
|
const workflow = file.match(/^\.github\/workflows\/([^/]+)\.yml$/)?.[1]
|
|
300
438
|
// The staging promotion caller only exists in the staging-release topology;
|
|
301
439
|
// direct repositories open feature branches into main and need no promotion.
|
|
@@ -303,7 +441,10 @@ function shouldInclude(file, languages, features, config) {
|
|
|
303
441
|
// The tiered validation caller supersedes the legacy ci/test/security/codeql
|
|
304
442
|
// event callers, so legacy feature names keep selecting it.
|
|
305
443
|
if (workflow === 'validation') {
|
|
306
|
-
return
|
|
444
|
+
return (
|
|
445
|
+
includesValue(features, 'validation') ||
|
|
446
|
+
LEGACY_GENERATED_CALLERS.some((legacy) => includesValue(features, legacy))
|
|
447
|
+
)
|
|
307
448
|
}
|
|
308
449
|
return !workflow || includesValue(features, workflow)
|
|
309
450
|
}
|
|
@@ -332,14 +473,20 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
|
|
|
332
473
|
const localPrefix = 'uses: ./.github/workflows/'
|
|
333
474
|
const remotePrefix = `uses: ${repository}/.github/workflows/`
|
|
334
475
|
let rendered = content.replaceAll(localPrefix, remotePrefix)
|
|
335
|
-
rendered = rendered.replace(
|
|
476
|
+
rendered = rendered.replace(
|
|
477
|
+
new RegExp(`${escapeRegExp(remotePrefix)}([^\\s@]+)`, 'g'),
|
|
478
|
+
`$&@${ref}`
|
|
479
|
+
)
|
|
336
480
|
// Pin every runtime reference in the rendered caller: the orchestrator input
|
|
337
481
|
// in the `with:` block and the mode job's runtime checkout. Self templates
|
|
338
482
|
// use ${{ github.sha }} and are only rewritten when rendered for consumers.
|
|
339
483
|
rendered = rendered.replace(/^(\s+runtime-ref:)\s+.*$/gm, `$1 ${ref}`)
|
|
340
484
|
rendered = rendered.replace(/^(\s+ref:)\s+\$\{\{\s*github\.sha\s*\}\}\s*$/gm, `$1 ${ref}`)
|
|
341
485
|
rendered = rendered.replace(/^(\s+runtime-repository:)\s+.*$/gm, `$1 ${repository}`)
|
|
342
|
-
rendered = rendered.replace(
|
|
486
|
+
rendered = rendered.replace(
|
|
487
|
+
new RegExp(`^(\\s+repository:)\\s+0xPlayerOne\\/code-foundry\\s*$`, 'gm'),
|
|
488
|
+
`$1 ${repository}`
|
|
489
|
+
)
|
|
343
490
|
/** @type {Record<string, string|undefined>} */
|
|
344
491
|
const runners = {
|
|
345
492
|
ci: config.ci_runner ?? config.runner,
|
|
@@ -361,7 +508,10 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
|
|
|
361
508
|
// The draft PR caller states the PR base explicitly so the shared
|
|
362
509
|
// reusable workflow creates pull requests against the repository's
|
|
363
510
|
// configured integration branch (staging) or main (direct).
|
|
364
|
-
rendered = rendered.replace(
|
|
511
|
+
rendered = rendered.replace(
|
|
512
|
+
/^(\s+base:)\s+.*$/m,
|
|
513
|
+
`$1 ${isStagingRelease(config.git_workflow) ? 'staging' : 'main'}`
|
|
514
|
+
)
|
|
365
515
|
}
|
|
366
516
|
const runner = workflow ? runners[workflow] : undefined
|
|
367
517
|
if (runner) rendered = rendered.replace(/^(\s+runner:)\s+.*$/m, `$1 ${runner}`)
|
|
@@ -514,10 +664,7 @@ const DIRECT_DOC_REPLACEMENTS = {
|
|
|
514
664
|
'git switch staging\ngit pull --ff-only origin staging',
|
|
515
665
|
'git switch main\ngit pull --ff-only origin main',
|
|
516
666
|
],
|
|
517
|
-
[
|
|
518
|
-
'1. Start from an up-to-date `staging` branch.',
|
|
519
|
-
'1. Start from an up-to-date `main` branch.',
|
|
520
|
-
],
|
|
667
|
+
['1. Start from an up-to-date `staging` branch.', '1. Start from an up-to-date `main` branch.'],
|
|
521
668
|
[
|
|
522
669
|
'8. Push the branch and open a pull request into `staging`.',
|
|
523
670
|
'8. Push the branch and open a pull request into `main`.',
|
|
@@ -526,10 +673,7 @@ const DIRECT_DOC_REPLACEMENTS = {
|
|
|
526
673
|
'10. Merge with a squash after required checks pass and the change is ready; feature PRs land on `staging` with squash merges.',
|
|
527
674
|
'10. Merge with a squash after required checks pass and the change is ready; feature PRs land on `main` with squash merges.',
|
|
528
675
|
],
|
|
529
|
-
[
|
|
530
|
-
'3. Branch from the upstream `staging` branch.',
|
|
531
|
-
'3. Branch from the upstream `main` branch.',
|
|
532
|
-
],
|
|
676
|
+
['3. Branch from the upstream `staging` branch.', '3. Branch from the upstream `main` branch.'],
|
|
533
677
|
[
|
|
534
678
|
'7. Push to the fork and open a pull request targeting `staging`.',
|
|
535
679
|
'7. Push to the fork and open a pull request targeting `main`.',
|
|
@@ -542,20 +686,14 @@ const DIRECT_DOC_REPLACEMENTS = {
|
|
|
542
686
|
'| Change | Target | Merge method | Merge gate |\n| ---------------------------- | --------- | ----------------------------------------------- | --------------------------------------------------------- |\n| Working branch | `staging` | Squash | All applicable required checks pass |\n| `staging` → `main` promotion | `main` | Rebase (`merge_strategy`) | Current staging checks, release review, and rollout notes |\n| Release Please version PR | `main` | Rebase (`release_merge_strategy`, fails closed) | Validation gate and release policy pass |\n',
|
|
543
687
|
'| Change | Target | Merge method | Merge gate |\n|----------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| Working branch | `main` | Squash | All applicable required checks pass |\n| Release Please version PR | `main` | Rebase (`release_merge_strategy`, fails closed) | Validation gate and release policy pass |',
|
|
544
688
|
],
|
|
545
|
-
[
|
|
546
|
-
'1. Create a focused branch from `staging`.',
|
|
547
|
-
'1. Create a focused branch from `main`.',
|
|
548
|
-
],
|
|
689
|
+
['1. Create a focused branch from `staging`.', '1. Create a focused branch from `main`.'],
|
|
549
690
|
],
|
|
550
691
|
'.github/SECURITY.md': [
|
|
551
692
|
[
|
|
552
693
|
'The latest commit on `staging` receives security patches. Patches are promoted to `main` through the next release cycle.',
|
|
553
694
|
'The latest commit on `main` receives security patches.',
|
|
554
695
|
],
|
|
555
|
-
[
|
|
556
|
-
'| `staging` | ✅ |\n',
|
|
557
|
-
'',
|
|
558
|
-
],
|
|
696
|
+
['| `staging` | ✅ |\n', ''],
|
|
559
697
|
],
|
|
560
698
|
}
|
|
561
699
|
|
|
@@ -606,7 +744,9 @@ function validateRustCodeqlConfig(config) {
|
|
|
606
744
|
}
|
|
607
745
|
|
|
608
746
|
/** @param {string} value */
|
|
609
|
-
function escapeRegExp(value) {
|
|
747
|
+
function escapeRegExp(value) {
|
|
748
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
749
|
+
}
|
|
610
750
|
|
|
611
751
|
/**
|
|
612
752
|
* Recognize a Code Foundry-generated legacy event caller (the consumer copies
|
|
@@ -626,49 +766,113 @@ export function isGeneratedEventCaller(content, stem, runtimeRepository) {
|
|
|
626
766
|
if (/^\s*(runs-on|steps):/m.test(text)) return false
|
|
627
767
|
if (!text.includes('runtime-repository:')) return false
|
|
628
768
|
if (!new RegExp(`^ ${stem}:`, 'm').test(text)) return false
|
|
629
|
-
return new RegExp(
|
|
769
|
+
return new RegExp(
|
|
770
|
+
`uses:\\s*${escapeRegExp(runtimeRepository)}/\\.github/workflows/${stem}\\.yml@`
|
|
771
|
+
).test(text)
|
|
630
772
|
}
|
|
631
773
|
|
|
632
774
|
/** @param {string} baseline @param {string} existing */
|
|
633
775
|
function mergeGitignore(baseline, existing) {
|
|
634
776
|
const marker = '# Repository-specific rules'
|
|
635
|
-
const custom = existing.includes(marker)
|
|
777
|
+
const custom = existing.includes(marker)
|
|
778
|
+
? existing.slice(existing.indexOf(marker) + marker.length).trim()
|
|
779
|
+
: ''
|
|
636
780
|
return custom ? `${baseline.trimEnd()}\n\n${marker}\n${custom}\n` : baseline
|
|
637
781
|
}
|
|
638
782
|
|
|
639
|
-
/**
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
783
|
+
/**
|
|
784
|
+
* Merge a baseline Oxc config (.oxfmtrc.json / .oxlintrc.json) with the
|
|
785
|
+
* consumer's current config. The baseline owns every key except
|
|
786
|
+
* `ignorePatterns`, where consumer-specific patterns (added by sync
|
|
787
|
+
* migrations or by the repository) are preserved so repeated syncs never
|
|
788
|
+
* churn them.
|
|
789
|
+
* @param {string} baseline @param {string} existing @returns {string}
|
|
790
|
+
*/
|
|
791
|
+
function mergeIgnorePatternsConfig(baseline, existing) {
|
|
792
|
+
/** @type {string[]} */
|
|
793
|
+
let extra = []
|
|
794
|
+
try {
|
|
795
|
+
const parsed = JSON.parse(existing)
|
|
796
|
+
if (Array.isArray(parsed.ignorePatterns)) extra = parsed.ignorePatterns
|
|
797
|
+
} catch {
|
|
798
|
+
return baseline
|
|
799
|
+
}
|
|
800
|
+
if (extra.length === 0) return baseline
|
|
801
|
+
let config
|
|
802
|
+
try {
|
|
803
|
+
config = JSON.parse(baseline)
|
|
804
|
+
} catch {
|
|
805
|
+
return baseline
|
|
806
|
+
}
|
|
807
|
+
const base = Array.isArray(config.ignorePatterns) ? config.ignorePatterns : []
|
|
808
|
+
const missing = extra.filter((pattern) => !base.includes(pattern))
|
|
809
|
+
if (missing.length === 0) return baseline
|
|
810
|
+
config.ignorePatterns = [...base, ...missing]
|
|
811
|
+
return `${JSON.stringify(config, null, 2)}\n`
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Ensure the consumer's .oxfmtrc.json carries every baseline ignore pattern
|
|
816
|
+
* (plus any migrated custom patterns). Missing patterns are appended to the
|
|
817
|
+
* existing list; the file is left untouched when nothing is missing or the
|
|
818
|
+
* config is absent/unparseable.
|
|
819
|
+
* @param {string} target @param {string[]} patterns @param {string[]} changed @param {boolean} dryRun
|
|
820
|
+
*/
|
|
821
|
+
function ensureOxfmtIgnorePatterns(target, patterns, changed, dryRun) {
|
|
822
|
+
const path = join(target, '.oxfmtrc.json')
|
|
823
|
+
if (patterns.length === 0 || !existsSync(path)) return
|
|
824
|
+
let config
|
|
825
|
+
try {
|
|
826
|
+
config = JSON.parse(readFileSync(path, 'utf8'))
|
|
827
|
+
} catch {
|
|
828
|
+
return
|
|
829
|
+
}
|
|
830
|
+
const existing = Array.isArray(config.ignorePatterns) ? config.ignorePatterns : []
|
|
831
|
+
const merged = [...existing]
|
|
832
|
+
for (const pattern of patterns) {
|
|
833
|
+
if (!merged.includes(pattern)) merged.push(pattern)
|
|
834
|
+
}
|
|
835
|
+
if (merged.length === existing.length) return
|
|
836
|
+
config.ignorePatterns = merged
|
|
837
|
+
changed.push('.oxfmtrc.json')
|
|
838
|
+
writeOrReport(path, `${JSON.stringify(config, null, 2)}\n`, dryRun)
|
|
647
839
|
}
|
|
648
840
|
|
|
649
841
|
/** @param {string} file @param {string} content */
|
|
650
842
|
function isLegacyManagedDoc(file, content) {
|
|
651
843
|
if (file === 'AGENTS.md') {
|
|
652
|
-
return
|
|
844
|
+
return (
|
|
845
|
+
content.includes('.github/scripts/bootstrap.sh') &&
|
|
846
|
+
content.includes('bash .github/scripts/ci.sh')
|
|
847
|
+
)
|
|
653
848
|
}
|
|
654
849
|
if (file === '.github/CONTRIBUTING.md') {
|
|
655
|
-
return
|
|
850
|
+
return (
|
|
851
|
+
content.includes('.github/scripts/bootstrap.sh') && content.includes('.github/template.yml')
|
|
852
|
+
)
|
|
656
853
|
}
|
|
657
854
|
return false
|
|
658
855
|
}
|
|
659
856
|
|
|
660
857
|
/** @param {string} target @param {string[]} args */
|
|
661
|
-
function git(target, args) {
|
|
858
|
+
function git(target, args) {
|
|
859
|
+
spawnSync('git', args, { cwd: target, stdio: 'ignore' })
|
|
860
|
+
}
|
|
662
861
|
|
|
663
862
|
/** @param {string} file @param {Buffer|string} content @param {boolean} dryRun */
|
|
664
863
|
function writeOrReport(file, content, dryRun) {
|
|
665
|
-
if (dryRun) {
|
|
864
|
+
if (dryRun) {
|
|
865
|
+
console.log(`Would sync ${file}`)
|
|
866
|
+
return
|
|
867
|
+
}
|
|
666
868
|
mkdirSync(dirname(file), { recursive: true })
|
|
667
869
|
writeFileSync(file, content)
|
|
668
870
|
}
|
|
669
871
|
|
|
670
872
|
/** @param {Buffer} a @param {Buffer} b */
|
|
671
|
-
function buffersEqual(a, b) {
|
|
873
|
+
function buffersEqual(a, b) {
|
|
874
|
+
return a.equals(b)
|
|
875
|
+
}
|
|
672
876
|
|
|
673
877
|
/** @param {string} root @param {string} source @returns {Record<string,string>} */
|
|
674
878
|
function createDefaultConfig(root, source) {
|
|
@@ -676,31 +880,61 @@ function createDefaultConfig(root, source) {
|
|
|
676
880
|
const packageManager = detectPackageManager(root)
|
|
677
881
|
const runners = recommendRunners(root)
|
|
678
882
|
return {
|
|
679
|
-
version: '1',
|
|
680
|
-
|
|
681
|
-
|
|
883
|
+
version: '1',
|
|
884
|
+
profile: detectProfile(root),
|
|
885
|
+
languages,
|
|
886
|
+
features: 'all',
|
|
887
|
+
codeql: 'auto',
|
|
888
|
+
dependency_review: 'auto',
|
|
889
|
+
package_manager: packageManager,
|
|
890
|
+
codeql_rust_shards: '["all"]',
|
|
891
|
+
codeql_rust_threads: '1',
|
|
892
|
+
codeql_rust_max_parallel: '1',
|
|
893
|
+
runtime_repository: '0xPlayerOne/code-foundry',
|
|
894
|
+
runtime_ref: `v${readPackageVersion(source)}`,
|
|
682
895
|
...runners,
|
|
683
|
-
toolchain: 'auto',
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
896
|
+
toolchain: 'auto',
|
|
897
|
+
staging_validation_mode: 'fast',
|
|
898
|
+
prune_standard: 'false',
|
|
899
|
+
cache_packages: 'auto',
|
|
900
|
+
cache_build: 'auto',
|
|
901
|
+
coverage_minimum: '80',
|
|
902
|
+
turbo_remote: 'auto',
|
|
903
|
+
release_type: detectPackageManager(root) === 'none' ? 'auto' : 'node',
|
|
904
|
+
npm_publish: 'false',
|
|
905
|
+
post_release: 'false',
|
|
906
|
+
post_release_workflow: '',
|
|
907
|
+
post_release_mode: 'auto',
|
|
908
|
+
opencode_security: 'false',
|
|
909
|
+
opencode_security_model: '',
|
|
910
|
+
sync_mode: 'overlay',
|
|
911
|
+
custom_workflows: 'preserve',
|
|
912
|
+
license: existsSync(join(root, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later',
|
|
913
|
+
git_workflow: 'direct',
|
|
914
|
+
merge_strategy: 'rebase',
|
|
915
|
+
release_merge_strategy: 'rebase',
|
|
690
916
|
}
|
|
691
917
|
}
|
|
692
918
|
|
|
693
919
|
/** @param {Record<string,string>} config */
|
|
694
|
-
function renderConfig(config) {
|
|
920
|
+
function renderConfig(config) {
|
|
921
|
+
return `${Object.entries(config)
|
|
922
|
+
.map(([key, value]) => renderConfigLine(key, value))
|
|
923
|
+
.join('\n')}\n`
|
|
924
|
+
}
|
|
695
925
|
/** @param {string} key @param {string} value */
|
|
696
926
|
function renderConfigLine(key, value) {
|
|
697
927
|
if (value === '') return `${key}:`
|
|
698
928
|
// Quote values that YAML would parse as collections or that would trip
|
|
699
|
-
//
|
|
700
|
-
const needsQuotes = /^[
|
|
929
|
+
// the formatter (e.g. codeql_rust_shards: '["all"]').
|
|
930
|
+
const needsQuotes = /^[[\]{]|[:,#]\s|\s#/.test(value)
|
|
701
931
|
return needsQuotes ? `${key}: '${value.replace(/'/g, "''")}'` : `${key}: ${value}`
|
|
702
932
|
}
|
|
703
933
|
/** @param {string} root */
|
|
704
934
|
function readPackageVersion(root) {
|
|
705
|
-
try {
|
|
935
|
+
try {
|
|
936
|
+
return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version ?? '0.0.0'
|
|
937
|
+
} catch {
|
|
938
|
+
return '0.0.0'
|
|
939
|
+
}
|
|
706
940
|
}
|