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/lib/config.mjs
CHANGED
|
@@ -16,7 +16,10 @@ export function readConfig(file) {
|
|
|
16
16
|
for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
17
17
|
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/)
|
|
18
18
|
if (!match) continue
|
|
19
|
-
const value = match[2]
|
|
19
|
+
const value = match[2]
|
|
20
|
+
.replace(/\s+#.*$/, '')
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/^['"]|['"]$/g, '')
|
|
20
23
|
config[match[1]] = value
|
|
21
24
|
}
|
|
22
25
|
return config
|
|
@@ -29,7 +32,10 @@ export function configured(value, fallback) {
|
|
|
29
32
|
|
|
30
33
|
/** @param {string} value @returns {string[]} */
|
|
31
34
|
export function listValue(value) {
|
|
32
|
-
return value
|
|
35
|
+
return value
|
|
36
|
+
.split(',')
|
|
37
|
+
.map((item) => item.trim())
|
|
38
|
+
.filter(Boolean)
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
/** @param {string} value @param {string} item */
|
|
@@ -18,21 +18,39 @@ export function doctorGithub(root) {
|
|
|
18
18
|
const rulesets = hydrateRulesets(repository, ghJson(['api', `repos/${repository}/rulesets`]))
|
|
19
19
|
const mainRulesets = rulesetsForBranch(rulesets, 'main')
|
|
20
20
|
const protection = ghJson(['api', `repos/${repository}/branches/main/protection`])
|
|
21
|
-
const required = mainRulesets.length
|
|
21
|
+
const required = mainRulesets.length
|
|
22
|
+
? requiredContextsFromRulesets(mainRulesets)
|
|
23
|
+
: requiredContextsFromProtection(protection)
|
|
22
24
|
details.requiredChecks = required
|
|
23
|
-
if (!protection && !mainRulesets.length)
|
|
24
|
-
|
|
25
|
+
if (!protection && !mainRulesets.length)
|
|
26
|
+
warnings.push(
|
|
27
|
+
'main branch protection or repository rulesets are not readable or are not configured.'
|
|
28
|
+
)
|
|
29
|
+
else if (!required.length)
|
|
30
|
+
warnings.push(
|
|
31
|
+
mainRulesets.length
|
|
32
|
+
? 'main ruleset has no required status checks.'
|
|
33
|
+
: 'main protection has no required status checks.'
|
|
34
|
+
)
|
|
25
35
|
const duplicateNames = required.filter((name) => /\b([^/]+) \/ \1\b/i.test(name))
|
|
26
|
-
if (duplicateNames.length)
|
|
36
|
+
if (duplicateNames.length)
|
|
37
|
+
errors.push(`required checks contain duplicate workflow prefixes: ${duplicateNames.join(', ')}`)
|
|
27
38
|
|
|
28
39
|
const sha = String(ghJson(['api', `repos/${repository}/git/ref/heads/main`])?.object?.sha ?? '')
|
|
29
|
-
const checks = sha
|
|
40
|
+
const checks = sha
|
|
41
|
+
? (ghJson(['api', `repos/${repository}/commits/${sha}/check-runs?per_page=100`])?.check_runs ??
|
|
42
|
+
[])
|
|
43
|
+
: []
|
|
30
44
|
const observed = checks.map(/** @param {any} check */ (check) => check.name).filter(Boolean)
|
|
31
45
|
details.observedChecks = observed
|
|
32
|
-
if (!observed.length)
|
|
46
|
+
if (!observed.length)
|
|
47
|
+
warnings.push(
|
|
48
|
+
'no check runs were observed on the current main commit; exact check validation is deferred until CI runs.'
|
|
49
|
+
)
|
|
33
50
|
if (required.length) {
|
|
34
51
|
const missing = required.filter((name) => observed.length && !observed.includes(name))
|
|
35
|
-
if (missing.length)
|
|
52
|
+
if (missing.length)
|
|
53
|
+
warnings.push(`required checks not observed on current main: ${missing.join(', ')}`)
|
|
36
54
|
}
|
|
37
55
|
|
|
38
56
|
const workflowIssues = inspectWorkflows(root)
|
|
@@ -40,34 +58,79 @@ export function doctorGithub(root) {
|
|
|
40
58
|
warnings.push(...workflowIssues.warnings)
|
|
41
59
|
|
|
42
60
|
const secrets = ghJson(['secret', 'list', '--repo', repository, '--json', 'name'])
|
|
43
|
-
const secretNames = Array.isArray(secrets)
|
|
61
|
+
const secretNames = Array.isArray(secrets)
|
|
62
|
+
? secrets.map(/** @param {any} secret */ (secret) => secret.name)
|
|
63
|
+
: []
|
|
44
64
|
details.secrets = {
|
|
45
65
|
codeFoundryTokenPresent: secretNames.includes('CODE_FOUNDRY_TOKEN'),
|
|
46
66
|
stagingDeployKeyPresent: secretNames.includes('STAGING_DEPLOY_KEY'),
|
|
47
67
|
}
|
|
48
68
|
if (!details.secrets.codeFoundryTokenPresent) {
|
|
49
|
-
warnings.push(
|
|
69
|
+
warnings.push(
|
|
70
|
+
'CODE_FOUNDRY_TOKEN is absent. PR workflow triggers and automation may require manual readiness.'
|
|
71
|
+
)
|
|
50
72
|
}
|
|
51
73
|
|
|
52
74
|
const config = readConfig(root)
|
|
53
|
-
if (
|
|
75
|
+
if (
|
|
76
|
+
['workflow-dispatch', 'dispatch'].includes(config.post_release_mode) &&
|
|
77
|
+
config.post_release !== 'false' &&
|
|
78
|
+
!details.secrets.codeFoundryTokenPresent &&
|
|
79
|
+
!details.secrets.releasePleaseTokenPresent
|
|
80
|
+
) {
|
|
54
81
|
errors.push('post-release workflow-dispatch mode requires CODE_FOUNDRY_TOKEN to be present.')
|
|
55
82
|
}
|
|
56
83
|
const credentialChecks = {
|
|
57
84
|
npmTokenPresent: secretNames.includes('NPM_TOKEN'),
|
|
58
85
|
turboTokenPresent: secretNames.includes('TURBO_TOKEN'),
|
|
59
|
-
turboTeamPresent: Boolean(
|
|
86
|
+
turboTeamPresent: Boolean(
|
|
87
|
+
ghJson(['variable', 'list', '--repo', repository, '--json', 'name'])?.some(
|
|
88
|
+
/** @param {{ name?: string }} variable */ (variable) => variable.name === 'TURBO_TEAM'
|
|
89
|
+
)
|
|
90
|
+
),
|
|
60
91
|
opencodeApiKeyPresent: secretNames.includes('OPENCODE_API_KEY'),
|
|
61
92
|
}
|
|
62
93
|
details.credentials = credentialChecks
|
|
63
|
-
if (config.npm_publish === 'true' && !credentialChecks.npmTokenPresent)
|
|
64
|
-
|
|
65
|
-
|
|
94
|
+
if (config.npm_publish === 'true' && !credentialChecks.npmTokenPresent)
|
|
95
|
+
warnings.push(
|
|
96
|
+
'npm_publish is enabled but NPM_TOKEN is not configured; npm trusted publishing must be configured for tokenless publication.'
|
|
97
|
+
)
|
|
98
|
+
if (
|
|
99
|
+
['true', 'auto'].includes(config.turbo_remote ?? 'false') &&
|
|
100
|
+
(!credentialChecks.turboTokenPresent || !credentialChecks.turboTeamPresent)
|
|
101
|
+
)
|
|
102
|
+
warnings.push(
|
|
103
|
+
'turbo_remote is enabled but TURBO_TOKEN and/or TURBO_TEAM is not configured; remote caching will be skipped.'
|
|
104
|
+
)
|
|
105
|
+
if (
|
|
106
|
+
['true', 'auto'].includes(config.opencode_security ?? 'false') &&
|
|
107
|
+
!credentialChecks.opencodeApiKeyPresent
|
|
108
|
+
)
|
|
109
|
+
warnings.push(
|
|
110
|
+
'opencode_security is enabled but OPENCODE_API_KEY is not configured; the optional scan will be skipped.'
|
|
111
|
+
)
|
|
66
112
|
|
|
67
|
-
const prs = ghJson([
|
|
113
|
+
const prs = ghJson([
|
|
114
|
+
'pr',
|
|
115
|
+
'list',
|
|
116
|
+
'--repo',
|
|
117
|
+
repository,
|
|
118
|
+
'--state',
|
|
119
|
+
'open',
|
|
120
|
+
'--base',
|
|
121
|
+
'main',
|
|
122
|
+
'--json',
|
|
123
|
+
'number,title,headRefName',
|
|
124
|
+
])
|
|
68
125
|
const openPrs = Array.isArray(prs) ? prs : []
|
|
69
|
-
details.openPromotionPrs = openPrs.filter(
|
|
70
|
-
|
|
126
|
+
details.openPromotionPrs = openPrs.filter(
|
|
127
|
+
/** @param {any} pr */ (pr) => pr.headRefName === 'staging' || /promote staging/i.test(pr.title)
|
|
128
|
+
)
|
|
129
|
+
details.openReleasePrs = openPrs.filter(
|
|
130
|
+
/** @param {any} pr */ (pr) =>
|
|
131
|
+
String(pr.headRefName).startsWith('release-please--') ||
|
|
132
|
+
pr.title.startsWith('chore(main): release ')
|
|
133
|
+
)
|
|
71
134
|
if (details.openPromotionPrs.length > 1) errors.push('multiple staging promotion PRs are open.')
|
|
72
135
|
if (details.openReleasePrs.length > 1) errors.push('multiple Release Please PRs are open.')
|
|
73
136
|
return { errors, warnings, details }
|
|
@@ -75,23 +138,33 @@ export function doctorGithub(root) {
|
|
|
75
138
|
|
|
76
139
|
/** @param {any} protection @returns {string[]} */
|
|
77
140
|
function requiredContextsFromProtection(protection) {
|
|
78
|
-
return [
|
|
79
|
-
...(
|
|
80
|
-
|
|
81
|
-
|
|
141
|
+
return [
|
|
142
|
+
...new Set(
|
|
143
|
+
[
|
|
144
|
+
...(protection?.required_status_checks?.contexts ?? []),
|
|
145
|
+
...(protection?.required_status_checks?.checks ?? []).map(
|
|
146
|
+
/** @param {any} check */ (check) => check.context
|
|
147
|
+
),
|
|
148
|
+
].filter(Boolean)
|
|
149
|
+
),
|
|
150
|
+
]
|
|
82
151
|
}
|
|
83
152
|
|
|
84
153
|
/** @param {any[]} rulesets @returns {string[]} */
|
|
85
154
|
function requiredContextsFromRulesets(rulesets) {
|
|
86
|
-
return [
|
|
87
|
-
|
|
88
|
-
(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
155
|
+
return [
|
|
156
|
+
...new Set(
|
|
157
|
+
(Array.isArray(rulesets) ? rulesets : []).flatMap((/** @type {any} */ ruleset) =>
|
|
158
|
+
(ruleset.rules ?? []).flatMap((/** @type {any} */ rule) =>
|
|
159
|
+
rule?.type === 'required_status_checks'
|
|
160
|
+
? (rule.parameters?.required_status_checks ?? [])
|
|
161
|
+
.map((/** @type {any} */ check) => check?.context)
|
|
162
|
+
.filter(Boolean)
|
|
163
|
+
: []
|
|
164
|
+
)
|
|
165
|
+
)
|
|
93
166
|
),
|
|
94
|
-
|
|
167
|
+
]
|
|
95
168
|
}
|
|
96
169
|
|
|
97
170
|
/** @param {string} repository @param {any[] | null} rulesets @returns {any[]} */
|
|
@@ -107,13 +180,14 @@ function hydrateRulesets(repository, rulesets) {
|
|
|
107
180
|
/** @param {any[] | null} rulesets @param {string} branch @returns {any[]} */
|
|
108
181
|
function rulesetsForBranch(rulesets, branch) {
|
|
109
182
|
if (!Array.isArray(rulesets)) return []
|
|
110
|
-
return rulesets.filter(
|
|
111
|
-
ruleset
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
183
|
+
return rulesets.filter(
|
|
184
|
+
(ruleset) =>
|
|
185
|
+
ruleset?.target === 'branch' &&
|
|
186
|
+
Array.isArray(ruleset?.conditions?.ref_name?.include) &&
|
|
187
|
+
Array.isArray(ruleset?.conditions?.ref_name?.exclude) &&
|
|
188
|
+
includesBranch(ruleset.conditions.ref_name.include, branch) &&
|
|
189
|
+
!includesBranch(ruleset.conditions.ref_name.exclude, branch)
|
|
190
|
+
)
|
|
117
191
|
}
|
|
118
192
|
|
|
119
193
|
/** @param {(string | null | undefined)[]} patterns @param {string} branch @returns {boolean} */
|
|
@@ -123,8 +197,11 @@ function includesBranch(patterns, branch) {
|
|
|
123
197
|
if (!pattern) return false
|
|
124
198
|
if (pattern === '~ALL') return true
|
|
125
199
|
if (pattern === target || pattern === branch) return true
|
|
126
|
-
if (!/[
|
|
127
|
-
const escaped = pattern
|
|
200
|
+
if (!/[*?]/.test(pattern)) return false
|
|
201
|
+
const escaped = pattern
|
|
202
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
203
|
+
.replace(/\\\*/g, '.*')
|
|
204
|
+
.replace(/\\\?/g, '.')
|
|
128
205
|
const regex = new RegExp(`^${escaped}$`)
|
|
129
206
|
return regex.test(target) || regex.test(branch)
|
|
130
207
|
})
|
|
@@ -139,12 +216,21 @@ function inspectWorkflows(root) {
|
|
|
139
216
|
const directory = join(root, '.github/workflows')
|
|
140
217
|
/** @type {string[]} */
|
|
141
218
|
let files = []
|
|
142
|
-
try {
|
|
143
|
-
|
|
219
|
+
try {
|
|
220
|
+
files = readdirSync(directory).filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'))
|
|
221
|
+
} catch {
|
|
222
|
+
return { errors: ['.github/workflows is missing.'], warnings }
|
|
223
|
+
}
|
|
144
224
|
for (const file of files) {
|
|
145
225
|
const content = readFileSync(join(directory, file), 'utf8')
|
|
146
|
-
if (!/^permissions:\s*$/m.test(content) && /uses:.*\.github\/workflows\//.test(content))
|
|
147
|
-
|
|
226
|
+
if (!/^permissions:\s*$/m.test(content) && /uses:.*\.github\/workflows\//.test(content))
|
|
227
|
+
warnings.push(`${file} does not declare top-level permissions.`)
|
|
228
|
+
if (
|
|
229
|
+
file === 'release.yml' &&
|
|
230
|
+
/contents:\s+write/.test(content) &&
|
|
231
|
+
!/pull-requests:\s+write/.test(content)
|
|
232
|
+
)
|
|
233
|
+
errors.push('release.yml needs pull-requests: write for guarded Release Please PR handling.')
|
|
148
234
|
}
|
|
149
235
|
return { errors, warnings }
|
|
150
236
|
}
|
|
@@ -153,27 +239,43 @@ function inspectWorkflows(root) {
|
|
|
153
239
|
function remoteRepository(root) {
|
|
154
240
|
const result = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8' })
|
|
155
241
|
const value = result.status === 0 ? result.stdout.trim() : ''
|
|
156
|
-
return value
|
|
242
|
+
return value
|
|
243
|
+
.replace(/^git@github\.com:/, '')
|
|
244
|
+
.replace(/^https?:\/\/github\.com\//, '')
|
|
245
|
+
.replace(/\.git$/, '')
|
|
157
246
|
}
|
|
158
247
|
|
|
159
248
|
/** @param {string[]} args @returns {any} */
|
|
160
249
|
function ghJson(args) {
|
|
161
250
|
const result = spawnSync('gh', args, { encoding: 'utf8' })
|
|
162
251
|
if (result.status !== 0) return null
|
|
163
|
-
try {
|
|
164
|
-
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(result.stdout)
|
|
254
|
+
} catch {
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
165
257
|
}
|
|
166
258
|
|
|
167
259
|
/** @param {string} command */
|
|
168
|
-
function commandExists(command) {
|
|
260
|
+
function commandExists(command) {
|
|
261
|
+
return spawnSync(command, ['--version'], { stdio: 'ignore' }).status === 0
|
|
262
|
+
}
|
|
169
263
|
|
|
170
264
|
/** @param {string} root @returns {Record<string, string>} */
|
|
171
265
|
function readConfig(root) {
|
|
172
266
|
const file = join(root, '.github/code-foundry.yml')
|
|
173
267
|
try {
|
|
174
|
-
return Object.fromEntries(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
268
|
+
return Object.fromEntries(
|
|
269
|
+
readFileSync(file, 'utf8')
|
|
270
|
+
.split(/\r?\n/)
|
|
271
|
+
.flatMap((line) => {
|
|
272
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/)
|
|
273
|
+
return match
|
|
274
|
+
? [[match[1], match[2].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '')]]
|
|
275
|
+
: []
|
|
276
|
+
})
|
|
277
|
+
)
|
|
278
|
+
} catch {
|
|
279
|
+
return {}
|
|
280
|
+
}
|
|
179
281
|
}
|
package/src/lib/overlay.mjs
CHANGED
|
@@ -23,7 +23,11 @@ export function customWorkflowFiles(root, standardFiles) {
|
|
|
23
23
|
export function overlayPolicy(root, config) {
|
|
24
24
|
const mode = config.sync_mode ?? 'overlay'
|
|
25
25
|
const custom = config.custom_workflows ?? 'preserve'
|
|
26
|
-
if (!['overlay', 'strict'].includes(mode))
|
|
27
|
-
|
|
26
|
+
if (!['overlay', 'strict'].includes(mode))
|
|
27
|
+
throw new Error(`Unsupported sync_mode: ${mode}; use overlay or strict.`)
|
|
28
|
+
if (custom !== 'preserve')
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Unsupported custom_workflows: ${custom}; custom workflows are always preserved.`
|
|
31
|
+
)
|
|
28
32
|
return { mode, custom_workflows: custom }
|
|
29
33
|
}
|
package/src/lib/profile.mjs
CHANGED
|
@@ -47,15 +47,19 @@ export function detectLanguages(root) {
|
|
|
47
47
|
if (
|
|
48
48
|
existsSync(join(root, 'package.json')) ||
|
|
49
49
|
files.some((file) => /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/.test(file)) ||
|
|
50
|
-
files.some((file) =>
|
|
51
|
-
|
|
50
|
+
files.some((file) =>
|
|
51
|
+
/(?:bun\.lockb?|pnpm-lock\.yaml|yarn\.lock|package-lock\.json)$/.test(file)
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
languages.push('typescript')
|
|
52
55
|
if (existsSync(join(root, 'Cargo.toml')) || hasSource(root, /\.rs$/)) languages.push('rust')
|
|
53
56
|
if (
|
|
54
57
|
existsSync(join(root, 'pyproject.toml')) ||
|
|
55
58
|
existsSync(join(root, 'requirements.txt')) ||
|
|
56
59
|
existsSync(join(root, 'requirements-dev.txt')) ||
|
|
57
60
|
hasSource(root, /\.py$/)
|
|
58
|
-
)
|
|
61
|
+
)
|
|
62
|
+
languages.push('python')
|
|
59
63
|
if (files.some((file) => file.endsWith('.sol'))) languages.push('solidity')
|
|
60
64
|
return languages.length ? languages : ['none']
|
|
61
65
|
}
|
|
@@ -72,16 +76,23 @@ export function detectPackageManager(root) {
|
|
|
72
76
|
|
|
73
77
|
/** @param {string} root */
|
|
74
78
|
export function detectProfile(root) {
|
|
75
|
-
if (existsSync(join(root, 'turbo.json')) || existsSync(join(root, 'pnpm-workspace.yaml')))
|
|
79
|
+
if (existsSync(join(root, 'turbo.json')) || existsSync(join(root, 'pnpm-workspace.yaml')))
|
|
80
|
+
return 'monorepo'
|
|
76
81
|
if (existsSync(join(root, 'package.json'))) {
|
|
77
82
|
try {
|
|
78
83
|
const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
79
|
-
if (Array.isArray(packageJson.workspaces) || typeof packageJson.workspaces === 'object')
|
|
84
|
+
if (Array.isArray(packageJson.workspaces) || typeof packageJson.workspaces === 'object')
|
|
85
|
+
return 'monorepo'
|
|
80
86
|
} catch {
|
|
81
87
|
// Doctor reports malformed package.json separately.
|
|
82
88
|
}
|
|
83
89
|
}
|
|
84
|
-
if (
|
|
90
|
+
if (
|
|
91
|
+
existsSync(join(root, 'package.json')) ||
|
|
92
|
+
existsSync(join(root, 'pyproject.toml')) ||
|
|
93
|
+
existsSync(join(root, 'Cargo.toml'))
|
|
94
|
+
)
|
|
95
|
+
return 'application'
|
|
85
96
|
return 'minimal'
|
|
86
97
|
}
|
|
87
98
|
|
|
@@ -110,7 +121,9 @@ export function resolveProfile(root) {
|
|
|
110
121
|
*/
|
|
111
122
|
export function recommendRunners(root) {
|
|
112
123
|
const languages = detectLanguages(root)
|
|
113
|
-
const heavy =
|
|
124
|
+
const heavy =
|
|
125
|
+
languages.some((language) => ['rust', 'python', 'solidity'].includes(language)) ||
|
|
126
|
+
hasBrowserProject(root)
|
|
114
127
|
return {
|
|
115
128
|
runner: heavy ? 'ubuntu-latest' : 'ubuntu-slim',
|
|
116
129
|
ci_runner: heavy ? 'ubuntu-latest' : 'ubuntu-slim',
|
|
@@ -125,7 +138,12 @@ export function recommendRunners(root) {
|
|
|
125
138
|
|
|
126
139
|
/** @param {string} root */
|
|
127
140
|
function hasBrowserProject(root) {
|
|
128
|
-
return [
|
|
141
|
+
return [
|
|
142
|
+
'playwright.config.ts',
|
|
143
|
+
'playwright.config.js',
|
|
144
|
+
'cypress.config.ts',
|
|
145
|
+
'cypress.config.js',
|
|
146
|
+
].some((file) => existsSync(join(root, file)))
|
|
129
147
|
}
|
|
130
148
|
|
|
131
149
|
/** @param {string} root */
|
package/src/lib/release-hook.mjs
CHANGED
|
@@ -11,16 +11,26 @@ import { createHash } from 'node:crypto'
|
|
|
11
11
|
*/
|
|
12
12
|
export function selectHookDelivery(input) {
|
|
13
13
|
const mode = input.mode ?? 'auto'
|
|
14
|
-
if (mode === 'false' || mode === 'disabled')
|
|
14
|
+
if (mode === 'false' || mode === 'disabled')
|
|
15
|
+
return { delivery: 'disabled', reason: 'Post-release hooks are disabled by configuration.' }
|
|
15
16
|
if (mode === 'workflow-dispatch' || (mode === 'auto' && input.tokenPresent)) {
|
|
16
17
|
return input.tokenPresent
|
|
17
|
-
? {
|
|
18
|
+
? {
|
|
19
|
+
delivery: 'workflow-dispatch',
|
|
20
|
+
reason: 'A PAT is available; use one explicit workflow dispatch.',
|
|
21
|
+
}
|
|
18
22
|
: { delivery: 'unavailable', reason: 'workflow-dispatch requires a PAT.' }
|
|
19
23
|
}
|
|
20
24
|
if (mode === 'release-event' || (mode === 'auto' && input.releaseEventEnabled !== false)) {
|
|
21
|
-
return {
|
|
25
|
+
return {
|
|
26
|
+
delivery: 'release-event',
|
|
27
|
+
reason: 'Use the published-release event; do not issue a fallback dispatch.',
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
delivery: 'unavailable',
|
|
32
|
+
reason: 'No supported post-release delivery mechanism is configured.',
|
|
22
33
|
}
|
|
23
|
-
return { delivery: 'unavailable', reason: 'No supported post-release delivery mechanism is configured.' }
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
/** @param {string} repository @param {string} tag @returns {string} */
|
|
@@ -36,5 +46,7 @@ export function releaseDeliveryKey(repository, tag) {
|
|
|
36
46
|
* @param {string} tag
|
|
37
47
|
*/
|
|
38
48
|
export function hasDeliveredHook(runs, tag) {
|
|
39
|
-
return runs.some(
|
|
49
|
+
return runs.some(
|
|
50
|
+
(run) => run.headBranch === tag || run.displayTitle?.includes(`release-tag=${tag}`)
|
|
51
|
+
)
|
|
40
52
|
}
|
|
@@ -3,7 +3,27 @@
|
|
|
3
3
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
|
|
6
|
-
const ignored = new Set([
|
|
6
|
+
const ignored = new Set([
|
|
7
|
+
'.git',
|
|
8
|
+
'.code-foundry',
|
|
9
|
+
'.venv',
|
|
10
|
+
'node_modules',
|
|
11
|
+
'target',
|
|
12
|
+
'vendor',
|
|
13
|
+
'dist',
|
|
14
|
+
'build',
|
|
15
|
+
'.next',
|
|
16
|
+
'.kilo',
|
|
17
|
+
'.turbo',
|
|
18
|
+
'.cache',
|
|
19
|
+
'.vercel',
|
|
20
|
+
'.output',
|
|
21
|
+
'.nuxt',
|
|
22
|
+
'.svelte-kit',
|
|
23
|
+
'.parcel-cache',
|
|
24
|
+
'out',
|
|
25
|
+
'coverage',
|
|
26
|
+
])
|
|
7
27
|
|
|
8
28
|
/** @typedef {{ directory: string, manifest: string, releaseType: 'node'|'python'|'rust', packageName?: string, extraFiles: string[] }} ReleasePackage */
|
|
9
29
|
|
|
@@ -16,11 +36,29 @@ export function detectReleasePackages(root) {
|
|
|
16
36
|
const directory = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '.'
|
|
17
37
|
if (name === 'package.json') {
|
|
18
38
|
const parsed = readJson(join(root, file))
|
|
19
|
-
packages.push({
|
|
39
|
+
packages.push({
|
|
40
|
+
directory,
|
|
41
|
+
manifest: file,
|
|
42
|
+
releaseType: 'node',
|
|
43
|
+
packageName: typeof parsed?.name === 'string' ? parsed.name : undefined,
|
|
44
|
+
extraFiles: detectExtraFiles(root, directory),
|
|
45
|
+
})
|
|
20
46
|
} else if (name === 'Cargo.toml') {
|
|
21
|
-
packages.push({
|
|
47
|
+
packages.push({
|
|
48
|
+
directory,
|
|
49
|
+
manifest: file,
|
|
50
|
+
releaseType: 'rust',
|
|
51
|
+
packageName: tomlPackageName(join(root, file)),
|
|
52
|
+
extraFiles: detectExtraFiles(root, directory),
|
|
53
|
+
})
|
|
22
54
|
} else if (name === 'pyproject.toml') {
|
|
23
|
-
packages.push({
|
|
55
|
+
packages.push({
|
|
56
|
+
directory,
|
|
57
|
+
manifest: file,
|
|
58
|
+
releaseType: 'python',
|
|
59
|
+
packageName: pyprojectName(join(root, file)),
|
|
60
|
+
extraFiles: detectExtraFiles(root, directory),
|
|
61
|
+
})
|
|
24
62
|
}
|
|
25
63
|
})
|
|
26
64
|
return packages.sort((a, b) => a.directory.localeCompare(b.directory))
|
|
@@ -45,19 +83,22 @@ export function buildReleaseConfig(root, existing = {}) {
|
|
|
45
83
|
const extra = Array.isArray(result['extra-files']) ? result['extra-files'] : []
|
|
46
84
|
result['extra-files'] = extra
|
|
47
85
|
for (const file of packages[0].extraFiles) {
|
|
48
|
-
if (!extra.some((entry) => (typeof entry === 'string' ? entry : entry?.path) === file))
|
|
86
|
+
if (!extra.some((entry) => (typeof entry === 'string' ? entry : entry?.path) === file))
|
|
87
|
+
extra.push(file)
|
|
49
88
|
}
|
|
50
89
|
return result
|
|
51
90
|
}
|
|
52
91
|
/** @type {Record<string, any>} */
|
|
53
|
-
const configuredPackages = { ...
|
|
92
|
+
const configuredPackages = { ...result.packages }
|
|
54
93
|
for (const entry of packages) {
|
|
55
|
-
const current = { ...
|
|
94
|
+
const current = { ...configuredPackages[entry.directory] }
|
|
56
95
|
current['release-type'] ??= entry.releaseType
|
|
57
96
|
if (entry.packageName) current['package-name'] ??= entry.packageName
|
|
58
97
|
/** @type {any[]} */
|
|
59
98
|
const extra = Array.isArray(current['extra-files']) ? [...current['extra-files']] : []
|
|
60
|
-
for (const file of entry.extraFiles)
|
|
99
|
+
for (const file of entry.extraFiles)
|
|
100
|
+
if (!extra.some((item) => (typeof item === 'string' ? item : item?.path) === file))
|
|
101
|
+
extra.push(file)
|
|
61
102
|
if (extra.length) current['extra-files'] = extra
|
|
62
103
|
configuredPackages[entry.directory] = current
|
|
63
104
|
}
|
|
@@ -75,9 +116,12 @@ export function buildReleaseConfig(root, existing = {}) {
|
|
|
75
116
|
* @returns {Record<string, string>|null}
|
|
76
117
|
*/
|
|
77
118
|
export function buildReleaseManifest(root, config = {}) {
|
|
78
|
-
const packages =
|
|
79
|
-
|
|
80
|
-
|
|
119
|
+
const packages =
|
|
120
|
+
config.packages && typeof config.packages === 'object' && !Array.isArray(config.packages)
|
|
121
|
+
? config.packages
|
|
122
|
+
: config['release-type']
|
|
123
|
+
? { '.': {} }
|
|
124
|
+
: null
|
|
81
125
|
if (!packages || !Object.keys(packages).length) return null
|
|
82
126
|
/** @type {Record<string, string>} */
|
|
83
127
|
const manifest = {}
|
|
@@ -94,7 +138,9 @@ function readDirectoryVersion(directory) {
|
|
|
94
138
|
try {
|
|
95
139
|
const parsed = JSON.parse(readFileSync(packagePath, 'utf8'))
|
|
96
140
|
if (typeof parsed?.version === 'string' && parsed.version) return parsed.version
|
|
97
|
-
} catch {
|
|
141
|
+
} catch {
|
|
142
|
+
/* fall through to other manifests */
|
|
143
|
+
}
|
|
98
144
|
}
|
|
99
145
|
for (const file of ['pyproject.toml', 'Cargo.toml']) {
|
|
100
146
|
const path = join(directory, file)
|
|
@@ -110,13 +156,17 @@ export function validateReleaseConfig(root, config) {
|
|
|
110
156
|
const detected = detectReleasePackages(root)
|
|
111
157
|
if (!detected.length) return []
|
|
112
158
|
const configured = config.packages && typeof config.packages === 'object' ? config.packages : null
|
|
113
|
-
if (detected.length > 1 && !configured)
|
|
159
|
+
if (detected.length > 1 && !configured)
|
|
160
|
+
return [
|
|
161
|
+
'mixed-language or multi-package repositories require release-please packages configuration',
|
|
162
|
+
]
|
|
114
163
|
if (!configured) return []
|
|
115
164
|
const errors = []
|
|
116
165
|
for (const entry of detected) {
|
|
117
166
|
const item = configured[entry.directory]
|
|
118
167
|
if (!item) errors.push(`release-please packages is missing ${entry.directory}`)
|
|
119
|
-
else if (item['release-type'] !== entry.releaseType)
|
|
168
|
+
else if (item['release-type'] !== entry.releaseType)
|
|
169
|
+
errors.push(`${entry.directory} release-type should be ${entry.releaseType}`)
|
|
120
170
|
}
|
|
121
171
|
return errors
|
|
122
172
|
}
|
|
@@ -137,17 +187,29 @@ function walk(root, visit) {
|
|
|
137
187
|
|
|
138
188
|
/** @param {string} root @param {string} directory @returns {string[]} */
|
|
139
189
|
function detectExtraFiles(root, directory) {
|
|
140
|
-
const candidates = [
|
|
190
|
+
const candidates = [
|
|
191
|
+
'version.txt',
|
|
192
|
+
'VERSION',
|
|
193
|
+
'.version',
|
|
194
|
+
'version.json',
|
|
195
|
+
'src/version.ts',
|
|
196
|
+
'src/version.js',
|
|
197
|
+
'src/version.py',
|
|
198
|
+
'src/version.rs',
|
|
199
|
+
]
|
|
141
200
|
return candidates
|
|
142
|
-
.map((file) => directory === '.' ? file : `${directory}/${file}`)
|
|
201
|
+
.map((file) => (directory === '.' ? file : `${directory}/${file}`))
|
|
143
202
|
.filter((file) => existsSync(join(root, file)))
|
|
144
|
-
.map((file) => directory === '.' ? file : file.slice(directory.length + 1))
|
|
203
|
+
.map((file) => (directory === '.' ? file : file.slice(directory.length + 1)))
|
|
145
204
|
}
|
|
146
205
|
|
|
147
206
|
/** @param {string} file @returns {any} */
|
|
148
207
|
function readJson(file) {
|
|
149
|
-
try {
|
|
150
|
-
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
210
|
+
} catch {
|
|
211
|
+
return {}
|
|
212
|
+
}
|
|
151
213
|
}
|
|
152
214
|
|
|
153
215
|
/** @param {string} file @returns {string|undefined} */
|