rvlib-shipkit 0.14.0 → 0.16.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 (65) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +6 -22
  3. package/package.json +1 -1
  4. package/src/assets/kits/identity-kit.ts +1 -1
  5. package/src/assets/panels/geometry-rects.ts +3 -17
  6. package/src/assets/server/studio-server.ts +1 -1
  7. package/src/cli/cmd_Bump.ts +52 -0
  8. package/src/cli/cmd_Check.ts +2 -2
  9. package/src/cli/cmd_Ci.ts +10 -2
  10. package/src/cli/cmd_Commands.ts +126 -0
  11. package/src/cli/cmd_Hooks.ts +66 -0
  12. package/src/cli/cmd_Mv.ts +29 -6
  13. package/src/cli/cmd_Release.ts +41 -0
  14. package/src/cli/cmd_Releases.ts +42 -0
  15. package/src/cli/cmd_Rules.ts +20 -6
  16. package/src/cli/cmd_Start.ts +2 -2
  17. package/src/cli.ts +14 -1
  18. package/src/collections/posts.ts +1 -1
  19. package/src/collections/providers.ts +1 -1
  20. package/src/contracts/manifests/discord.contract.ts +1 -1
  21. package/src/contracts/manifests/itch.contract.ts +2 -4
  22. package/src/core/canonical-scripts.ts +9 -14
  23. package/src/core/check-suite.ts +1 -1
  24. package/src/core/discover-checks.ts +21 -6
  25. package/src/core/run-check.ts +69 -21
  26. package/src/core/run-ci.ts +75 -9
  27. package/src/core/run-fixers.ts +144 -0
  28. package/src/core/write-status.ts +14 -5
  29. package/src/hooks/commit-message.ts +42 -0
  30. package/src/hooks/leak-guard.ts +0 -0
  31. package/src/hooks/run-hooks.ts +131 -0
  32. package/src/init-repo.ts +10 -5
  33. package/src/reflect/prompt-corpus.ts +3 -4
  34. package/src/release/changelog.ts +53 -0
  35. package/src/release/git-facts.ts +133 -0
  36. package/src/release/run-bump.ts +419 -0
  37. package/src/release/run-release.ts +267 -0
  38. package/src/release/run-releases.ts +230 -0
  39. package/src/release/unit-gate.ts +67 -0
  40. package/src/release/units.ts +246 -0
  41. package/src/repo-config.ts +56 -2
  42. package/src/rules/SK001-package-json-shape.ts +39 -17
  43. package/src/rules/SK002-contracts-fulfillness.ts +1 -0
  44. package/src/rules/SK003-claude-md-shape.ts +2 -1
  45. package/src/rules/SK004-gitignore-policy.ts +1 -0
  46. package/src/rules/SK005-rvlib-versions.ts +22 -3
  47. package/src/rules/SK006-npm-publish-safety.ts +1 -0
  48. package/src/rules/SK007-github-visibility.ts +5 -1
  49. package/src/rules/SK008-reviewer-agent.ts +1 -0
  50. package/src/rules/SK009-commit-ai-tells.ts +66 -0
  51. package/src/rules/SK010-undeclared-release-unit.ts +114 -0
  52. package/src/rules/rule-registry.ts +26 -6
  53. package/src/rules/rule.ts +8 -1
  54. package/src/rules/run-fix.ts +15 -8
  55. package/src/serve/hub-app/col-card.tsx +2 -3
  56. package/src/serve/hub-app/hub-app.tsx +6 -8
  57. package/src/serve/hub-app/hub-st.ts +4 -7
  58. package/src/serve/hub-app/media-view.tsx +2 -2
  59. package/src/serve/run-serve.ts +4 -5
  60. package/src/serve/serve-hub.ts +2 -3
  61. package/src/shipkit.ts +9 -1
  62. package/src/tui/checks-panel.ts +1 -1
  63. package/src/tui/run-tui.ts +2 -3
  64. package/src/tui/tasks-panel.ts +10 -4
  65. package/src/tui/tui-store.ts +2 -2
