rvlib-shipkit 0.14.0 → 0.17.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 (66) hide show
  1. package/CHANGELOG.md +141 -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 +61 -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 +78 -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/root-identity.ts +64 -0
  32. package/src/hooks/run-hooks.ts +149 -0
  33. package/src/init-repo.ts +10 -5
  34. package/src/reflect/prompt-corpus.ts +3 -4
  35. package/src/release/changelog.ts +70 -0
  36. package/src/release/git-facts.ts +133 -0
  37. package/src/release/run-bump.ts +436 -0
  38. package/src/release/run-release.ts +271 -0
  39. package/src/release/run-releases.ts +230 -0
  40. package/src/release/unit-gate.ts +67 -0
  41. package/src/release/units.ts +246 -0
  42. package/src/repo-config.ts +56 -2
  43. package/src/rules/SK001-package-json-shape.ts +39 -17
  44. package/src/rules/SK002-contracts-fulfillness.ts +1 -0
  45. package/src/rules/SK003-claude-md-shape.ts +2 -1
  46. package/src/rules/SK004-gitignore-policy.ts +4 -0
  47. package/src/rules/SK005-rvlib-versions.ts +22 -3
  48. package/src/rules/SK006-npm-publish-safety.ts +1 -0
  49. package/src/rules/SK007-github-visibility.ts +5 -1
  50. package/src/rules/SK008-reviewer-agent.ts +1 -0
  51. package/src/rules/SK009-commit-ai-tells.ts +66 -0
  52. package/src/rules/SK010-undeclared-release-unit.ts +125 -0
  53. package/src/rules/rule-registry.ts +26 -6
  54. package/src/rules/rule.ts +8 -1
  55. package/src/rules/run-fix.ts +15 -8
  56. package/src/serve/hub-app/col-card.tsx +2 -3
  57. package/src/serve/hub-app/hub-app.tsx +6 -8
  58. package/src/serve/hub-app/hub-st.ts +4 -7
  59. package/src/serve/hub-app/media-view.tsx +2 -2
  60. package/src/serve/run-serve.ts +4 -5
  61. package/src/serve/serve-hub.ts +2 -3
  62. package/src/shipkit.ts +9 -1
  63. package/src/tui/checks-panel.ts +1 -1
  64. package/src/tui/run-tui.ts +2 -3
  65. package/src/tui/tasks-panel.ts +10 -4
  66. package/src/tui/tui-store.ts +2 -2
