@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.
Files changed (5) hide show
  1. package/README.md +221 -42
  2. package/TRAIN.md +78 -41
  3. package/package.json +3 -3
  4. package/release.mjs +671 -47
  5. 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, prototype.
3
+ * release-train — orchestrated releases for interdependent packages.
4
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.
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 --dry-run --all plan every member, not just changed ones
13
- * train --dry-run <id>... plan these packages and their dependents
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 { pathToFileURL } from 'node:url'
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
- tagPrefix: releaseConfig.tagPrefix ?? 'v',
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` (`v` unless its release.config.json says
454
- * otherwise) when the repo owns exactly one member, `<name>@<version>` when it owns
455
- * several — which keeps single-package repos identical to standalone release-kit.
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
- if (repoMemberCount > 1) return { prefix: `${member.name}@`, glob: `${member.name}@*` }
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` is 'planned' until execution exists.
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 (prototype — read-only phases, seed-tags, and the train summary)
1443
+ const HELP = `release-train — release interdependent packages in dependency order
1169
1444
 
1170
- train graph print the derived dependency graph and topo order
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 --dry-run --all plan every member
1173
- train --dry-run <id>... plan these packages and their dependents
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 — when execution lands it is also forwarded to every
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
- let summary = buildSummary(plan, {
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()