@@ -0,0 +1,419 @@
1
+ // the whole release from one word. each step is trivial, the sequence is
2
+ // what costs time and produces half-released states, so the sequence is the
3
+ // command.
4
+ //
5
+ // order is load-bearing. The MERGE happens first, because every fact a bump
6
+ // writes (the next version, the tag, the per-unit gate) must describe the
7
+ // merged tree, never the branch it was computed on. Nothing is pushed until
8
+ // the very end, so any refusal rolls the merge back and returns you to your
9
+ // branch. The gate then runs before anything is written, so a red repo never
10
+ // gets a version bump commit, and the prepublish scripts run before the
11
+ // commit, so a failing build cannot be tagged. publishing is delegated to
12
+ // run-release.
13
+ //
14
+ // --dry-run is the ONE exception, and deliberately so: it is the command you
15
+ // run BECAUSE you do not want to touch anything, so a rehearsal that merges
16
+ // and rolls back is wrong even when the rollback works. It probes the merge
17
+ // in the object database instead (git-facts mergeProbe) and gates the
18
+ // UNMERGED tree, saying so. That is strictly less proof than a real bump,
19
+ // which is why the report states what it did NOT cover.
20
+ //
21
+ // every window where the release branch carries an unagreed merge is closed
22
+ // on the way out: the rollback reads both git exit codes, and an interrupt
23
+ // during the gate or the prepublish scripts runs it too.
24
+ //
25
+ // with several units the ATOMIC part is the version commit: one commit holds
26
+ // every selected unit's package.json and changelog. The publish part is
27
+ // RESUMABLE instead, because an npm publish cannot be undone and a rollback
28
+ // that cannot roll back is a lie.
29
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
30
+ import { relative } from 'path'
31
+ import { hasUnreleasedNotes, promoteUnreleased } from './changelog.ts'
32
+ import { deriveRemote, dirtyPaths, git as gitAt, mergeProbe, remoteTags, tagExistsLocally } from './git-facts.ts'
33
+ import { unitBlockers } from './unit-gate.ts'
34
+ import { formatUnitError, releaseOrder, renderTag, resolveUnits, unitById, unitPrepublishScripts, type ReleaseUnit } from './units.ts'
35
+ import { environmentBlockers, runReleaseGate, releaseUnit } from './run-release.ts'
36
+ import type { RepoConfig } from '../repo-config.ts'
37
+
38
+ export type BumpKind = 'major' | 'minor' | 'patch'
39
+
40
+ /** next version, X.Y.Z only. A prerelease or build suffix is refused rather
41
+ * than silently mangled: guessing what `1.2.3-beta.1` plus "minor" means is
42
+ * exactly the kind of doubt this tool exists to remove. */
43
+ export function nextVersion(current: string, kind: BumpKind): string {
44
+ const m = current.match(/^(\d+)\.(\d+)\.(\d+)$/)
45
+ if (m == null) throw new Error(`version '${current}' is not plain X.Y.Z — bump it by hand, then release`)
46
+ const [maj, min, pat] = [Number(m[1]), Number(m[2]), Number(m[3])]
47
+ if (kind === 'major') return `${maj + 1}.0.0`
48
+ if (kind === 'minor') return `${maj}.${min + 1}.0`
49
+ return `${maj}.${min}.${pat + 1}`
50
+ }
51
+
52
+ export type BumpOpts = {
53
+ /** rehearse: gate and prepublish scripts run, nothing is written, and no
54
+ * merge is made — the merge is probed in the object database instead */
55
+ dryRun?: boolean
56
+ /** stop after committing the bump, do not tag or publish */
57
+ noRelease?: boolean
58
+ /** refuse on a feature branch instead of merging it into the release branch */
59
+ noMerge?: boolean
60
+ /** which unit to bump (default 'root') */
61
+ unit?: string
62
+ /** bump every unit whose changelog carries non-empty Unreleased notes */
63
+ all?: boolean
64
+ print?: (line: string) => void
65
+ }
66
+
67
+ type Git = (args: string[]) => { code: number; out: string }
68
+
69
+ /** bring a feature branch onto the release branch with a merge commit, and
70
+ * hand back a rollback. nothing here is pushed, so a later failure can undo
71
+ * the merge completely and put the caller back where it started. */
72
+ function mergeIntoReleaseBranch(
73
+ git: Git,
74
+ p: { from: string; to: string; remote: string; print: (l: string) => void },
75
+ ): { ok: true; rollback: () => void } | { ok: false } {
76
+ const { from, to, remote, print } = p
77
+ git(['fetch', remote, '--quiet'])
78
+ // every return-to-the-branch is checked: a silent checkout failure leaves
79
+ // HEAD on the release branch while the line above says otherwise, and the
80
+ // operator's next commit lands there
81
+ const restore = (): boolean => {
82
+ const r = git(['checkout', '--quiet', from])
83
+ if (r.code === 0) return true
84
+ print(`FAIL: could not return to '${from}' (git checkout exited ${r.code}) — HEAD is STILL on '${to}', your next commit would land there`)
85
+ for (const l of r.out.split('\n').slice(0, 4)) print(` ${l}`)
86
+ print(` fix: git checkout ${from}`)
87
+ return false
88
+ }
89
+
90
+ const co = git(['checkout', '--quiet', to])
91
+ if (co.code !== 0) { print(`FAIL: cannot switch to '${to}' — ${co.out}`); return { ok: false } }
92
+
93
+ // fast-forward only: a release branch that diverged from its remote is a
94
+ // human decision, never something a release command resolves
95
+ const remoteHead = git(['rev-parse', '--quiet', '--verify', `${remote}/${to}`])
96
+ if (remoteHead.code === 0) {
97
+ const ff = git(['merge', '--ff-only', `${remote}/${to}`])
98
+ if (ff.code !== 0) {
99
+ print(`FAIL: '${to}' has diverged from ${remote}/${to} — reconcile it by hand`)
100
+ restore()
101
+ return { ok: false }
102
+ }
103
+ }
104
+ const before = git(['rev-parse', 'HEAD']).out
105
+ const merge = git(['merge', '--no-ff', from, '-m', `merge ${from}`])
106
+ if (merge.code !== 0) {
107
+ print(`FAIL: merging '${from}' into '${to}' hit conflicts, nothing was changed`)
108
+ for (const l of merge.out.split('\n').slice(0, 8)) print(` ${l}`)
109
+ git(['merge', '--abort'])
110
+ restore()
111
+ return { ok: false }
112
+ }
113
+ print(`ok: merged '${from}' into '${to}' (merge commit, not pushed yet)`)
114
+ return {
115
+ ok: true,
116
+ rollback: () => {
117
+ // the merge was never pushed, so this drops only local work. Both
118
+ // calls are READ: an index.lock from a concurrent run makes the
119
+ // reset fail, and announcing a rollback that did not happen is how
120
+ // a merge nobody agreed to gets pushed by the next command.
121
+ const reset = git(['reset', '--hard', before])
122
+ if (reset.code !== 0) {
123
+ print(`FAIL: rollback FAILED — '${to}' STILL carries the merge (git reset --hard exited ${reset.code})`)
124
+ for (const l of reset.out.split('\n').slice(0, 4)) print(` ${l}`)
125
+ print(` fix: git reset --hard ${before} && git checkout ${from}`)
126
+ return
127
+ }
128
+ if (restore()) print(`ok: rolled '${to}' back and returned to '${from}' — nothing was pushed`)
129
+ },
130
+ }
131
+ }
132
+
133
+ /** the rehearsal's answer to the same two questions, from the object database:
134
+ * has the release branch diverged from its remote, and would the merge
135
+ * conflict. Nothing is checked out and no ref moves, which is the whole point
136
+ * of a dry run. It must answer for the tree the REAL path would build, so a
137
+ * release branch that exists only on the remote resolves to the remote ref:
138
+ * that is exactly what `git checkout <to>` creates. `refuse` stops the run,
139
+ * and `inconclusive` forbids the closing "dry run clean" claim. */
140
+ type MergeRehearsal = { refuse: true } | { refuse: false; inconclusive: boolean }
141
+
142
+ function probeMergeIntoReleaseBranch(
143
+ root: string,
144
+ git: Git,
145
+ p: { from: string; to: string; remote: string; print: (l: string) => void },
146
+ ): MergeRehearsal {
147
+ const { from, to, remote, print } = p
148
+ git(['fetch', remote, '--quiet'])
149
+ const remoteRef = `${remote}/${to}`
150
+ const localExists = git(['rev-parse', '--quiet', '--verify', to]).code === 0
151
+ const remoteExists = git(['rev-parse', '--quiet', '--verify', remoteRef]).code === 0
152
+ if (!localExists && !remoteExists) {
153
+ print(`FAIL: no branch '${to}' here or on '${remote}' — a release needs a branch to ship from`)
154
+ return { refuse: true }
155
+ }
156
+ let tip = localExists ? to : remoteRef
157
+ if (localExists && remoteExists) {
158
+ // the real path fast-forwards, which succeeds when either side is an
159
+ // ancestor of the other and refuses only on a genuine divergence
160
+ const behind = git(['merge-base', '--is-ancestor', to, remoteRef]).code === 0
161
+ const ahead = git(['merge-base', '--is-ancestor', remoteRef, to]).code === 0
162
+ if (!behind && !ahead) { print(`FAIL: '${to}' has diverged from ${remoteRef} — reconcile it by hand`); return { refuse: true } }
163
+ if (behind) tip = remoteRef
164
+ }
165
+ const probe = mergeProbe(root, tip, from)
166
+ if (probe.state === 'conflicts') {
167
+ print(`FAIL: '${from}' would conflict with '${to}' — nothing was merged, nothing was changed`)
168
+ for (const path of probe.paths.slice(0, 8)) print(` ${path}`)
169
+ return { refuse: true }
170
+ }
171
+ if (probe.state === 'unknown') {
172
+ print(`WARN cannot check the merge without touching the worktree (${probe.reason}) — the rehearsal does not know whether '${from}' merges into '${to}'`)
173
+ return { refuse: false, inconclusive: true }
174
+ }
175
+ print(`note: '${from}' merges cleanly into '${to}' (checked in the object database, no ref moved)`)
176
+ return { refuse: false, inconclusive: false }
177
+ }
178
+
179
+ type Selected = { unit: ReleaseUnit; version: string; changelogRel: string }
180
+
181
+ export async function runBump(root: string, config: RepoConfig, kind: BumpKind, o: BumpOpts = {}): Promise<number> {
182
+ const print = o.print ?? ((l: string) => console.log(l))
183
+ if (o.all === true && o.unit != null) { print('FAIL: pass a unit or --all, never both'); return 1 }
184
+
185
+ const git: Git = (args) => gitAt(root, args)
186
+ const cur = git(['rev-parse', '--abbrev-ref', 'HEAD']).out
187
+ const branch = config.release?.branch ?? 'main'
188
+
189
+ // pre-merge, ONLY what a merge cannot change: a typo'd unit id, a dirty
190
+ // tree, the branch decision, the remote. Everything the bump WRITES is
191
+ // computed after the merge, off the tree that actually ships.
192
+ const onDisk = resolveUnits(root, config)
193
+ if (onDisk.errors.length > 0) { for (const e of onDisk.errors) print(`FAIL: ${formatUnitError(e)}`); return 1 }
194
+ if (o.all !== true) {
195
+ const id = o.unit ?? 'root'
196
+ if (unitById(onDisk.units, id) == null) {
197
+ print(`FAIL: no release unit '${id}' — known units: ${onDisk.units.map((x) => x.id).join(', ')}`)
198
+ print(' fix: shipkit releases')
199
+ return 1
200
+ }
201
+ }
202
+ // the tree must be clean BEFORE any branch move, or a checkout carries
203
+ // uncommitted work across branches
204
+ const dirty = dirtyPaths(root, config)
205
+ if (dirty !== '') { print(`FAIL: working tree not clean, commit your work first:\n${dirty.split('\n').slice(0, 10).join('\n')}`); return 1 }
206
+
207
+ const wantMerge = cur !== branch && o.noMerge !== true && (config.release?.mergeFromBranch ?? true)
208
+ if (cur !== branch && !wantMerge) { print(`FAIL: releases ship from '${branch}', HEAD is on '${cur}'`); return 1 }
209
+ const remote = deriveRemote(root)
210
+ if ('error' in remote) { print(`FAIL: ${remote.error}`); return 1 }
211
+ if (wantMerge) print(`note: on '${cur}', so '${cur}' merges into '${branch}' first`)
212
+
213
+ // the merge happens FIRST among the writes, so every fact below describes
214
+ // the tree that ships. Nothing here is pushed, so every refusal rolls it
215
+ // back whole. A rehearsal merges NOTHING and says what it therefore did
216
+ // not prove (the file header owns the reasoning).
217
+ const rehearsal = o.dryRun === true
218
+ let rollback: (() => void) | null = null
219
+ let onInterrupt: (() => void) | null = null
220
+ let mergeUnknown = false
221
+ if (wantMerge && rehearsal) {
222
+ const probe = probeMergeIntoReleaseBranch(root, git, { from: cur, to: branch, remote: remote.remote, print })
223
+ if (probe.refuse) return 1
224
+ mergeUnknown = probe.inconclusive
225
+ } else if (wantMerge) {
226
+ const m = mergeIntoReleaseBranch(git, { from: cur, to: branch, remote: remote.remote, print })
227
+ if (!m.ok) return 1
228
+ rollback = m.rollback
229
+ // the merge window spans the gate and the prepublish scripts, and
230
+ // nothing there handles an interrupt: Ctrl-C used to leave the merge
231
+ // on the release branch with not a line printed about it
232
+ onInterrupt = () => { print(`FAIL: interrupted — rolling '${branch}' back`); rollback?.(); process.exit(130) }
233
+ process.on('SIGINT', onInterrupt)
234
+ process.on('SIGTERM', onInterrupt)
235
+ }
236
+ // shut the window: after this the merge is either undone or permanent, and
237
+ // an interrupt must never reset --hard over files the operator has to keep
238
+ const closeMergeWindow = (): void => {
239
+ if (onInterrupt != null) { process.off('SIGINT', onInterrupt); process.off('SIGTERM', onInterrupt); onInterrupt = null }
240
+ rollback = null
241
+ }
242
+ const rollBack = (): void => { rollback?.(); closeMergeWindow() }
243
+ const fail = (msg: string, fixLine?: string): number => {
244
+ print(`FAIL: ${msg}`)
245
+ if (fixLine != null) print(` fix: ${fixLine}`)
246
+ rollBack()
247
+ return 1
248
+ }
249
+
250
+ // phase 1, on the MERGED tree (on the FEATURE tree in a rehearsal): the
251
+ // resolution, the selection, the next versions, the tag refusals and the
252
+ // per-unit gate all describe what ships
253
+ const resolved = resolveUnits(root, config)
254
+ // bump can touch several units at once, so ANY unresolved unit is fatal:
255
+ // a selection built from a partial model is a selection nobody can trust
256
+ if (resolved.errors.length > 0) { for (const e of resolved.errors) print(`FAIL: ${formatUnitError(e)}`); rollBack(); return 1 }
257
+ const all = releaseOrder(resolved.units)
258
+
259
+ // selection. `--all` reads the changelogs: a unit with nothing to say is
260
+ // skipped, not failed, so `--all` means "ship everything that has news".
261
+ let picked: ReleaseUnit[]
262
+ if (o.all === true) {
263
+ picked = []
264
+ for (const u of all) {
265
+ if (!existsSync(u.changelog)) { print(`skip: ${u.id} — no changelog at ${relative(root, u.changelog)}`); continue }
266
+ if (hasUnreleasedNotes(readFileSync(u.changelog, 'utf8'))) picked.push(u)
267
+ else print(`skip: ${u.id} — no Unreleased notes`)
268
+ }
269
+ if (picked.length === 0) return fail('no unit has non-empty Unreleased notes — write what changed, then bump')
270
+ } else {
271
+ const id = o.unit ?? 'root'
272
+ const u = unitById(all, id)
273
+ if (u == null) return fail(`no release unit '${id}' after the merge — known units: ${all.map((x) => x.id).join(', ')}`, 'shipkit releases')
274
+ picked = [u]
275
+ }
276
+
277
+ // one ls-remote for the whole run: a tag name the remote already carries
278
+ // is a burnt version, and finding that out AFTER the version commit leaves
279
+ // a number nobody can ship
280
+ const onRemote = remoteTags(root, remote.remote)
281
+ if ('error' in onRemote) return fail(`cannot read tags on '${remote.remote}' (${onRemote.error}) — a bump that cannot see the remote's tags can burn a version`)
282
+
283
+ const blockers: string[] = []
284
+ const selected: Selected[] = []
285
+ for (const unit of picked) {
286
+ let version: string
287
+ try { version = nextVersion(unit.version, kind) }
288
+ catch (e) { blockers.push(`unit '${unit.id}': ${e instanceof Error ? e.message : String(e)}`); continue }
289
+ const changelogRel = relative(root, unit.changelog)
290
+ if (!existsSync(unit.changelog)) { blockers.push(`unit '${unit.id}': ${changelogRel} not found — a release states what changed`); continue }
291
+ const promoted = promoteUnreleased(readFileSync(unit.changelog, 'utf8'), version)
292
+ if ('error' in promoted) { blockers.push(`unit '${unit.id}': ${promoted.error}`); continue }
293
+ const tag = renderTag(unit, version)
294
+ if (tagExistsLocally(root, tag)) {
295
+ blockers.push(`unit '${unit.id}': tag ${tag} already exists here — finish that release with \`shipkit release ${unit.id}\`, or pick another bump`)
296
+ continue
297
+ }
298
+ if (onRemote.tags.has(tag)) {
299
+ blockers.push(`unit '${unit.id}': tag ${tag} already exists on '${remote.remote}' — that version number is taken, pick another bump`)
300
+ continue
301
+ }
302
+ blockers.push(...unitBlockers(root, config, unit))
303
+ selected.push({ unit, version, changelogRel })
304
+ }
305
+ // everything the release needs that does not depend on the new version.
306
+ // HEAD is the release branch by now, so the branch check applies — except
307
+ // in a rehearsal that deliberately stayed on the feature branch, where the
308
+ // branch decision was already made and refused above
309
+ blockers.push(...environmentBlockers(root, config, {
310
+ wantGh: picked.some((u) => u.targets.githubRelease),
311
+ skipBranch: rehearsal && wantMerge,
312
+ }))
313
+ if (blockers.length > 0) { for (const b of blockers) print(`FAIL: ${b}`); rollBack(); return 1 }
314
+
315
+ for (const s of selected) print(`note: ${s.unit.id} ${s.unit.version} → ${s.version} (${kind})`)
316
+
317
+ // phase 2: ONE gate for the whole repo. The repo is one worktree, so N
318
+ // gates cost N times as much and prove the same thing. Frozen, and fatal
319
+ // on non-blocking failures when the repo declared requireHealthy.
320
+ // releaseUnit then skips it, because bump already proved this exact tree.
321
+ if ((await runReleaseGate(root, config, print, { fix: true })) !== 0) return fail('ci gate is red on the merged result — fix it, then bump')
322
+ // the gate's fix pass can rewrite files. writing a version on top of that
323
+ // produces a version commit that excludes the rewrite and a release that
324
+ // then refuses on a dirty tree, which is a half-released repo.
325
+ const afterGate = dirtyPaths(root, config)
326
+ if (afterGate !== '')
327
+ return fail(
328
+ `the gate rewrote files, so the tree is no longer clean:\n${afterGate.split('\n').slice(0, 10).join('\n')}`,
329
+ rollback != null
330
+ ? 'the rollback below discards them. run `shipkit ci` on your branch, commit what it rewrites, then bump again'
331
+ : 'run `shipkit ci`, commit what it rewrites, then bump again',
332
+ )
333
+ print('ok: ci gate green')
334
+
335
+ for (const s of selected) {
336
+ for (const script of unitPrepublishScripts(s.unit)) {
337
+ const r = Bun.spawnSync(['bun', 'run', script], { cwd: s.unit.dir, stdout: 'inherit', stderr: 'inherit' })
338
+ if (r.exitCode !== 0) return fail(`${s.unit.id}: ${script} failed (exit ${r.exitCode}) — nothing was written`)
339
+ print(`ok: ${s.unit.id} ${script}`)
340
+ }
341
+ }
342
+ if (selected.every((s) => unitPrepublishScripts(s.unit).length === 0)) print('note: no prepublish scripts')
343
+
344
+ if (rehearsal) {
345
+ const what = selected.map((s) => `${s.unit.id} ${s.version}`).join(', ')
346
+ // a rehearsal that could not answer the merge question must not close
347
+ // with the word "clean": that green is what sends an operator into a
348
+ // real bump that aborts mid-merge
349
+ if (mergeUnknown) print(`WARN dry run inconclusive — would write ${what}, but the merge into '${branch}' could not be checked (git 2.38+ answers it)`)
350
+ else print(`ok: dry run clean — would write ${what}, commit, then ${o.noRelease === true ? 'stop' : 'release'}`)
351
+ // what the rehearsal did NOT cover, said out loud: a merge that never
352
+ // happened cannot have been gated, and a number computed on the
353
+ // feature branch can differ from the one the real bump writes
354
+ if (wantMerge)
355
+ print(` it did NOT merge: '${branch}' never moved, and everything above (versions, tags, gate) describes '${cur}' UNMERGED`)
356
+ rollBack()
357
+ return 0
358
+ }
359
+
360
+ // phase 3: ONE version commit holding every selected unit's two files.
361
+ // Partial writes are the half-released state this whole command exists to
362
+ // prevent, so the write and the commit are one step for the whole selection.
363
+ //
364
+ // from the first write on, a failure NEVER rolls back: `git reset --hard`
365
+ // would delete the very files the fix line tells the operator to commit.
366
+ // What is on disk is reported as it is, interrupts included.
367
+ closeMergeWindow()
368
+ const failKeep = (msg: string, fixLine: string): number => { print(`FAIL: ${msg}`); print(` fix: ${fixLine}`); return 1 }
369
+ const files: string[] = []
370
+ for (const s of selected) {
371
+ const pkgRel = s.unit.rel === '' ? 'package.json' : `${s.unit.rel}/package.json`
372
+ // re-read: a merge can have changed both files under us
373
+ const pkgNow = readFileSync(`${root}/${pkgRel}`, 'utf8')
374
+ const promoted = promoteUnreleased(readFileSync(s.unit.changelog, 'utf8'), s.version)
375
+ if ('error' in promoted) return failKeep(`${s.unit.id}: ${promoted.error}`, `check ${s.changelogRel}, then run bump again`)
376
+ // indentation is preserved by rewriting only the version VALUE, so a bump
377
+ // never reformats a file the repo cares about
378
+ const bumped = pkgNow.replace(/("version"\s*:\s*")[^"]*(")/, `$1${s.version}$2`)
379
+ if (bumped === pkgNow) return failKeep(`${s.unit.id}: could not rewrite the version field in ${pkgRel}`, `git status, then set the version by hand`)
380
+ writeFileSync(`${root}/${pkgRel}`, bumped)
381
+ writeFileSync(s.unit.changelog, promoted.text)
382
+ files.push(pkgRel, s.changelogRel)
383
+ print(`ok: ${pkgRel} ${s.version}, ${s.changelogRel} section promoted`)
384
+ }
385
+
386
+ const add = git(['add', ...files])
387
+ if (add.code !== 0) return failKeep('git add failed', 'the version files are written but unstaged. git status, then commit them')
388
+ const label = selected.map((s) => (s.unit.id === 'root' ? s.version : `${s.unit.id}@${s.version}`)).join(', ')
389
+ // the hooks already ran through the gate above; --no-verify is NOT used,
390
+ // so the repo's own commit gates still apply to this commit
391
+ const commit = git(['commit', '-m', `release ${label}`])
392
+ if (commit.code !== 0)
393
+ return failKeep(`commit failed — ${commit.out}`, 'the version files are written but uncommitted. fix the gate, commit, then `shipkit release`')
394
+ print(`ok: committed release ${label}`)
395
+
396
+ if (o.noRelease === true) { print(`ok: bumped ${label}, not released (--no-release)`); return 0 }
397
+
398
+ // phase 4: publish per unit, sequentially. A failure STOPS and never rolls
399
+ // back: the version commit already landed for every unit, so each one
400
+ // resumes with `shipkit release <id>` and no re-bump.
401
+ const fresh = resolveUnits(root, config)
402
+ if (fresh.errors.length > 0) { for (const e of fresh.errors) print(`FAIL: ${formatUnitError(e)}`); return 1 }
403
+ const order = selected.map((s) => unitById(fresh.units, s.unit.id)).filter((u): u is ReleaseUnit => u != null)
404
+ for (let i = 0; i < order.length; i++) {
405
+ const u = order[i]!
406
+ const code = await releaseUnit(root, config, u, {
407
+ skipGate: true,
408
+ skipGateReason: 'already green on this tree (bump ran it)',
409
+ skipUnitGate: true,
410
+ print,
411
+ })
412
+ if (code !== 0) {
413
+ print(`FAIL: ${u.id} did not finish — the version commit is already in, so nothing needs re-bumping`)
414
+ for (const rest of order.slice(i)) print(` fix: shipkit release ${rest.id}`)
415
+ return 1
416
+ }
417
+ }
418
+ return 0
419
+ }