@entro314labs/release-kit 2.9.4 → 2.11.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 +221 -42
- package/TRAIN.md +78 -41
- package/package.json +3 -3
- package/release.mjs +671 -47
- package/train.mjs +328 -33
package/train.mjs
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* release-train — orchestrated releases for interdependent packages
|
|
3
|
+
* release-train — orchestrated releases for interdependent packages.
|
|
4
4
|
*
|
|
5
|
-
* Implements
|
|
6
|
-
*
|
|
7
|
-
* Execution
|
|
8
|
-
*
|
|
5
|
+
* Implements TRAIN.md's pipeline — discover → graph → detect changes → cascade → plan →
|
|
6
|
+
* preflight → execute → report — plus `seed-tags`, which establishes baseline release tags.
|
|
7
|
+
* Execution runs the release-kit beside this file once per package, in dependency order; a
|
|
8
|
+
* package that shares its git repository with other members runs with `--package`.
|
|
9
9
|
*
|
|
10
|
+
* train plan + preflight, confirm, release (--yes skips the prompt)
|
|
10
11
|
* train graph print the derived dependency graph and topo order
|
|
11
12
|
* train --dry-run full plan + whole-train preflight, execute nothing
|
|
12
|
-
* train --
|
|
13
|
-
* train
|
|
13
|
+
* train --all every member, not just changed ones
|
|
14
|
+
* train <id>... these packages and their dependents
|
|
14
15
|
* 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 --offline skip network work (registry lookups, tag pushes); dry runs only
|
|
16
17
|
* train --config <path> config elsewhere than ./train.config.json
|
|
17
18
|
*
|
|
18
19
|
* Reads train.config.json in the working directory. Config declares membership and
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
* 3. The cascade: a dependent of a releasing package joins with at least a patch.
|
|
30
31
|
*/
|
|
31
32
|
|
|
32
|
-
import { execFileSync } from 'node:child_process'
|
|
33
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
33
34
|
import {
|
|
34
35
|
existsSync,
|
|
35
36
|
mkdtempSync,
|
|
@@ -40,7 +41,8 @@ import {
|
|
|
40
41
|
} from 'node:fs'
|
|
41
42
|
import { tmpdir } from 'node:os'
|
|
42
43
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
43
|
-
import {
|
|
44
|
+
import { createInterface } from 'node:readline/promises'
|
|
45
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
44
46
|
|
|
45
47
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
48
|
// Small utilities
|
|
@@ -206,6 +208,12 @@ export function loadConfig(configPath) {
|
|
|
206
208
|
if (!RANGE_POLICIES.has(rangePolicy))
|
|
207
209
|
fail(`rangePolicy must be one of: ${[...RANGE_POLICIES].join(', ')}`)
|
|
208
210
|
const registryWait = { timeout: 300, interval: 5, ...config.registryWait }
|
|
211
|
+
for (const key of ['timeout', 'interval']) {
|
|
212
|
+
if (typeof registryWait[key] !== 'number' || !(registryWait[key] > 0))
|
|
213
|
+
fail(`registryWait.${key} must be a positive number of seconds`)
|
|
214
|
+
}
|
|
215
|
+
const extraWaitKeys = Object.keys(registryWait).filter((k) => k !== 'timeout' && k !== 'interval')
|
|
216
|
+
if (extraWaitKeys.length) fail(`unknown registryWait key: ${extraWaitKeys.join(', ')}`)
|
|
209
217
|
const assistant = normalizeAssistant(config.assistant ?? null)
|
|
210
218
|
if (assistant?.error) fail(assistant.error)
|
|
211
219
|
const summaryFile = config.summaryFile ?? null
|
|
@@ -216,6 +224,8 @@ export function loadConfig(configPath) {
|
|
|
216
224
|
|
|
217
225
|
const KNOWN_FLAGS = new Set([
|
|
218
226
|
'--dry-run',
|
|
227
|
+
'--yes',
|
|
228
|
+
'-y',
|
|
219
229
|
'--all',
|
|
220
230
|
'--offline',
|
|
221
231
|
'--config',
|
|
@@ -231,6 +241,7 @@ function parseArgs(argv) {
|
|
|
231
241
|
command: null,
|
|
232
242
|
ids: [],
|
|
233
243
|
dryRun: false,
|
|
244
|
+
yes: false,
|
|
234
245
|
all: false,
|
|
235
246
|
offline: false,
|
|
236
247
|
configPath: null,
|
|
@@ -251,6 +262,7 @@ function parseArgs(argv) {
|
|
|
251
262
|
const normalized = normalizeAssistant(name)
|
|
252
263
|
args.assistant = normalized?.error ? fail(normalized.error) : normalized
|
|
253
264
|
} else if (arg === '--dry-run') args.dryRun = true
|
|
265
|
+
else if (arg === '--yes' || arg === '-y') args.yes = true
|
|
254
266
|
else if (arg === '--all') args.all = true
|
|
255
267
|
else if (arg === '--offline') args.offline = true
|
|
256
268
|
else if (arg === '--help' || arg === '-h') args.help = true
|
|
@@ -263,6 +275,10 @@ function parseArgs(argv) {
|
|
|
263
275
|
fail('--all and explicit package ids conflict — pass one or the other')
|
|
264
276
|
if (args.command === 'graph' && (args.all || args.ids.length))
|
|
265
277
|
fail('graph takes no package ids or --all')
|
|
278
|
+
// A release publishes and waits on the registry; neither is possible offline, and a
|
|
279
|
+
// train that skipped them would release dependents onto versions nobody can install.
|
|
280
|
+
if (args.offline && !args.dryRun && args.command === null)
|
|
281
|
+
fail('--offline cannot release — it skips the registry a train publishes to. Add --dry-run')
|
|
266
282
|
return args
|
|
267
283
|
}
|
|
268
284
|
|
|
@@ -371,8 +387,10 @@ export function discover(rootDir, config) {
|
|
|
371
387
|
repoRelPath: repoDir ? relative(repoDir, dir) || '.' : null,
|
|
372
388
|
publish: entry.publish !== false,
|
|
373
389
|
branch: releaseConfig.branch === undefined ? 'main' : releaseConfig.branch,
|
|
374
|
-
// The prefix release-kit will tag with, and so the one its history is read under
|
|
375
|
-
|
|
390
|
+
// The prefix release-kit will tag with, and so the one its history is read under;
|
|
391
|
+
// null when the package's config leaves it to the default (see tagPatternFor).
|
|
392
|
+
tagPrefix: releaseConfig.tagPrefix ?? null,
|
|
393
|
+
requireGreen: releaseConfig.requireGreen === true,
|
|
376
394
|
...manifest,
|
|
377
395
|
})
|
|
378
396
|
}
|
|
@@ -450,13 +468,12 @@ export function topoSort(orderEdges) {
|
|
|
450
468
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
451
469
|
|
|
452
470
|
/**
|
|
453
|
-
* Tag scheme: the package's own `tagPrefix`
|
|
454
|
-
* otherwise
|
|
455
|
-
*
|
|
471
|
+
* Tag scheme: the package's own `tagPrefix` when its release.config.json sets one;
|
|
472
|
+
* otherwise `v` when the repo owns exactly one member, and `<name>@` when it owns several
|
|
473
|
+
* — the defaults release-kit itself tags with, standalone and under `--package`.
|
|
456
474
|
*/
|
|
457
475
|
export function tagPatternFor(member, repoMemberCount) {
|
|
458
|
-
|
|
459
|
-
const prefix = member.tagPrefix ?? 'v'
|
|
476
|
+
const prefix = member.tagPrefix ?? (repoMemberCount > 1 ? `${member.name}@` : 'v')
|
|
460
477
|
return { prefix, glob: `${prefix}*` }
|
|
461
478
|
}
|
|
462
479
|
|
|
@@ -759,6 +776,38 @@ function preflight({
|
|
|
759
776
|
|
|
760
777
|
for (const item of plan) {
|
|
761
778
|
const { member, next } = item
|
|
779
|
+
// A member sharing its repository releases under --package, tagged `<name>@`; with no
|
|
780
|
+
// name and no configured prefix release-kit would stop at this package's turn.
|
|
781
|
+
if ((repoMemberCounts.get(member.repoDir) ?? 1) > 1 && !member.name && !member.tagPrefix) {
|
|
782
|
+
failures.push(
|
|
783
|
+
`${item.id}: shares ${relative(rootDir, member.repoDir) || '.'} with other members but has no package name to tag with — set "tagPrefix" in its release.config.json`,
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
// requireGreen refuses a HEAD CI has not passed. The train moves this member's HEAD
|
|
787
|
+
// before its turn — its own deps commit, or an earlier member's release commit in the
|
|
788
|
+
// same repository — so the refusal would come mid-train, after others released.
|
|
789
|
+
const earlierInRepo = plan
|
|
790
|
+
.slice(0, plan.indexOf(item))
|
|
791
|
+
.some((other) => other.member.repoDir === member.repoDir)
|
|
792
|
+
if (member.requireGreen && (item.rewrites.some((r) => r.to) || earlierInRepo)) {
|
|
793
|
+
failures.push(
|
|
794
|
+
`${item.id}: its release.config.json sets requireGreen, and the train commits to its repository before its turn — CI cannot have passed that commit; turn requireGreen off for train releases`,
|
|
795
|
+
)
|
|
796
|
+
}
|
|
797
|
+
// Moving a range makes the lockfile that records it stale, and a frozen install in
|
|
798
|
+
// that repository's CI then fails. The train refreshes it — with the tool that owns it.
|
|
799
|
+
if (item.rewrites.some((r) => r.to)) {
|
|
800
|
+
const lock = findLockfile(member)
|
|
801
|
+
if (lock && !lock.tool) {
|
|
802
|
+
failures.push(
|
|
803
|
+
`${item.id}: ${lock.name} records the ranges the train rewrites, and the train cannot refresh it — use workspace: ranges or an npm/pnpm lockfile`,
|
|
804
|
+
)
|
|
805
|
+
} else if (lock && !toolAvailable(lock.tool)) {
|
|
806
|
+
failures.push(
|
|
807
|
+
`${item.id}: ${lock.name} has to be refreshed after its ranges move, and ${lock.tool} is not installed`,
|
|
808
|
+
)
|
|
809
|
+
}
|
|
810
|
+
}
|
|
762
811
|
if (!member.version) {
|
|
763
812
|
warnings.push(
|
|
764
813
|
`${item.id}: no manifest version (${member.ecosystem}); current version must come from its last release tag`,
|
|
@@ -944,7 +993,7 @@ function seedTags({ members, repoMemberCounts, registry, dryRun, offline }) {
|
|
|
944
993
|
* Markdown report of the whole train: every package with its version movement and why,
|
|
945
994
|
* plus the dependency ripple — which changes pulled which dependents in. Deterministic
|
|
946
995
|
* and buildable from the plan alone; per-package release notes stay per-package
|
|
947
|
-
* (release-kit owns those). `mode`
|
|
996
|
+
* (release-kit owns those). `mode` says whether the rows were planned or released.
|
|
948
997
|
*/
|
|
949
998
|
export function buildSummary(plan, { workspace, date, mode = 'planned' }) {
|
|
950
999
|
const lines = [
|
|
@@ -1101,6 +1150,232 @@ function draftAnnouncement(assistant, summaryMarkdown) {
|
|
|
1101
1150
|
}
|
|
1102
1151
|
}
|
|
1103
1152
|
|
|
1153
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1154
|
+
// Execute — release-kit once per package, dependencies first
|
|
1155
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* The release-kit shipped beside this file. Not the one on PATH and not a copy vendored in
|
|
1159
|
+
* each package: a train is one release, and one release-kit version runs all of it.
|
|
1160
|
+
*/
|
|
1161
|
+
const RELEASE_KIT = join(dirname(fileURLToPath(import.meta.url)), 'release.mjs')
|
|
1162
|
+
|
|
1163
|
+
const DEPENDENCY_MAPS = ['dependencies', 'peerDependencies', 'optionalDependencies']
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Lockfiles that record dependency ranges, nearest first, with the command that brings one
|
|
1167
|
+
* back into step with its manifest. `tool: null` is a lockfile the train will not guess a
|
|
1168
|
+
* refresh for; preflight refuses it rather than committing it stale.
|
|
1169
|
+
*/
|
|
1170
|
+
const LOCKFILES = [
|
|
1171
|
+
{
|
|
1172
|
+
name: 'pnpm-lock.yaml',
|
|
1173
|
+
tool: 'pnpm',
|
|
1174
|
+
args: ['install', '--lockfile-only', '--ignore-scripts'],
|
|
1175
|
+
},
|
|
1176
|
+
{
|
|
1177
|
+
name: 'package-lock.json',
|
|
1178
|
+
tool: 'npm',
|
|
1179
|
+
args: ['install', '--package-lock-only', '--ignore-scripts'],
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
name: 'npm-shrinkwrap.json',
|
|
1183
|
+
tool: 'npm',
|
|
1184
|
+
args: ['install', '--package-lock-only', '--ignore-scripts'],
|
|
1185
|
+
},
|
|
1186
|
+
{ name: 'yarn.lock', tool: null },
|
|
1187
|
+
{ name: 'bun.lock', tool: null },
|
|
1188
|
+
{ name: 'bun.lockb', tool: null },
|
|
1189
|
+
]
|
|
1190
|
+
|
|
1191
|
+
/** The lockfile governing a member: in its directory, or up to its repository root (a workspace). */
|
|
1192
|
+
function findLockfile(member) {
|
|
1193
|
+
let { dir } = member
|
|
1194
|
+
for (;;) {
|
|
1195
|
+
const lock = LOCKFILES.find(({ name }) => existsSync(join(dir, name)))
|
|
1196
|
+
if (lock) return { ...lock, dir, path: join(dir, lock.name) }
|
|
1197
|
+
if (!member.repoDir || dir === member.repoDir || dir === dirname(dir)) return null
|
|
1198
|
+
dir = dirname(dir)
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function toolAvailable(tool) {
|
|
1203
|
+
return spawnSync(tool, ['--version'], { stdio: 'ignore', timeout: 20_000 }).status === 0
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* A package.json with its internal ranges moved to the versions the train is releasing.
|
|
1208
|
+
* Every map that names the dependency is rewritten from its own current range, and
|
|
1209
|
+
* `workspace:` ranges are left to the package manager. Returns null when nothing changes —
|
|
1210
|
+
* the rewrite of a resumed train is already committed.
|
|
1211
|
+
*
|
|
1212
|
+
* @param {string} text the manifest as read
|
|
1213
|
+
* @param {Map<string, string>} targets dependency name → the version it releases
|
|
1214
|
+
* @param {string} policy rangePolicy
|
|
1215
|
+
* @returns {{ text: string, changed: string[] } | null}
|
|
1216
|
+
*/
|
|
1217
|
+
export function rewriteManifest(text, targets, policy) {
|
|
1218
|
+
const manifest = JSON.parse(text)
|
|
1219
|
+
const changed = new Set()
|
|
1220
|
+
for (const key of DEPENDENCY_MAPS) {
|
|
1221
|
+
for (const [name, version] of targets) {
|
|
1222
|
+
const current = manifest[key]?.[name]
|
|
1223
|
+
if (current === undefined) continue
|
|
1224
|
+
const next = rewriteRange(policy, current, version)
|
|
1225
|
+
if (next === null || next === current) continue
|
|
1226
|
+
manifest[key][name] = next
|
|
1227
|
+
changed.add(`${name} ${next}`)
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
if (!changed.size) return null
|
|
1231
|
+
const indent = /^\{\r?\n([ \t]+)"/.exec(text)?.[1] ?? ' '
|
|
1232
|
+
return { text: `${JSON.stringify(manifest, null, indent)}\n`, changed: [...changed] }
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* The release-kit command line for one plan item. The version is always passed explicitly
|
|
1237
|
+
* — release-kit then releases exactly what the plan printed — except for a package with no
|
|
1238
|
+
* manifest version (Go), whose version only release-kit can read, from its tags.
|
|
1239
|
+
*/
|
|
1240
|
+
export function releaseArgs(item, { noAssistant = false } = {}) {
|
|
1241
|
+
const args = [item.next ?? 'auto', '--yes']
|
|
1242
|
+
// One member of several in its repository: release the directory, not the repository.
|
|
1243
|
+
if (item.member.sharesRepo) args.push('--package')
|
|
1244
|
+
// `publish: false` in the train config: versioned, tagged and released, never published.
|
|
1245
|
+
if (!item.member.publish) args.push('--skip', 'publish')
|
|
1246
|
+
// `--assistant none` is the one assistant value forwarded: a whole-train kill switch.
|
|
1247
|
+
// Forcing a drafting tool onto packages that did not opt in stays impossible.
|
|
1248
|
+
if (noAssistant) args.push('--assistant', 'none')
|
|
1249
|
+
return args
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const sleep = (seconds) =>
|
|
1253
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, seconds * 1000)
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Poll until `name@version` is on the registry. A dependent released before its dependency
|
|
1257
|
+
* is visible fails its own install or publish, and registries replicate with a lag.
|
|
1258
|
+
*/
|
|
1259
|
+
function waitForRegistry(name, version, { timeout, interval }) {
|
|
1260
|
+
const deadline = Date.now() + timeout * 1000
|
|
1261
|
+
for (;;) {
|
|
1262
|
+
const found = spawnSync('npm', ['view', `${name}@${version}`, 'version'], {
|
|
1263
|
+
encoding: 'utf8',
|
|
1264
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1265
|
+
timeout: 20_000,
|
|
1266
|
+
})
|
|
1267
|
+
if (found.status === 0) return true
|
|
1268
|
+
if (Date.now() + interval * 1000 > deadline) return false
|
|
1269
|
+
sleep(interval)
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Release the plan, in order. Each package: move its internal ranges onto the versions
|
|
1275
|
+
* just released and commit that, run release-kit, then — when a later package depends on
|
|
1276
|
+
* it — wait for the registry to show it. The first failure stops the train; everything
|
|
1277
|
+
* before it is fully released, and a re-run resumes (see TRAIN.md, Failure and resume).
|
|
1278
|
+
*
|
|
1279
|
+
* @returns {{ released: string[], failed: string | null, reason: string | null }}
|
|
1280
|
+
*/
|
|
1281
|
+
function executePlan(plan, { rangePolicy, registryWait, noAssistant, env = process.env }) {
|
|
1282
|
+
const released = []
|
|
1283
|
+
const nextById = new Map(plan.map((item) => [item.id, item.next]))
|
|
1284
|
+
const stop = (item, reason) => ({ released, failed: item.id, reason })
|
|
1285
|
+
|
|
1286
|
+
for (const [index, item] of plan.entries()) {
|
|
1287
|
+
const { member } = item
|
|
1288
|
+
console.log(`\n━━ ${index + 1}/${plan.length} ${item.id} ${item.next ?? '(version from tag)'}`)
|
|
1289
|
+
|
|
1290
|
+
const targets = new Map(
|
|
1291
|
+
item.rewrites
|
|
1292
|
+
.filter((r) => r.to)
|
|
1293
|
+
.map((r) => [plan.find((p) => p.id === r.dep).member.name, nextById.get(r.dep)]),
|
|
1294
|
+
)
|
|
1295
|
+
if (targets.size) {
|
|
1296
|
+
const manifestPath = join(member.dir, member.manifestFile)
|
|
1297
|
+
const rewritten = rewriteManifest(readFileSync(manifestPath, 'utf8'), targets, rangePolicy)
|
|
1298
|
+
if (rewritten) {
|
|
1299
|
+
writeFileSync(manifestPath, rewritten.text)
|
|
1300
|
+
const paths = [member.manifestFile]
|
|
1301
|
+
const lock = findLockfile(member)
|
|
1302
|
+
if (lock?.tool) {
|
|
1303
|
+
// pnpm looks for its workspace by walking up past the repository, and a meta-
|
|
1304
|
+
// workspace root may carry a pnpm-workspace.yaml of its own. A lockfile with no
|
|
1305
|
+
// workspace file beside it is standalone: refresh that one, not a parent's.
|
|
1306
|
+
const args =
|
|
1307
|
+
lock.tool === 'pnpm' && !existsSync(join(lock.dir, 'pnpm-workspace.yaml'))
|
|
1308
|
+
? [...lock.args, '--ignore-workspace']
|
|
1309
|
+
: lock.args
|
|
1310
|
+
const refresh = spawnSync(lock.tool, args, {
|
|
1311
|
+
cwd: lock.dir,
|
|
1312
|
+
env,
|
|
1313
|
+
encoding: 'utf8',
|
|
1314
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1315
|
+
})
|
|
1316
|
+
if (refresh.status !== 0) {
|
|
1317
|
+
process.stderr.write(`${refresh.stdout}${refresh.stderr}`)
|
|
1318
|
+
git(member.dir, ['checkout', '--', member.manifestFile], { allowFailure: true })
|
|
1319
|
+
return stop(item, `\`${lock.tool} ${args.join(' ')}\` failed refreshing ${lock.name}`)
|
|
1320
|
+
}
|
|
1321
|
+
paths.push(relative(member.dir, lock.path))
|
|
1322
|
+
}
|
|
1323
|
+
const subject = `chore(deps): move ${rewritten.changed.join(', ')}`
|
|
1324
|
+
const committed =
|
|
1325
|
+
git(member.dir, ['add', '--', ...paths], { allowFailure: true }) !== null &&
|
|
1326
|
+
git(member.dir, ['commit', '-m', subject, '--', ...paths], {
|
|
1327
|
+
allowFailure: true,
|
|
1328
|
+
}) !== null
|
|
1329
|
+
if (!committed)
|
|
1330
|
+
return stop(item, `could not commit the range rewrite in ${member.manifestFile}`)
|
|
1331
|
+
console.log(` ${subject}`)
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const run = spawnSync(process.execPath, [RELEASE_KIT, ...releaseArgs(item, { noAssistant })], {
|
|
1336
|
+
cwd: member.dir,
|
|
1337
|
+
env,
|
|
1338
|
+
stdio: 'inherit',
|
|
1339
|
+
})
|
|
1340
|
+
if (run.status !== 0) return stop(item, `release-kit exited with ${run.status ?? run.signal}`)
|
|
1341
|
+
released.push(item.id)
|
|
1342
|
+
|
|
1343
|
+
const awaited = plan
|
|
1344
|
+
.slice(index + 1)
|
|
1345
|
+
.some((later) => later.rewrites.some((r) => r.dep === item.id))
|
|
1346
|
+
if (awaited && member.ecosystem === 'npm' && member.publish && member.name && item.next) {
|
|
1347
|
+
console.log(` waiting for ${member.name}@${item.next} on the registry…`)
|
|
1348
|
+
if (!waitForRegistry(member.name, item.next, registryWait)) {
|
|
1349
|
+
return stop(
|
|
1350
|
+
item,
|
|
1351
|
+
`${member.name}@${item.next} was released but is not on the registry after ${registryWait.timeout}s`,
|
|
1352
|
+
)
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
return { released, failed: null, reason: null }
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function printExecution(plan, { released, failed, reason }) {
|
|
1360
|
+
if (!failed) {
|
|
1361
|
+
console.log(
|
|
1362
|
+
`\nTrain released — ${released.length} package${released.length === 1 ? '' : 's'}: ${released.join(', ')}`,
|
|
1363
|
+
)
|
|
1364
|
+
return
|
|
1365
|
+
}
|
|
1366
|
+
const notStarted = plan
|
|
1367
|
+
.map((item) => item.id)
|
|
1368
|
+
.filter((id) => id !== failed && !released.includes(id))
|
|
1369
|
+
console.log(`\nTRAIN STOPPED at ${failed}: ${reason}`)
|
|
1370
|
+
if (released.length) console.log(` released: ${released.join(', ')}`)
|
|
1371
|
+
console.log(` stopped: ${failed}`)
|
|
1372
|
+
if (notStarted.length) console.log(` not started: ${notStarted.join(', ')}`)
|
|
1373
|
+
console.log(
|
|
1374
|
+
'\n Fix the cause and run the same command again: released packages are skipped, and the\n' +
|
|
1375
|
+
` one that stopped is finished by release-kit from wherever it got to.`,
|
|
1376
|
+
)
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1104
1379
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1105
1380
|
// Output
|
|
1106
1381
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1165,16 +1440,18 @@ function printSeedResults(results, dryRun) {
|
|
|
1165
1440
|
console.log(`\n${refusedOrErrored} member(s) refused or failed — see above.`)
|
|
1166
1441
|
}
|
|
1167
1442
|
|
|
1168
|
-
const HELP = `release-train
|
|
1443
|
+
const HELP = `release-train — release interdependent packages in dependency order
|
|
1169
1444
|
|
|
1170
|
-
train
|
|
1445
|
+
train plan, preflight, confirm, then release everything that changed
|
|
1446
|
+
train --yes the same without the confirmation prompt (required without a terminal)
|
|
1171
1447
|
train --dry-run plan + whole-train preflight, execute nothing
|
|
1172
|
-
train --
|
|
1173
|
-
train
|
|
1448
|
+
train --all every member, not just the changed ones
|
|
1449
|
+
train <id>... these packages and their dependents
|
|
1450
|
+
train graph print the derived dependency graph and topo order
|
|
1174
1451
|
train seed-tags create baseline tags (add --dry-run to preview)
|
|
1175
1452
|
train --summary <path> write the train summary (markdown) here; overrides summaryFile
|
|
1176
1453
|
train --assistant <name> none (whole-train kill switch), auto, claude, codex
|
|
1177
|
-
train --offline skip network work (registry lookups, tag pushes)
|
|
1454
|
+
train --offline skip network work (registry lookups, tag pushes); dry runs only
|
|
1178
1455
|
train --config <path> config file (default ./train.config.json)
|
|
1179
1456
|
`
|
|
1180
1457
|
|
|
@@ -1182,7 +1459,7 @@ const HELP = `release-train (prototype — read-only phases, seed-tags, and the
|
|
|
1182
1459
|
// Main
|
|
1183
1460
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1184
1461
|
|
|
1185
|
-
function main() {
|
|
1462
|
+
async function main() {
|
|
1186
1463
|
const args = parseArgs(process.argv.slice(2))
|
|
1187
1464
|
if (args.help) {
|
|
1188
1465
|
console.log(HELP)
|
|
@@ -1209,6 +1486,7 @@ function main() {
|
|
|
1209
1486
|
if (member.repoDir)
|
|
1210
1487
|
repoMemberCounts.set(member.repoDir, (repoMemberCounts.get(member.repoDir) ?? 0) + 1)
|
|
1211
1488
|
}
|
|
1489
|
+
for (const member of members) member.sharesRepo = (repoMemberCounts.get(member.repoDir) ?? 0) > 1
|
|
1212
1490
|
const registry = gatherRegistry(members, args.offline)
|
|
1213
1491
|
|
|
1214
1492
|
if (args.command === 'seed-tags') {
|
|
@@ -1224,8 +1502,6 @@ function main() {
|
|
|
1224
1502
|
return
|
|
1225
1503
|
}
|
|
1226
1504
|
|
|
1227
|
-
if (!args.dryRun) fail('execution is not implemented yet — run with --dry-run')
|
|
1228
|
-
|
|
1229
1505
|
const changes = new Map()
|
|
1230
1506
|
for (const member of members) {
|
|
1231
1507
|
if (!member.repoDir) continue
|
|
@@ -1259,18 +1535,37 @@ function main() {
|
|
|
1259
1535
|
})
|
|
1260
1536
|
printPlan(plan, result)
|
|
1261
1537
|
|
|
1538
|
+
let execution = null
|
|
1539
|
+
if (!args.dryRun && !result.failures.length && plan.length) {
|
|
1540
|
+
if (!args.yes) {
|
|
1541
|
+
if (!process.stdin.isTTY)
|
|
1542
|
+
fail('no terminal to confirm on — pass --yes to release the plan above')
|
|
1543
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout })
|
|
1544
|
+
const answer = await readline.question(
|
|
1545
|
+
`\nRelease ${plan.length} package${plan.length === 1 ? '' : 's'} in this order? [y/N] `,
|
|
1546
|
+
)
|
|
1547
|
+
readline.close()
|
|
1548
|
+
if (!/^y(es)?$/i.test(answer.trim())) fail('aborted — nothing was released')
|
|
1549
|
+
}
|
|
1550
|
+
execution = executePlan(plan, {
|
|
1551
|
+
rangePolicy: config.rangePolicy,
|
|
1552
|
+
registryWait: config.registryWait,
|
|
1553
|
+
noAssistant: args.assistant === null,
|
|
1554
|
+
})
|
|
1555
|
+
printExecution(plan, execution)
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1262
1558
|
// Train summary: deterministic always; announcement drafted only when an assistant is
|
|
1263
1559
|
// configured (or forced via --assistant) and never blocking. --assistant none is the
|
|
1264
|
-
// whole-train kill switch
|
|
1265
|
-
// release-kit run, and it is the ONLY assistant value that is forwarded: forcing a
|
|
1266
|
-
// drafting tool onto packages that did not opt in stays impossible by design.
|
|
1560
|
+
// whole-train kill switch, and executePlan forwards it to every release-kit run.
|
|
1267
1561
|
const summaryPath = args.summaryPath ?? config.summaryFile
|
|
1268
1562
|
if (summaryPath) {
|
|
1269
1563
|
const date = new Date().toISOString().slice(0, 10)
|
|
1270
|
-
|
|
1564
|
+
const shipped = execution ? plan.filter((item) => execution.released.includes(item.id)) : plan
|
|
1565
|
+
let summary = buildSummary(shipped, {
|
|
1271
1566
|
workspace: basename(rootDir),
|
|
1272
1567
|
date,
|
|
1273
|
-
mode: 'planned (dry run)',
|
|
1568
|
+
mode: execution ? 'released' : args.dryRun ? 'planned (dry run)' : 'planned, not released',
|
|
1274
1569
|
})
|
|
1275
1570
|
const assistant = resolveAssistant(
|
|
1276
1571
|
args.assistant === undefined ? config.assistant : args.assistant,
|
|
@@ -1287,7 +1582,7 @@ function main() {
|
|
|
1287
1582
|
console.log(`\nTrain summary written to ${summaryPath}`)
|
|
1288
1583
|
}
|
|
1289
1584
|
|
|
1290
|
-
process.exitCode = result.failures.length ? 1 : 0
|
|
1585
|
+
process.exitCode = result.failures.length || execution?.failed ? 1 : 0
|
|
1291
1586
|
}
|
|
1292
1587
|
|
|
1293
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main()
|
|
1588
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main()
|