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.
- package/CHANGELOG.md +129 -0
- package/README.md +6 -22
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +1 -1
- package/src/assets/panels/geometry-rects.ts +3 -17
- package/src/assets/server/studio-server.ts +1 -1
- package/src/cli/cmd_Bump.ts +52 -0
- package/src/cli/cmd_Check.ts +2 -2
- package/src/cli/cmd_Ci.ts +10 -2
- package/src/cli/cmd_Commands.ts +126 -0
- package/src/cli/cmd_Hooks.ts +66 -0
- package/src/cli/cmd_Mv.ts +29 -6
- package/src/cli/cmd_Release.ts +41 -0
- package/src/cli/cmd_Releases.ts +42 -0
- package/src/cli/cmd_Rules.ts +20 -6
- package/src/cli/cmd_Start.ts +2 -2
- package/src/cli.ts +14 -1
- package/src/collections/posts.ts +1 -1
- package/src/collections/providers.ts +1 -1
- package/src/contracts/manifests/discord.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +2 -4
- package/src/core/canonical-scripts.ts +9 -14
- package/src/core/check-suite.ts +1 -1
- package/src/core/discover-checks.ts +21 -6
- package/src/core/run-check.ts +69 -21
- package/src/core/run-ci.ts +75 -9
- package/src/core/run-fixers.ts +144 -0
- package/src/core/write-status.ts +14 -5
- package/src/hooks/commit-message.ts +42 -0
- package/src/hooks/leak-guard.ts +0 -0
- package/src/hooks/run-hooks.ts +131 -0
- package/src/init-repo.ts +10 -5
- package/src/reflect/prompt-corpus.ts +3 -4
- package/src/release/changelog.ts +53 -0
- package/src/release/git-facts.ts +133 -0
- package/src/release/run-bump.ts +419 -0
- package/src/release/run-release.ts +267 -0
- package/src/release/run-releases.ts +230 -0
- package/src/release/unit-gate.ts +67 -0
- package/src/release/units.ts +246 -0
- package/src/repo-config.ts +56 -2
- package/src/rules/SK001-package-json-shape.ts +39 -17
- package/src/rules/SK002-contracts-fulfillness.ts +1 -0
- package/src/rules/SK003-claude-md-shape.ts +2 -1
- package/src/rules/SK004-gitignore-policy.ts +1 -0
- package/src/rules/SK005-rvlib-versions.ts +22 -3
- package/src/rules/SK006-npm-publish-safety.ts +1 -0
- package/src/rules/SK007-github-visibility.ts +5 -1
- package/src/rules/SK008-reviewer-agent.ts +1 -0
- package/src/rules/SK009-commit-ai-tells.ts +66 -0
- package/src/rules/SK010-undeclared-release-unit.ts +114 -0
- package/src/rules/rule-registry.ts +26 -6
- package/src/rules/rule.ts +8 -1
- package/src/rules/run-fix.ts +15 -8
- package/src/serve/hub-app/col-card.tsx +2 -3
- package/src/serve/hub-app/hub-app.tsx +6 -8
- package/src/serve/hub-app/hub-st.ts +4 -7
- package/src/serve/hub-app/media-view.tsx +2 -2
- package/src/serve/run-serve.ts +4 -5
- package/src/serve/serve-hub.ts +2 -3
- package/src/shipkit.ts +9 -1
- package/src/tui/checks-panel.ts +1 -1
- package/src/tui/run-tui.ts +2 -3
- package/src/tui/tasks-panel.ts +10 -4
- 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
|
+
}
|