@@ -0,0 +1,133 @@
1
+ // the git questions a release asks, in one place: which remote to push to,
2
+ // what the worktree is hiding, which tags exist here but nowhere else.
3
+ // Deliberately dependency-free (no config load, no ci) so a check script can
4
+ // import it without dragging the gate in.
5
+ import type { RepoConfig } from '../repo-config.ts'
6
+
7
+ export function git(root: string, args: string[]): { code: number; out: string } {
8
+ const p = Bun.spawnSync(['git', ...args], { cwd: root })
9
+ return { code: p.exitCode, out: `${p.stdout.toString()}${p.stderr.toString()}`.trim() }
10
+ }
11
+
12
+ /** the push remote: the current branch's upstream when it has one, else the
13
+ * repo's single remote, else refuse. Never prefers a name: a repo whose
14
+ * remote is not called `origin` is normal, and guessing strands the tag
15
+ * somewhere nobody looks. */
16
+ export function deriveRemote(root: string): { remote: string } | { error: string } {
17
+ const branch = git(root, ['branch', '--show-current']).out
18
+ if (branch !== '') {
19
+ const r = git(root, ['config', '--get', `branch.${branch}.remote`])
20
+ if (r.code === 0 && r.out !== '') return { remote: r.out }
21
+ }
22
+ const r = git(root, ['remote'])
23
+ if (r.code !== 0) return { error: 'git remote failed (not a repo?)' }
24
+ const remotes = r.out.split('\n').map((l) => l.trim()).filter((l) => l !== '')
25
+ if (remotes.length === 0) return { error: 'no git remote configured' }
26
+ if (remotes.length === 1) return { remote: remotes[0]! }
27
+ return { error: `branch '${branch === '' ? '(detached)' : branch}' has no upstream and ${remotes.length} remotes exist (${remotes.join(', ')}) — set one with git push -u <remote> <branch>` }
28
+ }
29
+
30
+ /** the worktree's dirty paths, minus the runner's own log dir. running the
31
+ * gate creates that dir, so counting it would make every gated release refuse
32
+ * in any repo that does not gitignore it. */
33
+ export function dirtyPaths(root: string, config: RepoConfig, pathspec?: string): string {
34
+ const logRoot = (config.logDir ?? 'tmp/shipkit-ci').replace(/^\.\//, '').split('/')[0]!
35
+ const args = pathspec != null && pathspec !== '' ? ['status', '--porcelain', '--', pathspec] : ['status', '--porcelain']
36
+ return git(root, args).out
37
+ .split('\n')
38
+ .filter((l) => l.trim() !== '' && !l.slice(3).trim().replace(/^"|"$/g, '').startsWith(`${logRoot}/`) && l.slice(3).trim() !== `${logRoot}/`)
39
+ .join('\n')
40
+ }
41
+
42
+ export const tagExistsLocally = (root: string, tag: string): boolean =>
43
+ git(root, ['rev-parse', '--quiet', '--verify', `refs/tags/${tag}`]).code === 0
44
+
45
+ export type RemoteTags = { tags: Set<string> } | { error: string }
46
+ /** tag name → the COMMIT it names on the remote (the peeled oid when the tag
47
+ * is annotated), so two tags of the same name can be compared by what they
48
+ * describe rather than by their spelling */
49
+ export type RemoteTagRefs = { refs: Map<string, string> } | { error: string }
50
+
51
+ /** every tag the remote carries, with its commit. One `ls-remote` per repo,
52
+ * never one per tag: the caller compares locally. Deliberately unpatterned,
53
+ * because `ls-remote <pattern>` drops the peeled `^{}` line and leaves only
54
+ * the tag OBJECT id, which differs between two tags of the same commit. */
55
+ export function remoteTagRefs(root: string, remote: string): RemoteTagRefs {
56
+ const p = Bun.spawnSync(['git', 'ls-remote', '--tags', remote], { cwd: root, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
57
+ if (p.exitCode !== 0) return { error: `${p.stderr.toString()}${p.stdout.toString()}`.trim().split('\n').at(-1) ?? 'ls-remote failed' }
58
+ const refs = new Map<string, string>()
59
+ for (const line of p.stdout.toString().split('\n')) {
60
+ const [oid, ref] = line.split('\t')
61
+ const name = ref?.split('refs/tags/')[1]
62
+ if (oid == null || name == null || name === '') continue
63
+ const peeled = name.endsWith('^{}')
64
+ const key = peeled ? name.slice(0, -3) : name
65
+ // the peeled line always wins, whichever order it arrives in
66
+ if (peeled || !refs.has(key)) refs.set(key, oid)
67
+ }
68
+ return { refs }
69
+ }
70
+
71
+ export function remoteTags(root: string, remote: string): RemoteTags {
72
+ const r = remoteTagRefs(root, remote)
73
+ return 'error' in r ? r : { tags: new Set(r.refs.keys()) }
74
+ }
75
+
76
+ export type RemoteTagState =
77
+ | { state: 'absent' }
78
+ | { state: 'same'; oid: string }
79
+ /** the name is taken on the remote by another commit — the version is burnt */
80
+ | { state: 'different'; remoteOid: string; localOid: string | null }
81
+ | { state: 'unreadable'; error: string }
82
+
83
+ /** what the remote says about ONE tag. Comparing NAMES is what let a burnt
84
+ * version read as "fully released": the tag was there, describing a tree
85
+ * nobody released. */
86
+ export function remoteTagState(root: string, remote: string, tag: string): RemoteTagState {
87
+ const r = remoteTagRefs(root, remote)
88
+ if ('error' in r) return { state: 'unreadable', error: r.error }
89
+ const remoteOid = r.refs.get(tag)
90
+ if (remoteOid == null) return { state: 'absent' }
91
+ const local = git(root, ['rev-parse', `${tag}^{}`])
92
+ const localOid = local.code === 0 ? local.out : null
93
+ return localOid === remoteOid ? { state: 'same', oid: remoteOid } : { state: 'different', remoteOid, localOid }
94
+ }
95
+
96
+ export type MergeProbe =
97
+ | { state: 'clean' }
98
+ | { state: 'conflicts'; paths: string[] }
99
+ /** this git could not answer, so the caller must not claim either way */
100
+ | { state: 'unknown'; reason: string }
101
+
102
+ /** would `from` merge into `tip` cleanly? Answered from the object database:
103
+ * `git merge-tree --write-tree` (git 2.38+) writes nothing but objects, so no
104
+ * ref moves and no file is checked out. That is what lets a REHEARSAL answer
105
+ * the merge question without touching the branch a human treats as a gate.
106
+ *
107
+ * exit 1 covers both conflicts and a ref git could not resolve, so the tree
108
+ * oid on stdout is what separates them: no oid means no merge was attempted. */
109
+ export function mergeProbe(root: string, tip: string, from: string): MergeProbe {
110
+ // env is passed explicitly: Bun resolves argv[0] against the PATH it
111
+ // captured at startup unless it is, so an older git ahead on PATH would
112
+ // never be reached, here or in a test that stages one
113
+ const p = Bun.spawnSync(['git', 'merge-tree', '--write-tree', '--name-only', tip, from], { cwd: root, env: process.env })
114
+ const out = p.stdout.toString()
115
+ const [head, ...rest] = out.split('\n')
116
+ const wroteTree = head != null && /^[0-9a-f]{40}$/.test(head.trim())
117
+ if (p.exitCode === 0 && wroteTree) return { state: 'clean' }
118
+ if (p.exitCode === 1 && wroteTree)
119
+ return { state: 'conflicts', paths: (rest.join('\n').split('\n\n')[0] ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '') }
120
+ const reason = `${p.stderr.toString()}${out}`.trim().split('\n').filter((l) => l !== '').at(-1) ?? `git merge-tree exited ${p.exitCode}`
121
+ return { state: 'unknown', reason: `${reason} (needs git 2.38+)` }
122
+ }
123
+
124
+ /** local tags matching any of the release units' tag shapes that the remote
125
+ * does not have. A stranded tag is invisible where he looks (GitHub), so it
126
+ * is a failure, not a note. */
127
+ export function strandedTags(p: { root: string; globs: string[]; remote: string }): { stranded: string[]; local: string[] } | { error: string } {
128
+ const local = p.globs.flatMap((g) => git(p.root, ['tag', '-l', g]).out.split('\n').map((l) => l.trim()).filter((l) => l !== ''))
129
+ const uniq = [...new Set(local)].sort()
130
+ const r = remoteTags(p.root, p.remote)
131
+ if ('error' in r) return r
132
+ return { stranded: uniq.filter((t) => !r.tags.has(t)), local: uniq }
133
+ }
@@ -0,0 +1,436 @@
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 { declaredKind, 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
+ // a rehearsal's ONLY product is this answer, so it must not be computed
149
+ // from a remote ref that never refreshed. Offline, an expired credential
150
+ // or a declined prompt all land here.
151
+ const fetched = git(['fetch', remote, '--quiet'])
152
+ const stale = fetched.code !== 0
153
+ if (stale) print(`WARN could not fetch '${remote}' (git fetch exited ${fetched.code}) — the remote refs below may be stale`)
154
+ const remoteRef = `${remote}/${to}`
155
+ const localExists = git(['rev-parse', '--quiet', '--verify', to]).code === 0
156
+ const remoteExists = git(['rev-parse', '--quiet', '--verify', remoteRef]).code === 0
157
+ if (!localExists && !remoteExists) {
158
+ print(`FAIL: no branch '${to}' here or on '${remote}' — a release needs a branch to ship from`)
159
+ return { refuse: true }
160
+ }
161
+ let tip = localExists ? to : remoteRef
162
+ if (localExists && remoteExists) {
163
+ // the real path fast-forwards, which succeeds when either side is an
164
+ // ancestor of the other and refuses only on a genuine divergence
165
+ const behind = git(['merge-base', '--is-ancestor', to, remoteRef]).code === 0
166
+ const ahead = git(['merge-base', '--is-ancestor', remoteRef, to]).code === 0
167
+ if (!behind && !ahead) { print(`FAIL: '${to}' has diverged from ${remoteRef} — reconcile it by hand`); return { refuse: true } }
168
+ if (behind) tip = remoteRef
169
+ }
170
+ const probe = mergeProbe(root, tip, from)
171
+ if (probe.state === 'conflicts') {
172
+ print(`FAIL: '${from}' would conflict with '${to}' — nothing was merged, nothing was changed`)
173
+ for (const path of probe.paths.slice(0, 8)) print(` ${path}`)
174
+ return { refuse: true }
175
+ }
176
+ if (probe.state === 'unknown') {
177
+ print(`WARN cannot check the merge without touching the worktree (${probe.reason}) — the rehearsal does not know whether '${from}' merges into '${to}'`)
178
+ return { refuse: false, inconclusive: true }
179
+ }
180
+ print(`note: '${from}' merges cleanly into '${to}' (checked in the object database, no ref moved)`)
181
+ // a clean answer computed from refs that never refreshed proves less than
182
+ // it says, so the rehearsal declines to close clean
183
+ return { refuse: false, inconclusive: stale }
184
+ }
185
+
186
+ type Selected = { unit: ReleaseUnit; version: string; changelogRel: string; kind: BumpKind }
187
+
188
+ export async function runBump(root: string, config: RepoConfig, kind: BumpKind | null, o: BumpOpts = {}): Promise<number> {
189
+ const print = o.print ?? ((l: string) => console.log(l))
190
+ if (o.all === true && o.unit != null) { print('FAIL: pass a unit or --all, never both'); return 1 }
191
+
192
+ const git: Git = (args) => gitAt(root, args)
193
+ const cur = git(['rev-parse', '--abbrev-ref', 'HEAD']).out
194
+ const branch = config.release?.branch ?? 'main'
195
+
196
+ // pre-merge, ONLY what a merge cannot change: a typo'd unit id, a dirty
197
+ // tree, the branch decision, the remote. Everything the bump WRITES is
198
+ // computed after the merge, off the tree that actually ships.
199
+ const onDisk = resolveUnits(root, config)
200
+ if (onDisk.errors.length > 0) { for (const e of onDisk.errors) print(`FAIL: ${formatUnitError(e)}`); return 1 }
201
+ if (o.all !== true) {
202
+ const id = o.unit ?? 'root'
203
+ if (unitById(onDisk.units, id) == null) {
204
+ print(`FAIL: no release unit '${id}' — known units: ${onDisk.units.map((x) => x.id).join(', ')}`)
205
+ print(' fix: shipkit releases')
206
+ return 1
207
+ }
208
+ }
209
+ // the tree must be clean BEFORE any branch move, or a checkout carries
210
+ // uncommitted work across branches
211
+ const dirty = dirtyPaths(root, config)
212
+ if (dirty !== '') { print(`FAIL: working tree not clean, commit your work first:\n${dirty.split('\n').slice(0, 10).join('\n')}`); return 1 }
213
+
214
+ const wantMerge = cur !== branch && o.noMerge !== true && (config.release?.mergeFromBranch ?? true)
215
+ if (cur !== branch && !wantMerge) { print(`FAIL: releases ship from '${branch}', HEAD is on '${cur}'`); return 1 }
216
+ const remote = deriveRemote(root)
217
+ if ('error' in remote) { print(`FAIL: ${remote.error}`); return 1 }
218
+ if (wantMerge) print(`note: on '${cur}', so '${cur}' merges into '${branch}' first`)
219
+
220
+ // the merge happens FIRST among the writes, so every fact below describes
221
+ // the tree that ships. Nothing here is pushed, so every refusal rolls it
222
+ // back whole. A rehearsal merges NOTHING and says what it therefore did
223
+ // not prove (the file header owns the reasoning).
224
+ const rehearsal = o.dryRun === true
225
+ let rollback: (() => void) | null = null
226
+ let onInterrupt: (() => void) | null = null
227
+ let mergeUnknown = false
228
+ if (wantMerge && rehearsal) {
229
+ const probe = probeMergeIntoReleaseBranch(root, git, { from: cur, to: branch, remote: remote.remote, print })
230
+ if (probe.refuse) return 1
231
+ mergeUnknown = probe.inconclusive
232
+ } else if (wantMerge) {
233
+ const m = mergeIntoReleaseBranch(git, { from: cur, to: branch, remote: remote.remote, print })
234
+ if (!m.ok) return 1
235
+ rollback = m.rollback
236
+ // the merge window spans the gate and the prepublish scripts, and
237
+ // nothing there handles an interrupt: Ctrl-C used to leave the merge
238
+ // on the release branch with not a line printed about it
239
+ onInterrupt = () => { print(`FAIL: interrupted — rolling '${branch}' back`); rollback?.(); process.exit(130) }
240
+ process.on('SIGINT', onInterrupt)
241
+ process.on('SIGTERM', onInterrupt)
242
+ }
243
+ // shut the window: after this the merge is either undone or permanent, and
244
+ // an interrupt must never reset --hard over files the operator has to keep
245
+ const closeMergeWindow = (): void => {
246
+ if (onInterrupt != null) { process.off('SIGINT', onInterrupt); process.off('SIGTERM', onInterrupt); onInterrupt = null }
247
+ rollback = null
248
+ }
249
+ const rollBack = (): void => { rollback?.(); closeMergeWindow() }
250
+ const fail = (msg: string, fixLine?: string): number => {
251
+ print(`FAIL: ${msg}`)
252
+ if (fixLine != null) print(` fix: ${fixLine}`)
253
+ rollBack()
254
+ return 1
255
+ }
256
+
257
+ // phase 1, on the MERGED tree (on the FEATURE tree in a rehearsal): the
258
+ // resolution, the selection, the next versions, the tag refusals and the
259
+ // per-unit gate all describe what ships
260
+ const resolved = resolveUnits(root, config)
261
+ // bump can touch several units at once, so ANY unresolved unit is fatal:
262
+ // a selection built from a partial model is a selection nobody can trust
263
+ if (resolved.errors.length > 0) { for (const e of resolved.errors) print(`FAIL: ${formatUnitError(e)}`); rollBack(); return 1 }
264
+ const all = releaseOrder(resolved.units)
265
+
266
+ // selection. `--all` reads the changelogs: a unit with nothing to say is
267
+ // skipped, not failed, so `--all` means "ship everything that has news".
268
+ let picked: ReleaseUnit[]
269
+ if (o.all === true) {
270
+ picked = []
271
+ for (const u of all) {
272
+ if (!existsSync(u.changelog)) { print(`skip: ${u.id} — no changelog at ${relative(root, u.changelog)}`); continue }
273
+ if (hasUnreleasedNotes(readFileSync(u.changelog, 'utf8'))) picked.push(u)
274
+ else print(`skip: ${u.id} — no Unreleased notes`)
275
+ }
276
+ if (picked.length === 0) return fail('no unit has non-empty Unreleased notes — write what changed, then bump')
277
+ } else {
278
+ const id = o.unit ?? 'root'
279
+ const u = unitById(all, id)
280
+ if (u == null) return fail(`no release unit '${id}' after the merge — known units: ${all.map((x) => x.id).join(', ')}`, 'shipkit releases')
281
+ picked = [u]
282
+ }
283
+
284
+ // one ls-remote for the whole run: a tag name the remote already carries
285
+ // is a burnt version, and finding that out AFTER the version commit leaves
286
+ // a number nobody can ship
287
+ const onRemote = remoteTags(root, remote.remote)
288
+ 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`)
289
+
290
+ const blockers: string[] = []
291
+ const selected: Selected[] = []
292
+ for (const unit of picked) {
293
+ const changelogRel = relative(root, unit.changelog)
294
+ if (!existsSync(unit.changelog)) { blockers.push(`unit '${unit.id}': ${changelogRel} not found — a release states what changed`); continue }
295
+ const source = readFileSync(unit.changelog, 'utf8')
296
+ // an explicit kind on the command line wins for the whole selection.
297
+ // Without one, each unit answers for itself from its own heading, and
298
+ // a unit that never said is a refusal: guessing a version number
299
+ // publishes it, and a published number cannot be recalled.
300
+ const unitKind = kind ?? declaredKind(source)
301
+ if (unitKind == null) {
302
+ blockers.push(`unit '${unit.id}': ${changelogRel} does not say what kind of release this is — write "## Unreleased (minor)", or pass a kind that applies to every selected unit`)
303
+ continue
304
+ }
305
+ let version: string
306
+ try { version = nextVersion(unit.version, unitKind) }
307
+ catch (e) { blockers.push(`unit '${unit.id}': ${e instanceof Error ? e.message : String(e)}`); continue }
308
+ const promoted = promoteUnreleased(source, version)
309
+ if ('error' in promoted) { blockers.push(`unit '${unit.id}': ${promoted.error}`); continue }
310
+ const tag = renderTag(unit, version)
311
+ if (tagExistsLocally(root, tag)) {
312
+ blockers.push(`unit '${unit.id}': tag ${tag} already exists here — finish that release with \`shipkit release ${unit.id}\`, or pick another bump`)
313
+ continue
314
+ }
315
+ if (onRemote.tags.has(tag)) {
316
+ blockers.push(`unit '${unit.id}': tag ${tag} already exists on '${remote.remote}' — that version number is taken, pick another bump`)
317
+ continue
318
+ }
319
+ blockers.push(...unitBlockers(root, config, unit))
320
+ selected.push({ unit, version, changelogRel, kind: unitKind })
321
+ }
322
+ // everything the release needs that does not depend on the new version.
323
+ // HEAD is the release branch by now, so the branch check applies — except
324
+ // in a rehearsal that deliberately stayed on the feature branch, where the
325
+ // branch decision was already made and refused above
326
+ blockers.push(...environmentBlockers(root, config, {
327
+ wantGh: picked.some((u) => u.targets.githubRelease),
328
+ skipBranch: rehearsal && wantMerge,
329
+ }))
330
+ if (blockers.length > 0) { for (const b of blockers) print(`FAIL: ${b}`); rollBack(); return 1 }
331
+
332
+ for (const s of selected) print(`note: ${s.unit.id} ${s.unit.version} → ${s.version} (${s.kind})`)
333
+
334
+ // phase 2: ONE gate for the whole repo. The repo is one worktree, so N
335
+ // gates cost N times as much and prove the same thing. Frozen, and fatal
336
+ // on non-blocking failures when the repo declared requireHealthy.
337
+ // releaseUnit then skips it, because bump already proved this exact tree.
338
+ if ((await runReleaseGate(root, config, print, { fix: true })) !== 0) return fail('ci gate is red on the merged result — fix it, then bump')
339
+ // the gate's fix pass can rewrite files. writing a version on top of that
340
+ // produces a version commit that excludes the rewrite and a release that
341
+ // then refuses on a dirty tree, which is a half-released repo.
342
+ const afterGate = dirtyPaths(root, config)
343
+ if (afterGate !== '')
344
+ return fail(
345
+ `the gate rewrote files, so the tree is no longer clean:\n${afterGate.split('\n').slice(0, 10).join('\n')}`,
346
+ rollback != null
347
+ ? 'the rollback below discards them. run `shipkit ci` on your branch, commit what it rewrites, then bump again'
348
+ : 'run `shipkit ci`, commit what it rewrites, then bump again',
349
+ )
350
+ print('ok: ci gate green')
351
+
352
+ for (const s of selected) {
353
+ for (const script of unitPrepublishScripts(s.unit)) {
354
+ const r = Bun.spawnSync(['bun', 'run', script], { cwd: s.unit.dir, stdout: 'inherit', stderr: 'inherit' })
355
+ if (r.exitCode !== 0) return fail(`${s.unit.id}: ${script} failed (exit ${r.exitCode}) — nothing was written`)
356
+ print(`ok: ${s.unit.id} ${script}`)
357
+ }
358
+ }
359
+ if (selected.every((s) => unitPrepublishScripts(s.unit).length === 0)) print('note: no prepublish scripts')
360
+
361
+ if (rehearsal) {
362
+ const what = selected.map((s) => `${s.unit.id} ${s.version}`).join(', ')
363
+ // a rehearsal that could not answer the merge question must not close
364
+ // with the word "clean": that green is what sends an operator into a
365
+ // real bump that aborts mid-merge
366
+ if (mergeUnknown) print(`WARN dry run inconclusive — would write ${what}, but the merge into '${branch}' could not be checked (git 2.38+ answers it)`)
367
+ else print(`ok: dry run clean — would write ${what}, commit, then ${o.noRelease === true ? 'stop' : 'release'}`)
368
+ // what the rehearsal did NOT cover, said out loud: a merge that never
369
+ // happened cannot have been gated, and a number computed on the
370
+ // feature branch can differ from the one the real bump writes
371
+ if (wantMerge)
372
+ print(` it did NOT merge: '${branch}' never moved, and everything above (versions, tags, gate) describes '${cur}' UNMERGED`)
373
+ rollBack()
374
+ return 0
375
+ }
376
+
377
+ // phase 3: ONE version commit holding every selected unit's two files.
378
+ // Partial writes are the half-released state this whole command exists to
379
+ // prevent, so the write and the commit are one step for the whole selection.
380
+ //
381
+ // from the first write on, a failure NEVER rolls back: `git reset --hard`
382
+ // would delete the very files the fix line tells the operator to commit.
383
+ // What is on disk is reported as it is, interrupts included.
384
+ closeMergeWindow()
385
+ const failKeep = (msg: string, fixLine: string): number => { print(`FAIL: ${msg}`); print(` fix: ${fixLine}`); return 1 }
386
+ const files: string[] = []
387
+ for (const s of selected) {
388
+ const pkgRel = s.unit.rel === '' ? 'package.json' : `${s.unit.rel}/package.json`
389
+ // re-read: a merge can have changed both files under us
390
+ const pkgNow = readFileSync(`${root}/${pkgRel}`, 'utf8')
391
+ const promoted = promoteUnreleased(readFileSync(s.unit.changelog, 'utf8'), s.version)
392
+ if ('error' in promoted) return failKeep(`${s.unit.id}: ${promoted.error}`, `check ${s.changelogRel}, then run bump again`)
393
+ // indentation is preserved by rewriting only the version VALUE, so a bump
394
+ // never reformats a file the repo cares about
395
+ const bumped = pkgNow.replace(/("version"\s*:\s*")[^"]*(")/, `$1${s.version}$2`)
396
+ if (bumped === pkgNow) return failKeep(`${s.unit.id}: could not rewrite the version field in ${pkgRel}`, `git status, then set the version by hand`)
397
+ writeFileSync(`${root}/${pkgRel}`, bumped)
398
+ writeFileSync(s.unit.changelog, promoted.text)
399
+ files.push(pkgRel, s.changelogRel)
400
+ print(`ok: ${pkgRel} ${s.version}, ${s.changelogRel} section promoted`)
401
+ }
402
+
403
+ const add = git(['add', ...files])
404
+ if (add.code !== 0) return failKeep('git add failed', 'the version files are written but unstaged. git status, then commit them')
405
+ const label = selected.map((s) => (s.unit.id === 'root' ? s.version : `${s.unit.id}@${s.version}`)).join(', ')
406
+ // the hooks already ran through the gate above; --no-verify is NOT used,
407
+ // so the repo's own commit gates still apply to this commit
408
+ const commit = git(['commit', '-m', `release ${label}`])
409
+ if (commit.code !== 0)
410
+ return failKeep(`commit failed — ${commit.out}`, 'the version files are written but uncommitted. fix the gate, commit, then `shipkit release`')
411
+ print(`ok: committed release ${label}`)
412
+
413
+ if (o.noRelease === true) { print(`ok: bumped ${label}, not released (--no-release)`); return 0 }
414
+
415
+ // phase 4: publish per unit, sequentially. A failure STOPS and never rolls
416
+ // back: the version commit already landed for every unit, so each one
417
+ // resumes with `shipkit release <id>` and no re-bump.
418
+ const fresh = resolveUnits(root, config)
419
+ if (fresh.errors.length > 0) { for (const e of fresh.errors) print(`FAIL: ${formatUnitError(e)}`); return 1 }
420
+ const order = selected.map((s) => unitById(fresh.units, s.unit.id)).filter((u): u is ReleaseUnit => u != null)
421
+ for (let i = 0; i < order.length; i++) {
422
+ const u = order[i]!
423
+ const code = await releaseUnit(root, config, u, {
424
+ skipGate: true,
425
+ skipGateReason: 'already green on this tree (bump ran it)',
426
+ skipUnitGate: true,
427
+ print,
428
+ })
429
+ if (code !== 0) {
430
+ print(`FAIL: ${u.id} did not finish — the version commit is already in, so nothing needs re-bumping`)
431
+ for (const rest of order.slice(i)) print(` fix: shipkit release ${rest.id}`)
432
+ return 1
433
+ }
434
+ }
435
+ return 0
436
+ }