@entro314labs/release-kit 2.6.0 → 2.7.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/README.md +39 -6
- package/TRAIN.md +332 -0
- package/package.json +5 -2
- package/release.mjs +140 -71
- package/train.mjs +1241 -0
package/train.mjs
ADDED
|
@@ -0,0 +1,1241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* release-train — orchestrated releases for interdependent packages, prototype.
|
|
4
|
+
*
|
|
5
|
+
* Implements the read-only phases of TRAIN.md — discover → graph → detect changes →
|
|
6
|
+
* cascade → plan → preflight — plus `seed-tags`, which establishes baseline release tags.
|
|
7
|
+
* Execution (releasing via release-kit) is not implemented yet; `train` without
|
|
8
|
+
* --dry-run says so and exits.
|
|
9
|
+
*
|
|
10
|
+
* train graph print the derived dependency graph and topo order
|
|
11
|
+
* train --dry-run full plan + whole-train preflight, execute nothing
|
|
12
|
+
* train --dry-run --all plan every member, not just changed ones
|
|
13
|
+
* train --dry-run <id>... plan these packages and their dependents
|
|
14
|
+
* train seed-tags create baseline tags at each repo's HEAD (--dry-run to preview)
|
|
15
|
+
* train --offline skip network work (registry lookups, tag pushes)
|
|
16
|
+
* train --config <path> config elsewhere than ./train.config.json
|
|
17
|
+
*
|
|
18
|
+
* Reads train.config.json in the working directory. Config declares membership and
|
|
19
|
+
* policy only; versions, dependencies and order are derived from the package manifests —
|
|
20
|
+
* see TRAIN.md for why.
|
|
21
|
+
*
|
|
22
|
+
* A package's next version is derived, never guessed, from three sources in this order:
|
|
23
|
+
* 1. The registry. A manifest version that is not published — a pending bump that was
|
|
24
|
+
* committed but never released, or a package that has never been published — is
|
|
25
|
+
* released **as-is**: the pending version is the release, nothing is skipped over.
|
|
26
|
+
* A manifest *behind* the registry is a preflight failure, not a guess.
|
|
27
|
+
* 2. The commits since the package's last release tag, by Conventional Commit rules
|
|
28
|
+
* (feat → minor, breaking → major, softened below 1.0.0).
|
|
29
|
+
* 3. The cascade: a dependent of a releasing package joins with at least a patch.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { execFileSync } from 'node:child_process'
|
|
33
|
+
import {
|
|
34
|
+
existsSync,
|
|
35
|
+
mkdtempSync,
|
|
36
|
+
readFileSync,
|
|
37
|
+
readdirSync,
|
|
38
|
+
statSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
} from 'node:fs'
|
|
41
|
+
import { tmpdir } from 'node:os'
|
|
42
|
+
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
43
|
+
import { pathToFileURL } from 'node:url'
|
|
44
|
+
|
|
45
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
// Small utilities
|
|
47
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
function fail(message) {
|
|
50
|
+
console.error(`train: ${message}`)
|
|
51
|
+
process.exit(1)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function git(repoDir, args, { allowFailure = false } = {}) {
|
|
55
|
+
try {
|
|
56
|
+
return execFileSync('git', args, {
|
|
57
|
+
cwd: repoDir,
|
|
58
|
+
encoding: 'utf8',
|
|
59
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
60
|
+
}).trim()
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (allowFailure) return null
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readJson(path) {
|
|
68
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
// Semver — the subset the train needs (release-kit owns full semver per package)
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export function parseSemver(version) {
|
|
76
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version ?? '')
|
|
77
|
+
if (!match) return null
|
|
78
|
+
return { major: +match[1], minor: +match[2], patch: +match[3], prerelease: match[4] ?? null }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function compareSemver(a, b) {
|
|
82
|
+
const pa = parseSemver(a)
|
|
83
|
+
const pb = parseSemver(b)
|
|
84
|
+
if (!pa || !pb) return 0
|
|
85
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
86
|
+
if (pa[key] !== pb[key]) return pa[key] - pb[key]
|
|
87
|
+
}
|
|
88
|
+
if (pa.prerelease && !pb.prerelease) return -1
|
|
89
|
+
if (!pa.prerelease && pb.prerelease) return 1
|
|
90
|
+
return 0
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function bumpSemver(version, bump) {
|
|
94
|
+
const v = parseSemver(version)
|
|
95
|
+
if (!v) return null
|
|
96
|
+
if (bump === 'as-is') return version
|
|
97
|
+
if (bump === 'major') return `${v.major + 1}.0.0`
|
|
98
|
+
if (bump === 'minor') return `${v.major}.${v.minor + 1}.0`
|
|
99
|
+
return `${v.major}.${v.minor}.${v.patch + 1}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Conventional Commit derivation over full messages: feat → minor, a breaking change
|
|
104
|
+
* (`!` in the type or a BREAKING CHANGE footer anywhere in the body) → major, softened
|
|
105
|
+
* to minor below 1.0.0, else patch.
|
|
106
|
+
*/
|
|
107
|
+
export function bumpFromCommits(messages, currentVersion) {
|
|
108
|
+
let bump = 'patch'
|
|
109
|
+
const below1 = parseSemver(currentVersion)?.major === 0
|
|
110
|
+
for (const message of messages) {
|
|
111
|
+
const subject = message.split('\n', 1)[0]
|
|
112
|
+
if (/^[a-z]+(\([^)]*\))?!:/.test(subject) || /BREAKING CHANGE/.test(message)) {
|
|
113
|
+
return below1 ? 'minor' : 'major'
|
|
114
|
+
}
|
|
115
|
+
if (/^feat(\([^)]*\))?:/.test(subject)) bump = 'minor'
|
|
116
|
+
}
|
|
117
|
+
return bump
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
// Configuration and argument parsing
|
|
122
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
const CONFIG_KEYS = new Set([
|
|
125
|
+
'$schema',
|
|
126
|
+
'packages',
|
|
127
|
+
'rangePolicy',
|
|
128
|
+
'registryWait',
|
|
129
|
+
'summaryFile',
|
|
130
|
+
'assistant',
|
|
131
|
+
])
|
|
132
|
+
const RANGE_POLICIES = new Set(['caret', 'tilde', 'exact', 'preserve'])
|
|
133
|
+
const ASSISTANT_NAMES = new Set(['none', 'auto', 'claude', 'codex'])
|
|
134
|
+
|
|
135
|
+
/** Normalize an assistant spec (a name, "auto", "none", or { tool, model, effort }). */
|
|
136
|
+
export function normalizeAssistant(spec) {
|
|
137
|
+
if (spec === null || spec === undefined || spec === 'none') return null
|
|
138
|
+
const object = typeof spec === 'string' ? { tool: spec } : spec
|
|
139
|
+
if (
|
|
140
|
+
typeof object.tool !== 'string' ||
|
|
141
|
+
!ASSISTANT_NAMES.has(object.tool) ||
|
|
142
|
+
object.tool === 'none'
|
|
143
|
+
) {
|
|
144
|
+
return {
|
|
145
|
+
error: `assistant must be one of: ${[...ASSISTANT_NAMES].join(', ')}, or { tool, model, effort }`,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { tool: object.tool, model: object.model ?? null, effort: object.effort ?? null }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function loadConfig(configPath) {
|
|
152
|
+
if (!existsSync(configPath)) fail(`no ${basename(configPath)} found at ${configPath}`)
|
|
153
|
+
const config = readJson(configPath)
|
|
154
|
+
const unknown = Object.keys(config).filter((key) => !CONFIG_KEYS.has(key))
|
|
155
|
+
if (unknown.length)
|
|
156
|
+
fail(`unknown config key${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`)
|
|
157
|
+
if (!Array.isArray(config.packages) || config.packages.length === 0) {
|
|
158
|
+
fail('config must declare a non-empty "packages" array')
|
|
159
|
+
}
|
|
160
|
+
const rangePolicy = config.rangePolicy ?? 'caret'
|
|
161
|
+
if (!RANGE_POLICIES.has(rangePolicy))
|
|
162
|
+
fail(`rangePolicy must be one of: ${[...RANGE_POLICIES].join(', ')}`)
|
|
163
|
+
const registryWait = { timeout: 300, interval: 5, ...config.registryWait }
|
|
164
|
+
const assistant = normalizeAssistant(config.assistant ?? null)
|
|
165
|
+
if (assistant?.error) fail(assistant.error)
|
|
166
|
+
const summaryFile = config.summaryFile ?? null
|
|
167
|
+
if (summaryFile !== null && typeof summaryFile !== 'string')
|
|
168
|
+
fail('summaryFile must be a path or null')
|
|
169
|
+
return { packages: config.packages, rangePolicy, registryWait, summaryFile, assistant }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const KNOWN_FLAGS = new Set([
|
|
173
|
+
'--dry-run',
|
|
174
|
+
'--all',
|
|
175
|
+
'--offline',
|
|
176
|
+
'--config',
|
|
177
|
+
'--summary',
|
|
178
|
+
'--assistant',
|
|
179
|
+
'--help',
|
|
180
|
+
'-h',
|
|
181
|
+
])
|
|
182
|
+
const COMMANDS = new Set(['graph', 'seed-tags'])
|
|
183
|
+
|
|
184
|
+
function parseArgs(argv) {
|
|
185
|
+
const args = {
|
|
186
|
+
command: null,
|
|
187
|
+
ids: [],
|
|
188
|
+
dryRun: false,
|
|
189
|
+
all: false,
|
|
190
|
+
offline: false,
|
|
191
|
+
configPath: null,
|
|
192
|
+
summaryPath: null,
|
|
193
|
+
assistant: undefined, // undefined = no override; null = --assistant none (kill switch)
|
|
194
|
+
help: false,
|
|
195
|
+
}
|
|
196
|
+
for (let i = 0; i < argv.length; i++) {
|
|
197
|
+
const arg = argv[i]
|
|
198
|
+
if (arg === '--config') {
|
|
199
|
+
args.configPath = argv[++i] ?? fail('--config needs a path')
|
|
200
|
+
} else if (arg === '--summary') {
|
|
201
|
+
args.summaryPath = argv[++i] ?? fail('--summary needs a path')
|
|
202
|
+
} else if (arg === '--assistant') {
|
|
203
|
+
const name = argv[++i] ?? fail('--assistant needs a name (none, auto, claude, codex)')
|
|
204
|
+
if (!ASSISTANT_NAMES.has(name))
|
|
205
|
+
fail(`--assistant must be one of: ${[...ASSISTANT_NAMES].join(', ')}`)
|
|
206
|
+
const normalized = normalizeAssistant(name)
|
|
207
|
+
args.assistant = normalized?.error ? fail(normalized.error) : normalized
|
|
208
|
+
} else if (arg === '--dry-run') args.dryRun = true
|
|
209
|
+
else if (arg === '--all') args.all = true
|
|
210
|
+
else if (arg === '--offline') args.offline = true
|
|
211
|
+
else if (arg === '--help' || arg === '-h') args.help = true
|
|
212
|
+
else if (arg.startsWith('-'))
|
|
213
|
+
fail(`unknown flag ${arg} (known: ${[...KNOWN_FLAGS].join(', ')})`)
|
|
214
|
+
else if (args.command === null && args.ids.length === 0 && COMMANDS.has(arg)) args.command = arg
|
|
215
|
+
else args.ids.push(arg)
|
|
216
|
+
}
|
|
217
|
+
if (args.all && args.ids.length)
|
|
218
|
+
fail('--all and explicit package ids conflict — pass one or the other')
|
|
219
|
+
if (args.command === 'graph' && (args.all || args.ids.length))
|
|
220
|
+
fail('graph takes no package ids or --all')
|
|
221
|
+
return args
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Expand a membership entry to directories. Supports a single trailing `/*`. */
|
|
225
|
+
function expandEntry(rootDir, entry) {
|
|
226
|
+
const spec = typeof entry === 'string' ? { path: entry } : entry
|
|
227
|
+
if (!spec.path.includes('*')) return [spec]
|
|
228
|
+
const starIndex = spec.path.indexOf('*')
|
|
229
|
+
const parent = join(rootDir, spec.path.slice(0, starIndex))
|
|
230
|
+
if (!existsSync(parent)) return []
|
|
231
|
+
return readdirSync(parent)
|
|
232
|
+
.filter((name) => !name.startsWith('.') && statSync(join(parent, name)).isDirectory())
|
|
233
|
+
.map((name) =>
|
|
234
|
+
Object.assign({}, spec, { path: join(relative(rootDir, parent), name), id: undefined }),
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
239
|
+
// Discovery — manifests and owning repos
|
|
240
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/** Read whichever manifest the directory carries: name, version, dependency maps. */
|
|
243
|
+
function readManifest(dir) {
|
|
244
|
+
const pkgJson = join(dir, 'package.json')
|
|
245
|
+
if (existsSync(pkgJson)) {
|
|
246
|
+
const m = readJson(pkgJson)
|
|
247
|
+
return {
|
|
248
|
+
ecosystem: 'npm',
|
|
249
|
+
manifestFile: 'package.json',
|
|
250
|
+
name: m.name ?? null,
|
|
251
|
+
version: m.version ?? null,
|
|
252
|
+
deps: { ...m.dependencies, ...m.peerDependencies, ...m.optionalDependencies },
|
|
253
|
+
devDeps: { ...m.devDependencies },
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const composer = join(dir, 'composer.json')
|
|
257
|
+
if (existsSync(composer)) {
|
|
258
|
+
const m = readJson(composer)
|
|
259
|
+
return {
|
|
260
|
+
ecosystem: 'php',
|
|
261
|
+
manifestFile: 'composer.json',
|
|
262
|
+
name: m.name ?? null,
|
|
263
|
+
version: m.version ?? null,
|
|
264
|
+
deps: { ...m.require },
|
|
265
|
+
devDeps: { ...m['require-dev'] },
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const pyproject = join(dir, 'pyproject.toml')
|
|
269
|
+
if (existsSync(pyproject)) {
|
|
270
|
+
const text = readFileSync(pyproject, 'utf8')
|
|
271
|
+
const name = /^name\s*=\s*"([^"]+)"/m.exec(text)?.[1] ?? null
|
|
272
|
+
const version = /^version\s*=\s*"([^"]+)"/m.exec(text)?.[1] ?? null
|
|
273
|
+
return {
|
|
274
|
+
ecosystem: 'python',
|
|
275
|
+
manifestFile: 'pyproject.toml',
|
|
276
|
+
name,
|
|
277
|
+
version,
|
|
278
|
+
deps: {},
|
|
279
|
+
devDeps: {},
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const goMod = join(dir, 'go.mod')
|
|
283
|
+
if (existsSync(goMod)) {
|
|
284
|
+
const name = /^module\s+(\S+)/m.exec(readFileSync(goMod, 'utf8'))?.[1] ?? null
|
|
285
|
+
return { ecosystem: 'go', manifestFile: 'go.mod', name, version: null, deps: {}, devDeps: {} }
|
|
286
|
+
}
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Walk up from dir to the nearest .git, stopping at rootDir's parent. */
|
|
291
|
+
function findOwningRepo(rootDir, dir) {
|
|
292
|
+
let current = resolve(dir)
|
|
293
|
+
const stop = dirname(resolve(rootDir))
|
|
294
|
+
while (current !== stop && current !== dirname(current)) {
|
|
295
|
+
if (existsSync(join(current, '.git'))) return current
|
|
296
|
+
current = dirname(current)
|
|
297
|
+
}
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function discover(rootDir, config) {
|
|
302
|
+
const members = []
|
|
303
|
+
const problems = []
|
|
304
|
+
for (const entry of config.packages.flatMap((e) => expandEntry(rootDir, e))) {
|
|
305
|
+
const dir = join(rootDir, entry.path)
|
|
306
|
+
if (!existsSync(dir)) {
|
|
307
|
+
problems.push(`membership path does not exist: ${entry.path}`)
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
const manifest = readManifest(dir)
|
|
311
|
+
if (!manifest) {
|
|
312
|
+
problems.push(
|
|
313
|
+
`no manifest (package.json / composer.json / pyproject.toml / go.mod) in ${entry.path}`,
|
|
314
|
+
)
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
const repoDir = findOwningRepo(rootDir, dir)
|
|
318
|
+
if (!repoDir) problems.push(`no owning git repository found for ${entry.path}`)
|
|
319
|
+
const releaseConfigPath = join(dir, 'release.config.json')
|
|
320
|
+
const releaseConfig = existsSync(releaseConfigPath) ? readJson(releaseConfigPath) : {}
|
|
321
|
+
members.push({
|
|
322
|
+
id: entry.id ?? manifest.name ?? basename(dir),
|
|
323
|
+
path: entry.path,
|
|
324
|
+
dir,
|
|
325
|
+
repoDir,
|
|
326
|
+
repoRelPath: repoDir ? relative(repoDir, dir) || '.' : null,
|
|
327
|
+
publish: entry.publish !== false,
|
|
328
|
+
branch: releaseConfig.branch === undefined ? 'main' : releaseConfig.branch,
|
|
329
|
+
...manifest,
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
const seen = new Map()
|
|
333
|
+
for (const member of members) {
|
|
334
|
+
if (seen.has(member.id))
|
|
335
|
+
problems.push(
|
|
336
|
+
`duplicate package id "${member.id}" (${seen.get(member.id)} and ${member.path})`,
|
|
337
|
+
)
|
|
338
|
+
seen.set(member.id, member.path)
|
|
339
|
+
}
|
|
340
|
+
return { members, problems }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
344
|
+
// Graph — internal edges, cycles, topological order
|
|
345
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Publish-order edges come from dependencies/peerDependencies/optionalDependencies whose
|
|
349
|
+
* name matches another member. devDependencies never order publishes (they are not in the
|
|
350
|
+
* published artifact) but still cascade — a devDep edge is tracked separately.
|
|
351
|
+
*/
|
|
352
|
+
export function buildGraph(members) {
|
|
353
|
+
const byName = new Map(members.filter((m) => m.name).map((m) => [m.name, m]))
|
|
354
|
+
const orderEdges = new Map(members.map((m) => [m.id, []]))
|
|
355
|
+
const devEdges = new Map(members.map((m) => [m.id, []]))
|
|
356
|
+
for (const member of members) {
|
|
357
|
+
for (const [depName, range] of Object.entries(member.deps ?? {})) {
|
|
358
|
+
const dep = byName.get(depName)
|
|
359
|
+
if (dep && dep.id !== member.id) orderEdges.get(member.id).push({ dep: dep.id, range })
|
|
360
|
+
}
|
|
361
|
+
for (const [depName, range] of Object.entries(member.devDeps ?? {})) {
|
|
362
|
+
const dep = byName.get(depName)
|
|
363
|
+
if (dep && dep.id !== member.id) devEdges.get(member.id).push({ dep: dep.id, range })
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return { orderEdges, devEdges }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function findCycles(orderEdges) {
|
|
370
|
+
const cycles = []
|
|
371
|
+
const done = new Set()
|
|
372
|
+
const walk = (id, stack) => {
|
|
373
|
+
if (done.has(id)) return
|
|
374
|
+
const at = stack.indexOf(id)
|
|
375
|
+
if (at !== -1) {
|
|
376
|
+
cycles.push([...stack.slice(at), id])
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
for (const edge of orderEdges.get(id) ?? []) walk(edge.dep, [...stack, id])
|
|
380
|
+
done.add(id)
|
|
381
|
+
}
|
|
382
|
+
for (const id of orderEdges.keys()) walk(id, [])
|
|
383
|
+
return cycles
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Dependencies-first order. Deterministic: alphabetical among peers. */
|
|
387
|
+
export function topoSort(orderEdges) {
|
|
388
|
+
const order = []
|
|
389
|
+
const done = new Set()
|
|
390
|
+
const visit = (id) => {
|
|
391
|
+
if (done.has(id)) return
|
|
392
|
+
done.add(id)
|
|
393
|
+
for (const edge of [...(orderEdges.get(id) ?? [])].sort((a, b) => a.dep.localeCompare(b.dep)))
|
|
394
|
+
visit(edge.dep)
|
|
395
|
+
order.push(id)
|
|
396
|
+
}
|
|
397
|
+
for (const id of [...orderEdges.keys()].sort()) visit(id)
|
|
398
|
+
return order
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
402
|
+
// Tags and change detection
|
|
403
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Tag scheme: `v<version>` when the repo owns exactly one member, `<name>@<version>` when
|
|
407
|
+
* it owns several — which keeps single-package repos identical to standalone release-kit.
|
|
408
|
+
*/
|
|
409
|
+
export function tagPatternFor(member, repoMemberCount) {
|
|
410
|
+
return repoMemberCount > 1
|
|
411
|
+
? { prefix: `${member.name}@`, glob: `${member.name}@*` }
|
|
412
|
+
: { prefix: 'v', glob: 'v*' }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function lastReleaseTag(member, repoMemberCount) {
|
|
416
|
+
if (!member.repoDir) return null
|
|
417
|
+
const { prefix, glob } = tagPatternFor(member, repoMemberCount)
|
|
418
|
+
const tags = git(member.repoDir, ['tag', '--list', glob], { allowFailure: true })
|
|
419
|
+
if (!tags) return null
|
|
420
|
+
const versions = tags
|
|
421
|
+
.split('\n')
|
|
422
|
+
.map((tag) => ({ tag, version: tag.slice(prefix.length) }))
|
|
423
|
+
.filter(({ version }) => parseSemver(version))
|
|
424
|
+
.sort((a, b) => compareSemver(a.version, b.version))
|
|
425
|
+
return versions.at(-1) ?? null
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Full commit messages (subject + body) since the tag, newest first, for this path. */
|
|
429
|
+
function commitsSince(member, tag) {
|
|
430
|
+
const range = tag ? `${tag}..HEAD` : 'HEAD'
|
|
431
|
+
const pathArgs = member.repoRelPath === '.' ? [] : ['--', member.repoRelPath]
|
|
432
|
+
const out = git(member.repoDir, ['log', '--format=%B%x1e', range, ...pathArgs], {
|
|
433
|
+
allowFailure: true,
|
|
434
|
+
})
|
|
435
|
+
if (!out) return []
|
|
436
|
+
return out
|
|
437
|
+
.split('\u001E')
|
|
438
|
+
.map((message) => message.trim())
|
|
439
|
+
.filter(Boolean)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
443
|
+
// Registry — published versions, cached one lookup per package
|
|
444
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
const registryCache = new Map()
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* All published versions of an npm package. `[]` means "confirmed never published"
|
|
450
|
+
* (E404); `null` means the lookup failed (network, auth) and nothing can be concluded.
|
|
451
|
+
*/
|
|
452
|
+
function publishedVersions(name) {
|
|
453
|
+
if (registryCache.has(name)) return registryCache.get(name)
|
|
454
|
+
let result
|
|
455
|
+
try {
|
|
456
|
+
const out = execFileSync('npm', ['view', name, 'versions', '--json'], {
|
|
457
|
+
encoding: 'utf8',
|
|
458
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
459
|
+
timeout: 20_000,
|
|
460
|
+
}).trim()
|
|
461
|
+
const parsed = JSON.parse(out)
|
|
462
|
+
result = Array.isArray(parsed) ? parsed : [parsed]
|
|
463
|
+
} catch (err) {
|
|
464
|
+
result = /E404|404 Not Found/.test(`${err.stderr ?? ''}${err.stdout ?? ''}`) ? [] : null
|
|
465
|
+
}
|
|
466
|
+
registryCache.set(name, result)
|
|
467
|
+
return result
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* How a manifest version relates to the registry. The pure half of pending-version
|
|
472
|
+
* detection, so it is testable without a network.
|
|
473
|
+
*
|
|
474
|
+
* current — normal: the manifest version is the registry's latest
|
|
475
|
+
* pending — the manifest version is not published (a committed-but-unreleased bump,
|
|
476
|
+
* or a first release); it should be released as-is, not skipped over
|
|
477
|
+
* behind — the registry has a newer version than the manifest; releasing anything
|
|
478
|
+
* from this tree would regress, so it is a preflight failure
|
|
479
|
+
* unknown — the lookup failed or was skipped (--offline)
|
|
480
|
+
*/
|
|
481
|
+
export function registryStatus(manifestVersion, versions) {
|
|
482
|
+
if (versions === null || !manifestVersion) return { state: 'unknown', latest: null }
|
|
483
|
+
const latest =
|
|
484
|
+
versions
|
|
485
|
+
.filter((v) => parseSemver(v) && !parseSemver(v).prerelease)
|
|
486
|
+
.sort(compareSemver)
|
|
487
|
+
.at(-1) ?? null
|
|
488
|
+
if (!versions.includes(manifestVersion)) {
|
|
489
|
+
if (latest && compareSemver(latest, manifestVersion) > 0) return { state: 'behind', latest }
|
|
490
|
+
return { state: 'pending', latest }
|
|
491
|
+
}
|
|
492
|
+
if (latest && compareSemver(latest, manifestVersion) > 0) return { state: 'behind', latest }
|
|
493
|
+
return { state: 'current', latest }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Gather registry status for every npm publishable member. Offline: everything unknown. */
|
|
497
|
+
function gatherRegistry(members, offline) {
|
|
498
|
+
const registry = new Map()
|
|
499
|
+
for (const member of members) {
|
|
500
|
+
if (member.ecosystem !== 'npm' || !member.publish || !member.name) {
|
|
501
|
+
registry.set(member.id, { state: 'unknown', latest: null, versions: null })
|
|
502
|
+
continue
|
|
503
|
+
}
|
|
504
|
+
const versions = offline ? null : publishedVersions(member.name)
|
|
505
|
+
registry.set(member.id, { ...registryStatus(member.version, versions), versions })
|
|
506
|
+
}
|
|
507
|
+
return registry
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
511
|
+
// Plan — release set, bumps, range rewrites
|
|
512
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
export function rewriteRange(policy, currentRange, newVersion) {
|
|
515
|
+
if (currentRange.startsWith('workspace:')) return null // the package manager rewrites these at publish
|
|
516
|
+
if (policy === 'preserve') {
|
|
517
|
+
const operator = /^[\^~]/.exec(currentRange)?.[0] ?? ''
|
|
518
|
+
return `${operator}${newVersion}`
|
|
519
|
+
}
|
|
520
|
+
if (policy === 'caret') return `^${newVersion}`
|
|
521
|
+
if (policy === 'tilde') return `~${newVersion}`
|
|
522
|
+
return newVersion
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function rangeSatisfies(range, version) {
|
|
526
|
+
if (range.startsWith('workspace:')) return true
|
|
527
|
+
const operator = /^[\^~]/.exec(range)?.[0] ?? ''
|
|
528
|
+
const base = parseSemver(range.replace(/^[\^~]/, ''))
|
|
529
|
+
const v = parseSemver(version)
|
|
530
|
+
if (!base || !v) return false
|
|
531
|
+
if (operator === '^') return v.major === base.major && compareSemver(version, range.slice(1)) >= 0
|
|
532
|
+
if (operator === '~')
|
|
533
|
+
return v.major === base.major && v.minor === base.minor && v.patch >= base.patch
|
|
534
|
+
return compareSemver(version, range) === 0
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Decide the release set and each member's bump.
|
|
539
|
+
*
|
|
540
|
+
* Seeding, in priority order per member:
|
|
541
|
+
* 1. Registry `pending` — an unpublished manifest version is released **as-is**;
|
|
542
|
+
* the member joins the set even with zero new commits.
|
|
543
|
+
* 2. Explicit request / --all / commits since the last release tag — bump derived
|
|
544
|
+
* from the commit messages.
|
|
545
|
+
* Cascade then raises "not releasing" to patch, transitively, through both order and
|
|
546
|
+
* dev edges. A pending member reached by cascade stays as-is: its unpublished version
|
|
547
|
+
* is already the release.
|
|
548
|
+
*/
|
|
549
|
+
export function planReleases({
|
|
550
|
+
members,
|
|
551
|
+
orderEdges,
|
|
552
|
+
devEdges,
|
|
553
|
+
changes,
|
|
554
|
+
registry,
|
|
555
|
+
requested,
|
|
556
|
+
all,
|
|
557
|
+
rangePolicy,
|
|
558
|
+
}) {
|
|
559
|
+
const byId = new Map(members.map((m) => [m.id, m]))
|
|
560
|
+
const bumps = new Map()
|
|
561
|
+
const reasons = new Map()
|
|
562
|
+
|
|
563
|
+
for (const member of members) {
|
|
564
|
+
if (registry?.get(member.id)?.state === 'pending') {
|
|
565
|
+
bumps.set(member.id, 'as-is')
|
|
566
|
+
reasons.set(member.id, `${member.version} in manifest, not on the registry`)
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const seedIds = requested.length
|
|
571
|
+
? requested
|
|
572
|
+
: members.filter((m) => (all ? true : changes.get(m.id)?.commits.length)).map((m) => m.id)
|
|
573
|
+
for (const id of seedIds) {
|
|
574
|
+
if (bumps.has(id)) continue // pending wins: the unpublished version is the release
|
|
575
|
+
const change = changes.get(id)
|
|
576
|
+
const messages = change?.commits ?? []
|
|
577
|
+
bumps.set(id, messages.length ? bumpFromCommits(messages, byId.get(id).version) : 'patch')
|
|
578
|
+
reasons.set(
|
|
579
|
+
id,
|
|
580
|
+
requested.length
|
|
581
|
+
? 'requested'
|
|
582
|
+
: all
|
|
583
|
+
? '--all'
|
|
584
|
+
: `${messages.length} commit${messages.length === 1 ? '' : 's'}`,
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const dependents = new Map(members.map((m) => [m.id, new Set()]))
|
|
589
|
+
for (const [id, edges] of orderEdges) for (const e of edges) dependents.get(e.dep)?.add(id)
|
|
590
|
+
for (const [id, edges] of devEdges) for (const e of edges) dependents.get(e.dep)?.add(id)
|
|
591
|
+
|
|
592
|
+
const queue = [...bumps.keys()]
|
|
593
|
+
while (queue.length) {
|
|
594
|
+
const id = queue.shift()
|
|
595
|
+
for (const dependent of dependents.get(id) ?? []) {
|
|
596
|
+
if (!bumps.has(dependent)) {
|
|
597
|
+
bumps.set(dependent, 'patch')
|
|
598
|
+
reasons.set(dependent, `depends on ${id}`)
|
|
599
|
+
queue.push(dependent)
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const order = topoSort(orderEdges).filter((id) => bumps.has(id))
|
|
605
|
+
return order.map((id) => {
|
|
606
|
+
const member = byId.get(id)
|
|
607
|
+
const bump = bumps.get(id)
|
|
608
|
+
const next = member.version ? bumpSemver(member.version, bump) : null
|
|
609
|
+
const rewrites = (orderEdges.get(id) ?? [])
|
|
610
|
+
.filter((edge) => bumps.has(edge.dep))
|
|
611
|
+
.map((edge) => {
|
|
612
|
+
const dep = byId.get(edge.dep)
|
|
613
|
+
const depNext = dep.version ? bumpSemver(dep.version, bumps.get(edge.dep)) : null
|
|
614
|
+
return {
|
|
615
|
+
dep: edge.dep,
|
|
616
|
+
from: edge.range,
|
|
617
|
+
to: depNext ? rewriteRange(rangePolicy, edge.range, depNext) : null,
|
|
618
|
+
}
|
|
619
|
+
})
|
|
620
|
+
.filter((r) => r.to !== null || r.from.startsWith('workspace:'))
|
|
621
|
+
return { id, member, bump, current: member.version, next, reason: reasons.get(id), rewrites }
|
|
622
|
+
})
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
626
|
+
// Preflight — accumulate every failure, report once
|
|
627
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
function preflight({
|
|
630
|
+
members,
|
|
631
|
+
plan,
|
|
632
|
+
cycles,
|
|
633
|
+
discoveryProblems,
|
|
634
|
+
repoMemberCounts,
|
|
635
|
+
changes,
|
|
636
|
+
registry,
|
|
637
|
+
offline,
|
|
638
|
+
rootDir,
|
|
639
|
+
}) {
|
|
640
|
+
const failures = [...discoveryProblems]
|
|
641
|
+
const warnings = []
|
|
642
|
+
if (offline)
|
|
643
|
+
warnings.push(
|
|
644
|
+
'--offline: registry checks skipped — pending versions, collisions and behind-registry states are not detected',
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
for (const cycle of cycles)
|
|
648
|
+
failures.push(`dependency cycle: ${cycle.join(' → ')} — no release order exists`)
|
|
649
|
+
|
|
650
|
+
const repos = new Map()
|
|
651
|
+
for (const member of members) {
|
|
652
|
+
if (member.repoDir && !repos.has(member.repoDir)) repos.set(member.repoDir, member)
|
|
653
|
+
}
|
|
654
|
+
const plannedRepoDirs = new Set(plan.map((item) => item.member.repoDir).filter(Boolean))
|
|
655
|
+
|
|
656
|
+
for (const [repoDir, sample] of repos) {
|
|
657
|
+
if (!plannedRepoDirs.has(repoDir)) continue
|
|
658
|
+
const label = relative(rootDir, repoDir) || '.'
|
|
659
|
+
const dirty = git(repoDir, ['status', '--porcelain'], { allowFailure: true })
|
|
660
|
+
if (dirty) {
|
|
661
|
+
const count = dirty.split('\n').length
|
|
662
|
+
failures.push(
|
|
663
|
+
`${label}: working tree not clean (${count} ${count === 1 ? 'entry' : 'entries'})`,
|
|
664
|
+
)
|
|
665
|
+
}
|
|
666
|
+
const branch = git(repoDir, ['branch', '--show-current'], { allowFailure: true })
|
|
667
|
+
if (sample.branch !== null) {
|
|
668
|
+
if (!branch) failures.push(`${label}: detached HEAD`)
|
|
669
|
+
else if (branch !== sample.branch)
|
|
670
|
+
failures.push(`${label}: on branch "${branch}", releases run from "${sample.branch}"`)
|
|
671
|
+
}
|
|
672
|
+
const upstream = git(repoDir, ['rev-parse', '--abbrev-ref', '@{upstream}'], {
|
|
673
|
+
allowFailure: true,
|
|
674
|
+
})
|
|
675
|
+
if (!upstream) {
|
|
676
|
+
warnings.push(
|
|
677
|
+
`${label}: no upstream tracking branch — cannot tell whether it is behind its remote`,
|
|
678
|
+
)
|
|
679
|
+
} else {
|
|
680
|
+
const behind = git(repoDir, ['rev-list', '--count', `HEAD..${upstream}`], {
|
|
681
|
+
allowFailure: true,
|
|
682
|
+
})
|
|
683
|
+
if (behind && +behind > 0)
|
|
684
|
+
failures.push(`${label}: ${behind} commit(s) behind ${upstream} (as of the last fetch)`)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
for (const member of members) {
|
|
689
|
+
const status = registry.get(member.id)
|
|
690
|
+
if (status?.state === 'behind') {
|
|
691
|
+
failures.push(
|
|
692
|
+
`${member.id}: manifest says ${member.version} but the registry's latest is ${status.latest} — the tree is behind what was published; sync it before releasing`,
|
|
693
|
+
)
|
|
694
|
+
}
|
|
695
|
+
if (
|
|
696
|
+
!offline &&
|
|
697
|
+
member.ecosystem === 'npm' &&
|
|
698
|
+
member.publish &&
|
|
699
|
+
status?.state === 'unknown' &&
|
|
700
|
+
member.name
|
|
701
|
+
) {
|
|
702
|
+
warnings.push(
|
|
703
|
+
`${member.id}: registry lookup failed — pending/collision checks not performed for it`,
|
|
704
|
+
)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
for (const item of plan) {
|
|
709
|
+
const { member, next } = item
|
|
710
|
+
if (!member.version) {
|
|
711
|
+
warnings.push(
|
|
712
|
+
`${item.id}: no manifest version (${member.ecosystem}); current version must come from its last release tag`,
|
|
713
|
+
)
|
|
714
|
+
continue
|
|
715
|
+
}
|
|
716
|
+
const change = changes.get(item.id)
|
|
717
|
+
if (!change?.tag)
|
|
718
|
+
warnings.push(
|
|
719
|
+
`${item.id}: no release tag yet (cold start) — bump derived from the full history; consider seed-tags first`,
|
|
720
|
+
)
|
|
721
|
+
if (next) {
|
|
722
|
+
const { prefix } = tagPatternFor(member, repoMemberCounts.get(member.repoDir) ?? 1)
|
|
723
|
+
const nextTag = `${prefix}${next}`
|
|
724
|
+
const existing = member.repoDir
|
|
725
|
+
? git(member.repoDir, ['tag', '--list', nextTag], { allowFailure: true })
|
|
726
|
+
: ''
|
|
727
|
+
if (existing && item.bump === 'as-is') {
|
|
728
|
+
warnings.push(
|
|
729
|
+
`${item.id}: tag ${nextTag} already exists — a failed publish; release-kit reuses it when it is at HEAD`,
|
|
730
|
+
)
|
|
731
|
+
} else if (existing) {
|
|
732
|
+
failures.push(`${item.id}: tag ${nextTag} already exists`)
|
|
733
|
+
}
|
|
734
|
+
const status = registry.get(item.id)
|
|
735
|
+
if (status?.versions?.includes(next) && item.bump !== 'as-is') {
|
|
736
|
+
failures.push(`${item.id}: ${member.name}@${next} is already on the registry`)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
for (const rewrite of item.rewrites) {
|
|
740
|
+
if (
|
|
741
|
+
rewrite.to &&
|
|
742
|
+
!rangeSatisfies(rewrite.to, plan.find((p) => p.id === rewrite.dep)?.next ?? '')
|
|
743
|
+
) {
|
|
744
|
+
failures.push(
|
|
745
|
+
`${item.id}: rewritten range ${rewrite.to} for ${rewrite.dep} does not match its planned version`,
|
|
746
|
+
)
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
for (const member of members) {
|
|
752
|
+
for (const [depName, range] of Object.entries(member.deps ?? {})) {
|
|
753
|
+
if (range.startsWith('workspace:') && !members.some((m) => m.name === depName)) {
|
|
754
|
+
failures.push(
|
|
755
|
+
`${member.id}: workspace range for ${depName}, which is not a member of the train`,
|
|
756
|
+
)
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return { failures, warnings }
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
765
|
+
// seed-tags — establish baseline release tags so change detection has a floor
|
|
766
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* For each member: tag the manifest version at the owning repo's HEAD, then push the tag.
|
|
770
|
+
* Refuses per member, without touching it, when
|
|
771
|
+
* - the manifest version is not on the registry — the baseline would claim a release
|
|
772
|
+
* that never happened (pending versions are released by the train, not seeded), or
|
|
773
|
+
* - the manifest file itself has uncommitted changes — the version on disk may not be
|
|
774
|
+
* the version at HEAD, so the tag would point at the wrong commit, or
|
|
775
|
+
* - there is no manifest version at all (go, tag-only projects) — seed by hand.
|
|
776
|
+
* Under --offline the registry cannot vouch for npm members, so npm members are skipped;
|
|
777
|
+
* tags that are created are not pushed. --dry-run prints every action and does nothing.
|
|
778
|
+
*/
|
|
779
|
+
function seedTags({ members, repoMemberCounts, registry, dryRun, offline }) {
|
|
780
|
+
const results = []
|
|
781
|
+
for (const member of members) {
|
|
782
|
+
const { id, repoDir, version } = member
|
|
783
|
+
if (!repoDir) {
|
|
784
|
+
results.push({ id, action: 'skip', detail: 'no owning git repository' })
|
|
785
|
+
continue
|
|
786
|
+
}
|
|
787
|
+
if (!version) {
|
|
788
|
+
results.push({
|
|
789
|
+
id,
|
|
790
|
+
action: 'skip',
|
|
791
|
+
detail: `no manifest version (${member.ecosystem}) — seed by hand if the registry has releases`,
|
|
792
|
+
})
|
|
793
|
+
continue
|
|
794
|
+
}
|
|
795
|
+
const { prefix } = tagPatternFor(member, repoMemberCounts.get(repoDir) ?? 1)
|
|
796
|
+
const tag = `${prefix}${version}`
|
|
797
|
+
if (git(repoDir, ['tag', '--list', tag], { allowFailure: true })) {
|
|
798
|
+
results.push({ id, action: 'ok', detail: `${tag} already exists` })
|
|
799
|
+
continue
|
|
800
|
+
}
|
|
801
|
+
if (member.ecosystem === 'npm' && member.publish) {
|
|
802
|
+
const status = registry.get(id)
|
|
803
|
+
if (status?.state === 'pending') {
|
|
804
|
+
results.push({
|
|
805
|
+
id,
|
|
806
|
+
action: 'refuse',
|
|
807
|
+
detail: `${version} is not on the registry — a pending version is released by the train, not seeded`,
|
|
808
|
+
})
|
|
809
|
+
continue
|
|
810
|
+
}
|
|
811
|
+
if (status?.state === 'behind') {
|
|
812
|
+
results.push({
|
|
813
|
+
id,
|
|
814
|
+
action: 'refuse',
|
|
815
|
+
detail: `manifest ${version} is behind the registry (${status.latest}) — sync the tree first`,
|
|
816
|
+
})
|
|
817
|
+
continue
|
|
818
|
+
}
|
|
819
|
+
if (status?.state === 'unknown') {
|
|
820
|
+
results.push({
|
|
821
|
+
id,
|
|
822
|
+
action: 'skip',
|
|
823
|
+
detail: offline
|
|
824
|
+
? 'registry unverifiable under --offline'
|
|
825
|
+
: 'registry lookup failed — cannot verify the version was released',
|
|
826
|
+
})
|
|
827
|
+
continue
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
const manifestDirty = git(
|
|
831
|
+
repoDir,
|
|
832
|
+
[
|
|
833
|
+
'status',
|
|
834
|
+
'--porcelain',
|
|
835
|
+
'--',
|
|
836
|
+
join(member.repoRelPath === '.' ? '' : member.repoRelPath, member.manifestFile),
|
|
837
|
+
],
|
|
838
|
+
{
|
|
839
|
+
allowFailure: true,
|
|
840
|
+
},
|
|
841
|
+
)
|
|
842
|
+
if (manifestDirty) {
|
|
843
|
+
results.push({
|
|
844
|
+
id,
|
|
845
|
+
action: 'refuse',
|
|
846
|
+
detail: `${member.manifestFile} has uncommitted changes — the version on disk may not match HEAD`,
|
|
847
|
+
})
|
|
848
|
+
continue
|
|
849
|
+
}
|
|
850
|
+
if (dryRun) {
|
|
851
|
+
results.push({
|
|
852
|
+
id,
|
|
853
|
+
action: 'would-tag',
|
|
854
|
+
detail: `${tag} at HEAD${offline ? '' : ', then push'}`,
|
|
855
|
+
})
|
|
856
|
+
continue
|
|
857
|
+
}
|
|
858
|
+
const tagged = git(
|
|
859
|
+
repoDir,
|
|
860
|
+
[
|
|
861
|
+
'tag',
|
|
862
|
+
'-a',
|
|
863
|
+
tag,
|
|
864
|
+
'-m',
|
|
865
|
+
`Baseline for ${member.name ?? id} ${version} (seeded by release-train)`,
|
|
866
|
+
],
|
|
867
|
+
{ allowFailure: true },
|
|
868
|
+
)
|
|
869
|
+
if (tagged === null) {
|
|
870
|
+
results.push({ id, action: 'error', detail: `git tag ${tag} failed` })
|
|
871
|
+
continue
|
|
872
|
+
}
|
|
873
|
+
if (offline) {
|
|
874
|
+
results.push({ id, action: 'tagged', detail: `${tag} created, not pushed (--offline)` })
|
|
875
|
+
continue
|
|
876
|
+
}
|
|
877
|
+
const pushed = git(repoDir, ['push', 'origin', tag], { allowFailure: true })
|
|
878
|
+
results.push(
|
|
879
|
+
pushed === null
|
|
880
|
+
? { id, action: 'tagged', detail: `${tag} created; push failed — push it manually` }
|
|
881
|
+
: { id, action: 'tagged', detail: `${tag} created and pushed` },
|
|
882
|
+
)
|
|
883
|
+
}
|
|
884
|
+
return results
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
888
|
+
// Train summary — deterministic report, optionally with a drafted announcement
|
|
889
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Markdown report of the whole train: every package with its version movement and why,
|
|
893
|
+
* plus the dependency ripple — which changes pulled which dependents in. Deterministic
|
|
894
|
+
* and buildable from the plan alone; per-package release notes stay per-package
|
|
895
|
+
* (release-kit owns those). `mode` is 'planned' until execution exists.
|
|
896
|
+
*/
|
|
897
|
+
export function buildSummary(plan, { workspace, date, mode = 'planned' }) {
|
|
898
|
+
const lines = [
|
|
899
|
+
`# Release train — ${workspace}`,
|
|
900
|
+
'',
|
|
901
|
+
`_${date} · ${plan.length} package${plan.length === 1 ? '' : 's'} ${mode}, in dependency order_`,
|
|
902
|
+
'',
|
|
903
|
+
]
|
|
904
|
+
if (plan.length === 0) {
|
|
905
|
+
lines.push(
|
|
906
|
+
'Nothing to release: no member had commits since its last release tag or a pending unpublished version.',
|
|
907
|
+
)
|
|
908
|
+
return `${lines.join('\n')}\n`
|
|
909
|
+
}
|
|
910
|
+
lines.push('| # | Package | Version | Bump | Why |', '|---|---------|---------|------|-----|')
|
|
911
|
+
for (const [index, item] of plan.entries()) {
|
|
912
|
+
const version =
|
|
913
|
+
item.bump === 'as-is'
|
|
914
|
+
? `${item.current} (as-is)`
|
|
915
|
+
: item.current
|
|
916
|
+
? `${item.current} → ${item.next}`
|
|
917
|
+
: '(from tag)'
|
|
918
|
+
lines.push(`| ${index + 1} | ${item.id} | ${version} | ${item.bump} | ${item.reason} |`)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Ripple: cascade reasons carry a parent pointer ("depends on <id>") — render each
|
|
922
|
+
// seed with the transitive dependents it pulled in.
|
|
923
|
+
const children = new Map()
|
|
924
|
+
for (const item of plan) {
|
|
925
|
+
const parent = /^depends on (.+)$/.exec(item.reason)?.[1]
|
|
926
|
+
if (parent) {
|
|
927
|
+
if (!children.has(parent)) children.set(parent, [])
|
|
928
|
+
children.get(parent).push(item.id)
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
const seeds = plan.filter((item) => !item.reason.startsWith('depends on '))
|
|
932
|
+
const rippleLines = []
|
|
933
|
+
const renderRipple = (id, depth) => {
|
|
934
|
+
for (const child of children.get(id) ?? []) {
|
|
935
|
+
rippleLines.push(`${' '.repeat(depth)}- ${child}`)
|
|
936
|
+
renderRipple(child, depth + 1)
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
for (const seed of seeds) {
|
|
940
|
+
if (!children.has(seed.id)) continue
|
|
941
|
+
rippleLines.push(`- **${seed.id}** (${seed.reason}) pulled in:`)
|
|
942
|
+
renderRipple(seed.id, 1)
|
|
943
|
+
}
|
|
944
|
+
if (rippleLines.length) lines.push('', '## Dependency ripple', '', ...rippleLines)
|
|
945
|
+
|
|
946
|
+
const rewriteCount = plan.reduce((sum, item) => sum + item.rewrites.filter((r) => r.to).length, 0)
|
|
947
|
+
if (rewriteCount)
|
|
948
|
+
lines.push(
|
|
949
|
+
'',
|
|
950
|
+
`${rewriteCount} internal dependency range${rewriteCount === 1 ? '' : 's'} updated across the train.`,
|
|
951
|
+
)
|
|
952
|
+
return `${lines.join('\n')}\n`
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Assistant invocations mirror release-kit's ASSISTANTS table, so a tool configured for
|
|
956
|
+
// one behaves identically for the other.
|
|
957
|
+
const ASSISTANTS = {
|
|
958
|
+
claude: {
|
|
959
|
+
command: 'claude',
|
|
960
|
+
args: ['-p'],
|
|
961
|
+
probe: ['--version'],
|
|
962
|
+
model: (m) => ['--model', m],
|
|
963
|
+
effort: (e) => ['--effort', e],
|
|
964
|
+
},
|
|
965
|
+
codex: {
|
|
966
|
+
command: 'codex',
|
|
967
|
+
args: ['exec', '--skip-git-repo-check', '--sandbox', 'read-only'],
|
|
968
|
+
probe: ['--version'],
|
|
969
|
+
model: (m) => ['-m', m],
|
|
970
|
+
effort: (e) => [`-c`, `model_reasoning_effort="${e}"`],
|
|
971
|
+
outputFile: (path) => ['--output-last-message', path],
|
|
972
|
+
},
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const DRAFT_TIMEOUT_MS = 180_000
|
|
976
|
+
|
|
977
|
+
/** Strip tool attribution and markdown fences a model may wrap its answer in. */
|
|
978
|
+
function cleanDraft(text) {
|
|
979
|
+
return text
|
|
980
|
+
.replace(/^\s*```[a-z]*\s*\n?/i, '')
|
|
981
|
+
.replace(/\n?```\s*$/, '')
|
|
982
|
+
.split('\n')
|
|
983
|
+
.filter(
|
|
984
|
+
(line) => !/^\s*co-authored-by:/i.test(line) && !/^\s*(🤖\s*)?generated with/i.test(line),
|
|
985
|
+
)
|
|
986
|
+
.join('\n')
|
|
987
|
+
.trim()
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function assistantAvailable(tool) {
|
|
991
|
+
try {
|
|
992
|
+
execFileSync(ASSISTANTS[tool].command, ASSISTANTS[tool].probe, {
|
|
993
|
+
stdio: 'ignore',
|
|
994
|
+
timeout: 10_000,
|
|
995
|
+
})
|
|
996
|
+
return true
|
|
997
|
+
} catch {
|
|
998
|
+
return false
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** "auto" → the first tool on PATH; a named tool that is missing is a hard error. */
|
|
1003
|
+
function resolveAssistant(spec) {
|
|
1004
|
+
if (!spec) return null
|
|
1005
|
+
if (spec.tool === 'auto') {
|
|
1006
|
+
const found = Object.keys(ASSISTANTS).find((tool) => assistantAvailable(tool))
|
|
1007
|
+
return found ? { ...spec, tool: found } : null // auto degrades quietly by design
|
|
1008
|
+
}
|
|
1009
|
+
if (!assistantAvailable(spec.tool))
|
|
1010
|
+
fail(`assistant "${spec.tool}" is not installed or not working`)
|
|
1011
|
+
return spec
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Draft a short ecosystem announcement from the deterministic summary. Every failure —
|
|
1016
|
+
* timeout, unusable answer, tool error — returns null and the train carries on with the
|
|
1017
|
+
* deterministic summary alone. A train is never blocked by a text generator.
|
|
1018
|
+
*/
|
|
1019
|
+
function draftAnnouncement(assistant, summaryMarkdown) {
|
|
1020
|
+
const definition = ASSISTANTS[assistant.tool]
|
|
1021
|
+
const prompt = [
|
|
1022
|
+
'Draft a short release announcement (3-6 sentences, plain prose, no headings) for this',
|
|
1023
|
+
'coordinated multi-package release. Lead with what changed and why consumers care;',
|
|
1024
|
+
'mention the packages by name only where it helps. Output only the announcement text.',
|
|
1025
|
+
'',
|
|
1026
|
+
summaryMarkdown,
|
|
1027
|
+
].join('\n')
|
|
1028
|
+
const args = [
|
|
1029
|
+
...definition.args,
|
|
1030
|
+
...(assistant.model ? definition.model(assistant.model) : []),
|
|
1031
|
+
...(assistant.effort ? definition.effort(assistant.effort) : []),
|
|
1032
|
+
]
|
|
1033
|
+
let outputPath = null
|
|
1034
|
+
if (definition.outputFile) {
|
|
1035
|
+
outputPath = join(mkdtempSync(join(tmpdir(), 'train-')), 'answer.md')
|
|
1036
|
+
args.push(...definition.outputFile(outputPath))
|
|
1037
|
+
}
|
|
1038
|
+
try {
|
|
1039
|
+
const stdout = execFileSync(definition.command, args, {
|
|
1040
|
+
input: prompt,
|
|
1041
|
+
encoding: 'utf8',
|
|
1042
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
1043
|
+
timeout: DRAFT_TIMEOUT_MS,
|
|
1044
|
+
})
|
|
1045
|
+
const answer = cleanDraft(outputPath ? readFileSync(outputPath, 'utf8') : stdout)
|
|
1046
|
+
return answer.length >= 20 ? answer : null
|
|
1047
|
+
} catch {
|
|
1048
|
+
return null
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1053
|
+
// Output
|
|
1054
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1055
|
+
|
|
1056
|
+
function printGraph(members, orderEdges, order) {
|
|
1057
|
+
console.log(`train graph — ${members.length} members\n`)
|
|
1058
|
+
for (const id of order) {
|
|
1059
|
+
const edges = orderEdges.get(id) ?? []
|
|
1060
|
+
const deps = edges.map((e) => `${e.dep} (${e.range})`).join(', ')
|
|
1061
|
+
console.log(` ${id} ← ${deps || '(no internal deps)'}`)
|
|
1062
|
+
}
|
|
1063
|
+
console.log('\nTopological order (dependencies first):')
|
|
1064
|
+
console.log(` ${order.join(' → ')}`)
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function printPlan(plan, { failures, warnings }) {
|
|
1068
|
+
if (plan.length === 0) {
|
|
1069
|
+
console.log(
|
|
1070
|
+
'Nothing to release: no member has commits since its last release tag or a pending unpublished version.',
|
|
1071
|
+
)
|
|
1072
|
+
} else {
|
|
1073
|
+
console.log(`Release plan — ${plan.length} package${plan.length === 1 ? '' : 's'}, in order:\n`)
|
|
1074
|
+
for (const [index, item] of plan.entries()) {
|
|
1075
|
+
const versionText =
|
|
1076
|
+
item.bump === 'as-is'
|
|
1077
|
+
? `${item.current} (as-is)`
|
|
1078
|
+
: item.current
|
|
1079
|
+
? `${item.current} → ${item.next}`
|
|
1080
|
+
: `(${item.member.ecosystem}: version from tag)`
|
|
1081
|
+
console.log(
|
|
1082
|
+
` ${String(index + 1).padStart(2)}. ${item.id.padEnd(24)} ${item.bump.padEnd(6)} ${versionText.padEnd(20)} ${item.reason}`,
|
|
1083
|
+
)
|
|
1084
|
+
for (const rewrite of item.rewrites) {
|
|
1085
|
+
const target = rewrite.from.startsWith('workspace:')
|
|
1086
|
+
? `${rewrite.from} (rewritten at publish)`
|
|
1087
|
+
: `${rewrite.from} → ${rewrite.to}`
|
|
1088
|
+
console.log(` deps: ${rewrite.dep} ${target}`)
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
if (warnings.length) {
|
|
1093
|
+
console.log(`\nWarnings (${warnings.length}):`)
|
|
1094
|
+
for (const warning of warnings) console.log(` warn ${warning}`)
|
|
1095
|
+
}
|
|
1096
|
+
if (failures.length) {
|
|
1097
|
+
console.log(
|
|
1098
|
+
`\nPreflight failures (${failures.length}) — nothing would mutate until every one is fixed:`,
|
|
1099
|
+
)
|
|
1100
|
+
for (const failure of failures) console.log(` FAIL ${failure}`)
|
|
1101
|
+
} else {
|
|
1102
|
+
console.log('\nPreflight: ok')
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function printSeedResults(results, dryRun) {
|
|
1107
|
+
console.log(`seed-tags${dryRun ? ' (dry run)' : ''} — ${results.length} members\n`)
|
|
1108
|
+
for (const r of results) console.log(` ${r.action.padEnd(10)} ${r.id.padEnd(24)} ${r.detail}`)
|
|
1109
|
+
const refusedOrErrored = results.filter(
|
|
1110
|
+
(r) => r.action === 'refuse' || r.action === 'error',
|
|
1111
|
+
).length
|
|
1112
|
+
if (refusedOrErrored)
|
|
1113
|
+
console.log(`\n${refusedOrErrored} member(s) refused or failed — see above.`)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const HELP = `release-train (prototype — read-only phases, seed-tags, and the train summary)
|
|
1117
|
+
|
|
1118
|
+
train graph print the derived dependency graph and topo order
|
|
1119
|
+
train --dry-run plan + whole-train preflight, execute nothing
|
|
1120
|
+
train --dry-run --all plan every member
|
|
1121
|
+
train --dry-run <id>... plan these packages and their dependents
|
|
1122
|
+
train seed-tags create baseline tags (add --dry-run to preview)
|
|
1123
|
+
train --summary <path> write the train summary (markdown) here; overrides summaryFile
|
|
1124
|
+
train --assistant <name> none (whole-train kill switch), auto, claude, codex
|
|
1125
|
+
train --offline skip network work (registry lookups, tag pushes)
|
|
1126
|
+
train --config <path> config file (default ./train.config.json)
|
|
1127
|
+
`
|
|
1128
|
+
|
|
1129
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1130
|
+
// Main
|
|
1131
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1132
|
+
|
|
1133
|
+
function main() {
|
|
1134
|
+
const args = parseArgs(process.argv.slice(2))
|
|
1135
|
+
if (args.help) {
|
|
1136
|
+
console.log(HELP)
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
const configPath = args.configPath ?? join(process.cwd(), 'train.config.json')
|
|
1140
|
+
const config = loadConfig(configPath)
|
|
1141
|
+
const rootDir = dirname(resolve(configPath))
|
|
1142
|
+
const { members, problems } = discover(rootDir, config)
|
|
1143
|
+
const { orderEdges, devEdges } = buildGraph(members)
|
|
1144
|
+
const cycles = findCycles(orderEdges)
|
|
1145
|
+
const order = topoSort(orderEdges)
|
|
1146
|
+
|
|
1147
|
+
if (args.command === 'graph') {
|
|
1148
|
+
printGraph(members, orderEdges, order)
|
|
1149
|
+
if (cycles.length) for (const cycle of cycles) console.log(`\nCYCLE: ${cycle.join(' → ')}`)
|
|
1150
|
+
if (problems.length) for (const problem of problems) console.log(`warn ${problem}`)
|
|
1151
|
+
process.exitCode = cycles.length || problems.length ? 1 : 0
|
|
1152
|
+
return
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const repoMemberCounts = new Map()
|
|
1156
|
+
for (const member of members) {
|
|
1157
|
+
if (member.repoDir)
|
|
1158
|
+
repoMemberCounts.set(member.repoDir, (repoMemberCounts.get(member.repoDir) ?? 0) + 1)
|
|
1159
|
+
}
|
|
1160
|
+
const registry = gatherRegistry(members, args.offline)
|
|
1161
|
+
|
|
1162
|
+
if (args.command === 'seed-tags') {
|
|
1163
|
+
const results = seedTags({
|
|
1164
|
+
members,
|
|
1165
|
+
repoMemberCounts,
|
|
1166
|
+
registry,
|
|
1167
|
+
dryRun: args.dryRun,
|
|
1168
|
+
offline: args.offline,
|
|
1169
|
+
})
|
|
1170
|
+
printSeedResults(results, args.dryRun)
|
|
1171
|
+
process.exitCode = results.some((r) => r.action === 'error') ? 1 : 0
|
|
1172
|
+
return
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
if (!args.dryRun) fail('execution is not implemented yet — run with --dry-run')
|
|
1176
|
+
|
|
1177
|
+
const changes = new Map()
|
|
1178
|
+
for (const member of members) {
|
|
1179
|
+
if (!member.repoDir) continue
|
|
1180
|
+
const last = lastReleaseTag(member, repoMemberCounts.get(member.repoDir))
|
|
1181
|
+
changes.set(member.id, { tag: last?.tag ?? null, commits: commitsSince(member, last?.tag) })
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
for (const id of args.ids)
|
|
1185
|
+
if (!members.some((m) => m.id === id)) fail(`unknown package id "${id}" — see train graph`)
|
|
1186
|
+
|
|
1187
|
+
const plan = planReleases({
|
|
1188
|
+
members,
|
|
1189
|
+
orderEdges,
|
|
1190
|
+
devEdges,
|
|
1191
|
+
changes,
|
|
1192
|
+
registry,
|
|
1193
|
+
requested: args.ids,
|
|
1194
|
+
all: args.all,
|
|
1195
|
+
rangePolicy: config.rangePolicy,
|
|
1196
|
+
})
|
|
1197
|
+
const result = preflight({
|
|
1198
|
+
members,
|
|
1199
|
+
plan,
|
|
1200
|
+
cycles,
|
|
1201
|
+
discoveryProblems: problems,
|
|
1202
|
+
repoMemberCounts,
|
|
1203
|
+
changes,
|
|
1204
|
+
registry,
|
|
1205
|
+
offline: args.offline,
|
|
1206
|
+
rootDir,
|
|
1207
|
+
})
|
|
1208
|
+
printPlan(plan, result)
|
|
1209
|
+
|
|
1210
|
+
// Train summary: deterministic always; announcement drafted only when an assistant is
|
|
1211
|
+
// configured (or forced via --assistant) and never blocking. --assistant none is the
|
|
1212
|
+
// whole-train kill switch — when execution lands it is also forwarded to every
|
|
1213
|
+
// release-kit run, and it is the ONLY assistant value that is forwarded: forcing a
|
|
1214
|
+
// drafting tool onto packages that did not opt in stays impossible by design.
|
|
1215
|
+
const summaryPath = args.summaryPath ?? config.summaryFile
|
|
1216
|
+
if (summaryPath) {
|
|
1217
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
1218
|
+
let summary = buildSummary(plan, {
|
|
1219
|
+
workspace: basename(rootDir),
|
|
1220
|
+
date,
|
|
1221
|
+
mode: 'planned (dry run)',
|
|
1222
|
+
})
|
|
1223
|
+
const assistant = resolveAssistant(
|
|
1224
|
+
args.assistant === undefined ? config.assistant : args.assistant,
|
|
1225
|
+
)
|
|
1226
|
+
if (assistant && plan.length) {
|
|
1227
|
+
const announcement = draftAnnouncement(assistant, summary)
|
|
1228
|
+
if (announcement) summary = summary.replace('\n', `\n\n## Announcement\n\n${announcement}\n`)
|
|
1229
|
+
else
|
|
1230
|
+
console.log(
|
|
1231
|
+
`\nwarn ${assistant.tool} produced no usable announcement — deterministic summary only`,
|
|
1232
|
+
)
|
|
1233
|
+
}
|
|
1234
|
+
writeFileSync(resolve(rootDir, summaryPath), summary)
|
|
1235
|
+
console.log(`\nTrain summary written to ${summaryPath}`)
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
process.exitCode = result.failures.length ? 1 : 0
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main()
|