code-foundry 1.2.0 → 1.3.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/code-foundry.yml +1 -1
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/codeql.yml +7 -2
- 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 +332 -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,14 @@ 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
|
+
)
|
|
162
227
|
}
|
|
163
|
-
if (file === '.
|
|
164
|
-
content = Buffer.from(
|
|
228
|
+
if (file === '.oxfmtrc.json' && existsSync(destination)) {
|
|
229
|
+
content = Buffer.from(
|
|
230
|
+
mergeOxfmtConfig(content.toString('utf8'), readFileSync(destination, 'utf8'))
|
|
231
|
+
)
|
|
165
232
|
}
|
|
166
233
|
if (!existsSync(destination) || !buffersEqual(content, readFileSync(destination))) {
|
|
167
234
|
changed.push(file)
|
|
@@ -174,7 +241,8 @@ export function syncRepository(options) {
|
|
|
174
241
|
if (!existsSync(destination)) continue
|
|
175
242
|
if (isGeneratedEventCaller(readFileSync(destination, 'utf8'), stem, runtimeRepository)) {
|
|
176
243
|
changed.push(`.github/workflows/${stem}.yml`)
|
|
177
|
-
if (dryRun)
|
|
244
|
+
if (dryRun)
|
|
245
|
+
console.log(`Would remove generated legacy caller ${stem}.yml; validation.yml replaces it.`)
|
|
178
246
|
else rmSync(destination, { force: true })
|
|
179
247
|
} else {
|
|
180
248
|
console.log(`Preserved ${stem}.yml: not recognized as a Code Foundry-generated caller.`)
|
|
@@ -186,25 +254,39 @@ export function syncRepository(options) {
|
|
|
186
254
|
// linger dormant (it triggers on pushes to a branch that does not exist).
|
|
187
255
|
if (!isStagingRelease(config.git_workflow)) {
|
|
188
256
|
const promotion = join(target, '.github/workflows/release-pr.yml')
|
|
189
|
-
if (
|
|
257
|
+
if (
|
|
258
|
+
existsSync(promotion) &&
|
|
259
|
+
isGeneratedEventCaller(readFileSync(promotion, 'utf8'), 'release-pr', runtimeRepository)
|
|
260
|
+
) {
|
|
190
261
|
changed.push('.github/workflows/release-pr.yml')
|
|
191
|
-
if (dryRun)
|
|
262
|
+
if (dryRun)
|
|
263
|
+
console.log(
|
|
264
|
+
'Would remove generated release-pr caller; the direct topology targets pull requests at main.'
|
|
265
|
+
)
|
|
192
266
|
else rmSync(promotion, { force: true })
|
|
193
267
|
}
|
|
194
268
|
}
|
|
195
269
|
|
|
196
|
-
const releaseManifest = buildReleaseManifest(
|
|
270
|
+
const releaseManifest = buildReleaseManifest(
|
|
271
|
+
target,
|
|
272
|
+
mergeReleaseConfig(target, sourcePath(source, 'release-please-config.json'))
|
|
273
|
+
)
|
|
197
274
|
if (releaseManifest) {
|
|
198
275
|
const manifestPath = join(target, '.release-please-manifest.json')
|
|
199
276
|
/** @type {Record<string, string>} */
|
|
200
277
|
let existingManifest = {}
|
|
201
278
|
if (existsSync(manifestPath)) {
|
|
202
|
-
try {
|
|
203
|
-
|
|
279
|
+
try {
|
|
280
|
+
existingManifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
281
|
+
} catch {
|
|
282
|
+
existingManifest = {}
|
|
283
|
+
}
|
|
204
284
|
}
|
|
205
285
|
/** @type {Record<string, string>} */
|
|
206
286
|
const mergedManifest = {}
|
|
207
|
-
for (const directory of [
|
|
287
|
+
for (const directory of [
|
|
288
|
+
...new Set([...Object.keys(releaseManifest), ...Object.keys(existingManifest)]),
|
|
289
|
+
].sort()) {
|
|
208
290
|
mergedManifest[directory] = existingManifest[directory] ?? releaseManifest[directory]
|
|
209
291
|
}
|
|
210
292
|
const content = `${JSON.stringify(mergedManifest, null, 2)}\n`
|
|
@@ -218,11 +300,15 @@ export function syncRepository(options) {
|
|
|
218
300
|
const sourceLicense = join(source, '.github/licenses', licenseFile)
|
|
219
301
|
if (!existsSync(sourceLicense)) throw new Error(`License template missing: ${sourceLicense}`)
|
|
220
302
|
const licenseContent = readFileSync(sourceLicense)
|
|
221
|
-
if (
|
|
303
|
+
if (
|
|
304
|
+
!existsSync(join(target, 'LICENSE')) ||
|
|
305
|
+
!buffersEqual(licenseContent, readFileSync(join(target, 'LICENSE')))
|
|
306
|
+
) {
|
|
222
307
|
changed.push('LICENSE')
|
|
223
308
|
writeOrReport(join(target, 'LICENSE'), licenseContent, dryRun)
|
|
224
309
|
}
|
|
225
|
-
if (!existsSync(join(target, 'NOTICE')))
|
|
310
|
+
if (!existsSync(join(target, 'NOTICE')))
|
|
311
|
+
writeOrReport(join(target, 'NOTICE'), readFileSync(join(source, 'NOTICE')), dryRun)
|
|
226
312
|
}
|
|
227
313
|
|
|
228
314
|
for (const file of legacyFiles) {
|
|
@@ -233,21 +319,53 @@ export function syncRepository(options) {
|
|
|
233
319
|
else rmSync(destination, { force: true })
|
|
234
320
|
}
|
|
235
321
|
}
|
|
236
|
-
for (const file of ['ruff.toml', '.
|
|
237
|
-
const relevant =
|
|
322
|
+
for (const file of ['ruff.toml', '.oxfmtrc.json', '.oxlintrc.json']) {
|
|
323
|
+
const relevant =
|
|
324
|
+
file === 'ruff.toml'
|
|
325
|
+
? includesValue(languages, 'python')
|
|
326
|
+
: includesValue(languages, 'typescript')
|
|
238
327
|
if (!relevant && existsSync(join(target, file))) {
|
|
239
328
|
changed.push(file)
|
|
240
329
|
if (dryRun) console.log(`Would remove irrelevant language configuration ${file}`)
|
|
241
330
|
else rmSync(join(target, file), { force: true })
|
|
242
331
|
}
|
|
243
332
|
}
|
|
244
|
-
if (includesValue(languages, 'typescript')
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
333
|
+
if (includesValue(languages, 'typescript')) {
|
|
334
|
+
// The Oxfmt baseline supersedes .prettierrc/.prettierignore. Entries the
|
|
335
|
+
// consumer added beyond the baseline survive the migration inside the
|
|
336
|
+
// .oxfmtrc.json ignorePatterns list.
|
|
337
|
+
const customPatterns = []
|
|
338
|
+
const legacyIgnore = join(target, '.prettierignore')
|
|
339
|
+
if (existsSync(legacyIgnore)) {
|
|
340
|
+
const baselineEntries = new Set(['CHANGELOG.md', '.github/.code-foundry', '.github/actions/'])
|
|
341
|
+
for (const line of readFileSync(legacyIgnore, 'utf8').split(/\r?\n/)) {
|
|
342
|
+
const entry = line.trim()
|
|
343
|
+
if (!entry || entry.startsWith('#') || baselineEntries.has(entry)) continue
|
|
344
|
+
customPatterns.push(entry)
|
|
345
|
+
}
|
|
250
346
|
changed.push('.prettierignore')
|
|
347
|
+
if (dryRun) console.log('Would remove superseded .prettierignore')
|
|
348
|
+
else rmSync(legacyIgnore, { force: true })
|
|
349
|
+
}
|
|
350
|
+
const legacyConfig = join(target, '.prettierrc')
|
|
351
|
+
if (existsSync(legacyConfig)) {
|
|
352
|
+
changed.push('.prettierrc')
|
|
353
|
+
if (dryRun) console.log('Would remove superseded .prettierrc')
|
|
354
|
+
else rmSync(legacyConfig, { force: true })
|
|
355
|
+
}
|
|
356
|
+
ensureOxfmtIgnorePatterns(
|
|
357
|
+
target,
|
|
358
|
+
['.github/.code-foundry', '.github/actions/', ...customPatterns],
|
|
359
|
+
changed,
|
|
360
|
+
dryRun
|
|
361
|
+
)
|
|
362
|
+
} else {
|
|
363
|
+
for (const file of ['.prettierrc', '.prettierignore']) {
|
|
364
|
+
if (existsSync(join(target, file))) {
|
|
365
|
+
changed.push(file)
|
|
366
|
+
if (dryRun) console.log(`Would remove irrelevant language configuration ${file}`)
|
|
367
|
+
else rmSync(join(target, file), { force: true })
|
|
368
|
+
}
|
|
251
369
|
}
|
|
252
370
|
}
|
|
253
371
|
if (!dryRun && existsSync(join(target, '.githooks/pre-commit'))) {
|
|
@@ -267,20 +385,33 @@ export function syncRepository(options) {
|
|
|
267
385
|
* Package and release-type policy is detected from the repository itself and
|
|
268
386
|
* must never leak from the runtime template.
|
|
269
387
|
*/
|
|
270
|
-
const RELEASE_BASELINE_KEYS = [
|
|
388
|
+
const RELEASE_BASELINE_KEYS = [
|
|
389
|
+
'$schema',
|
|
390
|
+
'bump-minor-pre-major',
|
|
391
|
+
'changelog-sections',
|
|
392
|
+
'include-component-in-tag',
|
|
393
|
+
]
|
|
271
394
|
|
|
272
395
|
/** @param {string} target @param {string} sourceFile @returns {Record<string, any>} */
|
|
273
396
|
function mergeReleaseConfig(target, sourceFile) {
|
|
274
397
|
/** @type {Record<string, any>} */
|
|
275
398
|
let baseline = {}
|
|
276
|
-
try {
|
|
277
|
-
|
|
278
|
-
|
|
399
|
+
try {
|
|
400
|
+
baseline = JSON.parse(readFileSync(sourceFile, 'utf8'))
|
|
401
|
+
} catch {
|
|
402
|
+
baseline = {}
|
|
403
|
+
}
|
|
404
|
+
const safeBaseline = Object.fromEntries(
|
|
405
|
+
RELEASE_BASELINE_KEYS.filter((key) => key in baseline).map((key) => [key, baseline[key]])
|
|
406
|
+
)
|
|
279
407
|
const destination = join(target, 'release-please-config.json')
|
|
280
408
|
let existing = baseline
|
|
281
409
|
if (existsSync(destination)) {
|
|
282
|
-
try {
|
|
283
|
-
|
|
410
|
+
try {
|
|
411
|
+
existing = JSON.parse(readFileSync(destination, 'utf8'))
|
|
412
|
+
} catch {
|
|
413
|
+
existing = baseline
|
|
414
|
+
}
|
|
284
415
|
}
|
|
285
416
|
return buildReleaseConfig(target, { ...safeBaseline, ...existing })
|
|
286
417
|
}
|
|
@@ -293,9 +424,11 @@ function renderReleaseConfig(target, sourceFile) {
|
|
|
293
424
|
/** @param {string} file @param {string} languages @param {string} features @param {Record<string, string>} config */
|
|
294
425
|
function shouldInclude(file, languages, features, config) {
|
|
295
426
|
if (file === 'ruff.toml') return includesValue(languages, 'python')
|
|
296
|
-
if (file === '.
|
|
427
|
+
if (file === '.oxfmtrc.json' || file === '.oxlintrc.json')
|
|
428
|
+
return includesValue(languages, 'typescript')
|
|
297
429
|
if (file === '.github/dependabot.yml') return includesValue(features, 'dependabot')
|
|
298
|
-
if (file === '.github/workflows/opencode-security.yml')
|
|
430
|
+
if (file === '.github/workflows/opencode-security.yml')
|
|
431
|
+
return ['true', 'auto'].includes(config.opencode_security ?? 'false')
|
|
299
432
|
const workflow = file.match(/^\.github\/workflows\/([^/]+)\.yml$/)?.[1]
|
|
300
433
|
// The staging promotion caller only exists in the staging-release topology;
|
|
301
434
|
// direct repositories open feature branches into main and need no promotion.
|
|
@@ -303,7 +436,10 @@ function shouldInclude(file, languages, features, config) {
|
|
|
303
436
|
// The tiered validation caller supersedes the legacy ci/test/security/codeql
|
|
304
437
|
// event callers, so legacy feature names keep selecting it.
|
|
305
438
|
if (workflow === 'validation') {
|
|
306
|
-
return
|
|
439
|
+
return (
|
|
440
|
+
includesValue(features, 'validation') ||
|
|
441
|
+
LEGACY_GENERATED_CALLERS.some((legacy) => includesValue(features, legacy))
|
|
442
|
+
)
|
|
307
443
|
}
|
|
308
444
|
return !workflow || includesValue(features, workflow)
|
|
309
445
|
}
|
|
@@ -332,14 +468,20 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
|
|
|
332
468
|
const localPrefix = 'uses: ./.github/workflows/'
|
|
333
469
|
const remotePrefix = `uses: ${repository}/.github/workflows/`
|
|
334
470
|
let rendered = content.replaceAll(localPrefix, remotePrefix)
|
|
335
|
-
rendered = rendered.replace(
|
|
471
|
+
rendered = rendered.replace(
|
|
472
|
+
new RegExp(`${escapeRegExp(remotePrefix)}([^\\s@]+)`, 'g'),
|
|
473
|
+
`$&@${ref}`
|
|
474
|
+
)
|
|
336
475
|
// Pin every runtime reference in the rendered caller: the orchestrator input
|
|
337
476
|
// in the `with:` block and the mode job's runtime checkout. Self templates
|
|
338
477
|
// use ${{ github.sha }} and are only rewritten when rendered for consumers.
|
|
339
478
|
rendered = rendered.replace(/^(\s+runtime-ref:)\s+.*$/gm, `$1 ${ref}`)
|
|
340
479
|
rendered = rendered.replace(/^(\s+ref:)\s+\$\{\{\s*github\.sha\s*\}\}\s*$/gm, `$1 ${ref}`)
|
|
341
480
|
rendered = rendered.replace(/^(\s+runtime-repository:)\s+.*$/gm, `$1 ${repository}`)
|
|
342
|
-
rendered = rendered.replace(
|
|
481
|
+
rendered = rendered.replace(
|
|
482
|
+
new RegExp(`^(\\s+repository:)\\s+0xPlayerOne\\/code-foundry\\s*$`, 'gm'),
|
|
483
|
+
`$1 ${repository}`
|
|
484
|
+
)
|
|
343
485
|
/** @type {Record<string, string|undefined>} */
|
|
344
486
|
const runners = {
|
|
345
487
|
ci: config.ci_runner ?? config.runner,
|
|
@@ -361,7 +503,10 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
|
|
|
361
503
|
// The draft PR caller states the PR base explicitly so the shared
|
|
362
504
|
// reusable workflow creates pull requests against the repository's
|
|
363
505
|
// configured integration branch (staging) or main (direct).
|
|
364
|
-
rendered = rendered.replace(
|
|
506
|
+
rendered = rendered.replace(
|
|
507
|
+
/^(\s+base:)\s+.*$/m,
|
|
508
|
+
`$1 ${isStagingRelease(config.git_workflow) ? 'staging' : 'main'}`
|
|
509
|
+
)
|
|
365
510
|
}
|
|
366
511
|
const runner = workflow ? runners[workflow] : undefined
|
|
367
512
|
if (runner) rendered = rendered.replace(/^(\s+runner:)\s+.*$/m, `$1 ${runner}`)
|
|
@@ -514,10 +659,7 @@ const DIRECT_DOC_REPLACEMENTS = {
|
|
|
514
659
|
'git switch staging\ngit pull --ff-only origin staging',
|
|
515
660
|
'git switch main\ngit pull --ff-only origin main',
|
|
516
661
|
],
|
|
517
|
-
[
|
|
518
|
-
'1. Start from an up-to-date `staging` branch.',
|
|
519
|
-
'1. Start from an up-to-date `main` branch.',
|
|
520
|
-
],
|
|
662
|
+
['1. Start from an up-to-date `staging` branch.', '1. Start from an up-to-date `main` branch.'],
|
|
521
663
|
[
|
|
522
664
|
'8. Push the branch and open a pull request into `staging`.',
|
|
523
665
|
'8. Push the branch and open a pull request into `main`.',
|
|
@@ -526,10 +668,7 @@ const DIRECT_DOC_REPLACEMENTS = {
|
|
|
526
668
|
'10. Merge with a squash after required checks pass and the change is ready; feature PRs land on `staging` with squash merges.',
|
|
527
669
|
'10. Merge with a squash after required checks pass and the change is ready; feature PRs land on `main` with squash merges.',
|
|
528
670
|
],
|
|
529
|
-
[
|
|
530
|
-
'3. Branch from the upstream `staging` branch.',
|
|
531
|
-
'3. Branch from the upstream `main` branch.',
|
|
532
|
-
],
|
|
671
|
+
['3. Branch from the upstream `staging` branch.', '3. Branch from the upstream `main` branch.'],
|
|
533
672
|
[
|
|
534
673
|
'7. Push to the fork and open a pull request targeting `staging`.',
|
|
535
674
|
'7. Push to the fork and open a pull request targeting `main`.',
|
|
@@ -542,20 +681,14 @@ const DIRECT_DOC_REPLACEMENTS = {
|
|
|
542
681
|
'| 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
682
|
'| 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
683
|
],
|
|
545
|
-
[
|
|
546
|
-
'1. Create a focused branch from `staging`.',
|
|
547
|
-
'1. Create a focused branch from `main`.',
|
|
548
|
-
],
|
|
684
|
+
['1. Create a focused branch from `staging`.', '1. Create a focused branch from `main`.'],
|
|
549
685
|
],
|
|
550
686
|
'.github/SECURITY.md': [
|
|
551
687
|
[
|
|
552
688
|
'The latest commit on `staging` receives security patches. Patches are promoted to `main` through the next release cycle.',
|
|
553
689
|
'The latest commit on `main` receives security patches.',
|
|
554
690
|
],
|
|
555
|
-
[
|
|
556
|
-
'| `staging` | ✅ |\n',
|
|
557
|
-
'',
|
|
558
|
-
],
|
|
691
|
+
['| `staging` | ✅ |\n', ''],
|
|
559
692
|
],
|
|
560
693
|
}
|
|
561
694
|
|
|
@@ -606,7 +739,9 @@ function validateRustCodeqlConfig(config) {
|
|
|
606
739
|
}
|
|
607
740
|
|
|
608
741
|
/** @param {string} value */
|
|
609
|
-
function escapeRegExp(value) {
|
|
742
|
+
function escapeRegExp(value) {
|
|
743
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
744
|
+
}
|
|
610
745
|
|
|
611
746
|
/**
|
|
612
747
|
* Recognize a Code Foundry-generated legacy event caller (the consumer copies
|
|
@@ -626,49 +761,112 @@ export function isGeneratedEventCaller(content, stem, runtimeRepository) {
|
|
|
626
761
|
if (/^\s*(runs-on|steps):/m.test(text)) return false
|
|
627
762
|
if (!text.includes('runtime-repository:')) return false
|
|
628
763
|
if (!new RegExp(`^ ${stem}:`, 'm').test(text)) return false
|
|
629
|
-
return new RegExp(
|
|
764
|
+
return new RegExp(
|
|
765
|
+
`uses:\\s*${escapeRegExp(runtimeRepository)}/\\.github/workflows/${stem}\\.yml@`
|
|
766
|
+
).test(text)
|
|
630
767
|
}
|
|
631
768
|
|
|
632
769
|
/** @param {string} baseline @param {string} existing */
|
|
633
770
|
function mergeGitignore(baseline, existing) {
|
|
634
771
|
const marker = '# Repository-specific rules'
|
|
635
|
-
const custom = existing.includes(marker)
|
|
772
|
+
const custom = existing.includes(marker)
|
|
773
|
+
? existing.slice(existing.indexOf(marker) + marker.length).trim()
|
|
774
|
+
: ''
|
|
636
775
|
return custom ? `${baseline.trimEnd()}\n\n${marker}\n${custom}\n` : baseline
|
|
637
776
|
}
|
|
638
777
|
|
|
639
|
-
/**
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
778
|
+
/**
|
|
779
|
+
* Merge the baseline Oxfmt config with the consumer's current config. The
|
|
780
|
+
* baseline owns every key except `ignorePatterns`, where consumer-specific
|
|
781
|
+
* patterns (added by sync migrations or by the repository) are preserved so
|
|
782
|
+
* repeated syncs never churn them.
|
|
783
|
+
* @param {string} baseline @param {string} existing @returns {string}
|
|
784
|
+
*/
|
|
785
|
+
function mergeOxfmtConfig(baseline, existing) {
|
|
786
|
+
/** @type {string[]} */
|
|
787
|
+
let extra = []
|
|
788
|
+
try {
|
|
789
|
+
const parsed = JSON.parse(existing)
|
|
790
|
+
if (Array.isArray(parsed.ignorePatterns)) extra = parsed.ignorePatterns
|
|
791
|
+
} catch {
|
|
792
|
+
return baseline
|
|
793
|
+
}
|
|
794
|
+
if (extra.length === 0) return baseline
|
|
795
|
+
let config
|
|
796
|
+
try {
|
|
797
|
+
config = JSON.parse(baseline)
|
|
798
|
+
} catch {
|
|
799
|
+
return baseline
|
|
800
|
+
}
|
|
801
|
+
const base = Array.isArray(config.ignorePatterns) ? config.ignorePatterns : []
|
|
802
|
+
const missing = extra.filter((pattern) => !base.includes(pattern))
|
|
803
|
+
if (missing.length === 0) return baseline
|
|
804
|
+
config.ignorePatterns = [...base, ...missing]
|
|
805
|
+
return `${JSON.stringify(config, null, 2)}\n`
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Ensure the consumer's .oxfmtrc.json carries every baseline ignore pattern
|
|
810
|
+
* (plus any migrated custom patterns). Missing patterns are appended to the
|
|
811
|
+
* existing list; the file is left untouched when nothing is missing or the
|
|
812
|
+
* config is absent/unparseable.
|
|
813
|
+
* @param {string} target @param {string[]} patterns @param {string[]} changed @param {boolean} dryRun
|
|
814
|
+
*/
|
|
815
|
+
function ensureOxfmtIgnorePatterns(target, patterns, changed, dryRun) {
|
|
816
|
+
const path = join(target, '.oxfmtrc.json')
|
|
817
|
+
if (patterns.length === 0 || !existsSync(path)) return
|
|
818
|
+
let config
|
|
819
|
+
try {
|
|
820
|
+
config = JSON.parse(readFileSync(path, 'utf8'))
|
|
821
|
+
} catch {
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
const existing = Array.isArray(config.ignorePatterns) ? config.ignorePatterns : []
|
|
825
|
+
const merged = [...existing]
|
|
826
|
+
for (const pattern of patterns) {
|
|
827
|
+
if (!merged.includes(pattern)) merged.push(pattern)
|
|
828
|
+
}
|
|
829
|
+
if (merged.length === existing.length) return
|
|
830
|
+
config.ignorePatterns = merged
|
|
831
|
+
changed.push('.oxfmtrc.json')
|
|
832
|
+
writeOrReport(path, `${JSON.stringify(config, null, 2)}\n`, dryRun)
|
|
647
833
|
}
|
|
648
834
|
|
|
649
835
|
/** @param {string} file @param {string} content */
|
|
650
836
|
function isLegacyManagedDoc(file, content) {
|
|
651
837
|
if (file === 'AGENTS.md') {
|
|
652
|
-
return
|
|
838
|
+
return (
|
|
839
|
+
content.includes('.github/scripts/bootstrap.sh') &&
|
|
840
|
+
content.includes('bash .github/scripts/ci.sh')
|
|
841
|
+
)
|
|
653
842
|
}
|
|
654
843
|
if (file === '.github/CONTRIBUTING.md') {
|
|
655
|
-
return
|
|
844
|
+
return (
|
|
845
|
+
content.includes('.github/scripts/bootstrap.sh') && content.includes('.github/template.yml')
|
|
846
|
+
)
|
|
656
847
|
}
|
|
657
848
|
return false
|
|
658
849
|
}
|
|
659
850
|
|
|
660
851
|
/** @param {string} target @param {string[]} args */
|
|
661
|
-
function git(target, args) {
|
|
852
|
+
function git(target, args) {
|
|
853
|
+
spawnSync('git', args, { cwd: target, stdio: 'ignore' })
|
|
854
|
+
}
|
|
662
855
|
|
|
663
856
|
/** @param {string} file @param {Buffer|string} content @param {boolean} dryRun */
|
|
664
857
|
function writeOrReport(file, content, dryRun) {
|
|
665
|
-
if (dryRun) {
|
|
858
|
+
if (dryRun) {
|
|
859
|
+
console.log(`Would sync ${file}`)
|
|
860
|
+
return
|
|
861
|
+
}
|
|
666
862
|
mkdirSync(dirname(file), { recursive: true })
|
|
667
863
|
writeFileSync(file, content)
|
|
668
864
|
}
|
|
669
865
|
|
|
670
866
|
/** @param {Buffer} a @param {Buffer} b */
|
|
671
|
-
function buffersEqual(a, b) {
|
|
867
|
+
function buffersEqual(a, b) {
|
|
868
|
+
return a.equals(b)
|
|
869
|
+
}
|
|
672
870
|
|
|
673
871
|
/** @param {string} root @param {string} source @returns {Record<string,string>} */
|
|
674
872
|
function createDefaultConfig(root, source) {
|
|
@@ -676,31 +874,61 @@ function createDefaultConfig(root, source) {
|
|
|
676
874
|
const packageManager = detectPackageManager(root)
|
|
677
875
|
const runners = recommendRunners(root)
|
|
678
876
|
return {
|
|
679
|
-
version: '1',
|
|
680
|
-
|
|
681
|
-
|
|
877
|
+
version: '1',
|
|
878
|
+
profile: detectProfile(root),
|
|
879
|
+
languages,
|
|
880
|
+
features: 'all',
|
|
881
|
+
codeql: 'auto',
|
|
882
|
+
dependency_review: 'auto',
|
|
883
|
+
package_manager: packageManager,
|
|
884
|
+
codeql_rust_shards: '["all"]',
|
|
885
|
+
codeql_rust_threads: '1',
|
|
886
|
+
codeql_rust_max_parallel: '1',
|
|
887
|
+
runtime_repository: '0xPlayerOne/code-foundry',
|
|
888
|
+
runtime_ref: `v${readPackageVersion(source)}`,
|
|
682
889
|
...runners,
|
|
683
|
-
toolchain: 'auto',
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
890
|
+
toolchain: 'auto',
|
|
891
|
+
staging_validation_mode: 'fast',
|
|
892
|
+
prune_standard: 'false',
|
|
893
|
+
cache_packages: 'auto',
|
|
894
|
+
cache_build: 'auto',
|
|
895
|
+
coverage_minimum: '80',
|
|
896
|
+
turbo_remote: 'auto',
|
|
897
|
+
release_type: detectPackageManager(root) === 'none' ? 'auto' : 'node',
|
|
898
|
+
npm_publish: 'false',
|
|
899
|
+
post_release: 'false',
|
|
900
|
+
post_release_workflow: '',
|
|
901
|
+
post_release_mode: 'auto',
|
|
902
|
+
opencode_security: 'false',
|
|
903
|
+
opencode_security_model: '',
|
|
904
|
+
sync_mode: 'overlay',
|
|
905
|
+
custom_workflows: 'preserve',
|
|
906
|
+
license: existsSync(join(root, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later',
|
|
907
|
+
git_workflow: 'direct',
|
|
908
|
+
merge_strategy: 'rebase',
|
|
909
|
+
release_merge_strategy: 'rebase',
|
|
690
910
|
}
|
|
691
911
|
}
|
|
692
912
|
|
|
693
913
|
/** @param {Record<string,string>} config */
|
|
694
|
-
function renderConfig(config) {
|
|
914
|
+
function renderConfig(config) {
|
|
915
|
+
return `${Object.entries(config)
|
|
916
|
+
.map(([key, value]) => renderConfigLine(key, value))
|
|
917
|
+
.join('\n')}\n`
|
|
918
|
+
}
|
|
695
919
|
/** @param {string} key @param {string} value */
|
|
696
920
|
function renderConfigLine(key, value) {
|
|
697
921
|
if (value === '') return `${key}:`
|
|
698
922
|
// Quote values that YAML would parse as collections or that would trip
|
|
699
|
-
//
|
|
700
|
-
const needsQuotes = /^[
|
|
923
|
+
// the formatter (e.g. codeql_rust_shards: '["all"]').
|
|
924
|
+
const needsQuotes = /^[[\]{]|[:,#]\s|\s#/.test(value)
|
|
701
925
|
return needsQuotes ? `${key}: '${value.replace(/'/g, "''")}'` : `${key}: ${value}`
|
|
702
926
|
}
|
|
703
927
|
/** @param {string} root */
|
|
704
928
|
function readPackageVersion(root) {
|
|
705
|
-
try {
|
|
929
|
+
try {
|
|
930
|
+
return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version ?? '0.0.0'
|
|
931
|
+
} catch {
|
|
932
|
+
return '0.0.0'
|
|
933
|
+
}
|
|
706
934
|
}
|