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.
- package/CHANGELOG.md +141 -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 +61 -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 +78 -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/root-identity.ts +64 -0
- package/src/hooks/run-hooks.ts +149 -0
- package/src/init-repo.ts +10 -5
- package/src/reflect/prompt-corpus.ts +3 -4
- package/src/release/changelog.ts +70 -0
- package/src/release/git-facts.ts +133 -0
- package/src/release/run-bump.ts +436 -0
- package/src/release/run-release.ts +271 -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 +4 -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 +125 -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,271 @@
|
|
|
1
|
+
// one release path per unit, public or private. The unit decides which steps
|
|
2
|
+
// apply, never a different script: a unit with no npm target skips the
|
|
3
|
+
// publish and still gets tag, push, and a GitHub release when it wants one,
|
|
4
|
+
// with notes from its own versioned CHANGELOG.
|
|
5
|
+
//
|
|
6
|
+
// every step is idempotent and the pipeline resumes: a run that died after
|
|
7
|
+
// publishing completes only what is missing. preflight touches nothing.
|
|
8
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
9
|
+
import { tmpdir } from 'os'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { runCi } from '../core/run-ci.ts'
|
|
12
|
+
import { extractChangelogSection } from './changelog.ts'
|
|
13
|
+
import { deriveRemote, dirtyPaths, git, remoteTagState, tagExistsLocally, type RemoteTagState } from './git-facts.ts'
|
|
14
|
+
import { npmPublishArgv, unitBlockers } from './unit-gate.ts'
|
|
15
|
+
import { formatUnitError, resolveUnits, unitById, type ReleaseUnit } from './units.ts'
|
|
16
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
17
|
+
|
|
18
|
+
export type ReleaseConfig = {
|
|
19
|
+
/** releases ship from this branch only (default 'main') */
|
|
20
|
+
branch?: string
|
|
21
|
+
/** the ROOT unit's tag template, `{version}` substituted (default 'v{version}') */
|
|
22
|
+
tag?: string
|
|
23
|
+
/** the ROOT unit's versioned changelog (default 'CHANGELOG.md') */
|
|
24
|
+
changelog?: string
|
|
25
|
+
/** publish the ROOT unit to npm. Default: its package.json is not private.
|
|
26
|
+
* A private package NEVER publishes, whatever this says. */
|
|
27
|
+
npm?: boolean
|
|
28
|
+
/** argv printing an npm auth token on stdout (e.g. a secret-manager read).
|
|
29
|
+
* Absent = rely on ambient npm auth. Never put a token in config.
|
|
30
|
+
* Repo-wide: one account publishes every unit. */
|
|
31
|
+
npmTokenCommand?: string[]
|
|
32
|
+
/** cut a GitHub release for the ROOT unit with `gh` (default true) */
|
|
33
|
+
githubRelease?: boolean
|
|
34
|
+
/** make a NON-blocking check failure block the release too. Blocking
|
|
35
|
+
* checks always block, here as everywhere. Default false: a non-blocking
|
|
36
|
+
* check reports the world (a missing cred, a box that is off), and that
|
|
37
|
+
* is rarely a reason to refuse a finished version. */
|
|
38
|
+
requireHealthy?: boolean
|
|
39
|
+
/** repo-relative tsconfig for the ROOT unit's own typecheck (default
|
|
40
|
+
* '<root>/tsconfig.json' when it exists) */
|
|
41
|
+
tsconfig?: string
|
|
42
|
+
/** `shipkit bump` run from a feature branch merges it into the release
|
|
43
|
+
* branch first, with a merge commit. Default true. A branch is a GATE, so
|
|
44
|
+
* this is deliberately reachable ONLY by running bump by hand. */
|
|
45
|
+
mergeFromBranch?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** the parts of the release preflight that do not depend on the new version
|
|
49
|
+
* existing yet: branch, remote, gh. bump runs this BEFORE it writes anything,
|
|
50
|
+
* or a refusal here lands after the version commit. */
|
|
51
|
+
export function environmentBlockers(root: string, config: RepoConfig, o: { skipBranch?: boolean; wantGh?: boolean } = {}): string[] {
|
|
52
|
+
const branch = config.release?.branch ?? 'main'
|
|
53
|
+
const out: string[] = []
|
|
54
|
+
const cur = git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
55
|
+
if (cur.code !== 0) out.push('not a git repository')
|
|
56
|
+
else if (cur.out !== branch && o.skipBranch !== true) out.push(`releases ship from '${branch}', HEAD is on '${cur.out}'`)
|
|
57
|
+
const remote = deriveRemote(root)
|
|
58
|
+
if ('error' in remote) out.push(remote.error)
|
|
59
|
+
if (o.wantGh ?? config.release?.githubRelease ?? true) {
|
|
60
|
+
if (Bun.which('gh') == null) out.push('gh not installed and a unit wants a GitHub release — install gh or set githubRelease: false')
|
|
61
|
+
else if (Bun.spawnSync(['gh', 'auth', 'status']).exitCode !== 0) out.push('gh is not authenticated (gh auth login)')
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ReleaseOpts = {
|
|
67
|
+
dryRun?: boolean
|
|
68
|
+
allowDirty?: boolean
|
|
69
|
+
/** skip the ci gate preflight, deliberately awkward to reach */
|
|
70
|
+
skipGate?: boolean
|
|
71
|
+
/** why the gate was skipped. `shipkit bump` already ran it on this exact
|
|
72
|
+
* tree, which is not the same thing as publishing unverified, and the
|
|
73
|
+
* output must not claim it is. */
|
|
74
|
+
skipGateReason?: string
|
|
75
|
+
/** bump already ran the per-unit gate on this exact tree */
|
|
76
|
+
skipUnitGate?: boolean
|
|
77
|
+
/** which unit to release (default 'root') */
|
|
78
|
+
unit?: string
|
|
79
|
+
print?: (line: string) => void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** the ci gate as the release path runs it: FROZEN (a self-updating rule that
|
|
83
|
+
* rewrites package.json between the gate and `npm publish` packs a tree that
|
|
84
|
+
* exists in no commit) and, when the repo asked for it, fatal on non-blocking
|
|
85
|
+
* check failures too. There is no second health suite: `runCi` runs every
|
|
86
|
+
* check, and a check's own `blocking` declaration is the only severity axis.
|
|
87
|
+
*
|
|
88
|
+
* the config travels INTO the gate: classifying severity from one config
|
|
89
|
+
* while the rules ran from another downgrades a blocking failure to a warning. */
|
|
90
|
+
export async function runReleaseGate(root: string, config: RepoConfig, print: (l: string) => void, o: { fix?: boolean } = {}): Promise<number> {
|
|
91
|
+
return await runCi(root, {
|
|
92
|
+
fix: o.fix ?? false,
|
|
93
|
+
frozen: true,
|
|
94
|
+
status: false,
|
|
95
|
+
// a release must not gate on a clean pass cached hours ago: SK005's
|
|
96
|
+
// blocking branches are local facts (a link: that stopped resolving, a
|
|
97
|
+
// dep nobody installed) and a TTL replays them as green
|
|
98
|
+
fresh: true,
|
|
99
|
+
config,
|
|
100
|
+
failOnAdvisory: config.release?.requireHealthy === true,
|
|
101
|
+
print: (l) => print(` ${l}`),
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `shipkit release [unit]` — resolve the unit, then ship it. */
|
|
106
|
+
export async function runRelease(root: string, config: RepoConfig, o: ReleaseOpts = {}): Promise<number> {
|
|
107
|
+
const print = o.print ?? ((l: string) => console.log(l))
|
|
108
|
+
const { units, errors } = resolveUnits(root, config)
|
|
109
|
+
const id = o.unit ?? 'root'
|
|
110
|
+
// only THIS unit's own resolution has to hold: a broken sibling is not a
|
|
111
|
+
// reason to refuse a release of something that resolves fine
|
|
112
|
+
const mine = errors.filter((e) => e.id === id)
|
|
113
|
+
if (mine.length > 0) { for (const e of mine) print(`FAIL: ${formatUnitError(e)}`); return 1 }
|
|
114
|
+
const unit = unitById(units, id)
|
|
115
|
+
if (unit == null) {
|
|
116
|
+
print(`FAIL: no release unit '${id}' — known units: ${units.map((u) => u.id).join(', ')}`)
|
|
117
|
+
print(' fix: shipkit releases')
|
|
118
|
+
return 1
|
|
119
|
+
}
|
|
120
|
+
return await releaseUnit(root, config, unit, o)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** ship ONE resolved unit. Every gate above it has already run when bump
|
|
124
|
+
* calls this; a bare `shipkit release` runs them here. */
|
|
125
|
+
export async function releaseUnit(root: string, config: RepoConfig, unit: ReleaseUnit, o: ReleaseOpts = {}): Promise<number> {
|
|
126
|
+
const print = o.print ?? ((l: string) => console.log(l))
|
|
127
|
+
const rc = config.release ?? {}
|
|
128
|
+
const branch = rc.branch ?? 'main'
|
|
129
|
+
const { name, version, tag } = unit
|
|
130
|
+
const wantNpm = unit.targets.npm
|
|
131
|
+
const wantGh = unit.targets.githubRelease
|
|
132
|
+
|
|
133
|
+
const blockers: string[] = []
|
|
134
|
+
const notesResult = ((): { notes: string } | { error: string } => {
|
|
135
|
+
if (!existsSync(unit.changelog)) return { error: `unit '${unit.id}': CHANGELOG not found at ${unit.changelog} — a release states what changed` }
|
|
136
|
+
try { return { notes: extractChangelogSection({ changelog: readFileSync(unit.changelog, 'utf8'), version }) } }
|
|
137
|
+
catch (e) { return { error: `unit '${unit.id}': ${e instanceof Error ? e.message : String(e)}` } }
|
|
138
|
+
})()
|
|
139
|
+
if ('error' in notesResult) blockers.push(notesResult.error)
|
|
140
|
+
const notes = 'notes' in notesResult ? notesResult.notes : ''
|
|
141
|
+
|
|
142
|
+
const cur = git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
143
|
+
if (cur.code !== 0) blockers.push('not a git repository')
|
|
144
|
+
else if (cur.out !== branch) blockers.push(`releases ship from '${branch}', HEAD is on '${cur.out}'`)
|
|
145
|
+
|
|
146
|
+
const dirty = dirtyPaths(root, config)
|
|
147
|
+
if (dirty !== '' && o.allowDirty !== true)
|
|
148
|
+
blockers.push(`working tree not clean (--allow-dirty overrides):\n${dirty.split('\n').slice(0, 10).join('\n')}`)
|
|
149
|
+
if (o.skipUnitGate !== true) blockers.push(...unitBlockers(root, config, unit))
|
|
150
|
+
|
|
151
|
+
let alreadyPublished = false
|
|
152
|
+
if (wantNpm) {
|
|
153
|
+
const res = await fetch(`https://registry.npmjs.org/${name}`, { signal: AbortSignal.timeout(15000) }).catch(() => null)
|
|
154
|
+
if (res == null) blockers.push('npm registry unreachable — offline?')
|
|
155
|
+
else if (res.ok) {
|
|
156
|
+
const versions = ((await res.json()) as { versions?: Record<string, unknown> }).versions ?? {}
|
|
157
|
+
alreadyPublished = versions[version] != null
|
|
158
|
+
} else if (res.status !== 404) blockers.push(`npm registry lookup for ${name} → ${res.status}`)
|
|
159
|
+
}
|
|
160
|
+
// a tag that exists but was never pushed, or whose github release is
|
|
161
|
+
// missing, means a previous run died mid-pipeline. re-running must finish
|
|
162
|
+
// it, never demand a burnt version number. only a FULLY shipped version is
|
|
163
|
+
// a dead end.
|
|
164
|
+
const remote = deriveRemote(root)
|
|
165
|
+
if ('error' in remote) blockers.push(remote.error)
|
|
166
|
+
const remoteName0 = 'remote' in remote ? remote.remote : null
|
|
167
|
+
const tagExists = (): boolean => tagExistsLocally(root, tag)
|
|
168
|
+
const remoteState: RemoteTagState = remoteName0 == null ? { state: 'unreadable', error: 'no remote' } : remoteTagState(root, remoteName0, tag)
|
|
169
|
+
if (remoteName0 != null && remoteState.state === 'unreadable')
|
|
170
|
+
blockers.push(`cannot read tags on '${remoteName0}' (${remoteState.error}) — the push would be a guess`)
|
|
171
|
+
// a tag NAME the remote already carries at another commit is not a
|
|
172
|
+
// resumable release: this version would describe a tree that shipped as
|
|
173
|
+
// something else, and git refuses the push anyway
|
|
174
|
+
if (remoteState.state === 'different')
|
|
175
|
+
blockers.push(`tag ${tag} already on '${remoteName0 ?? ''}' at ${remoteState.remoteOid.slice(0, 8)}, ${remoteState.localOid == null ? 'and not here at all' : `not ${remoteState.localOid.slice(0, 8)} as here`} — that version number is burnt, bump a new one`)
|
|
176
|
+
const ghReleaseExists = (): boolean =>
|
|
177
|
+
wantGh && Bun.which('gh') != null && Bun.spawnSync(['gh', 'release', 'view', tag], { cwd: root }).exitCode === 0
|
|
178
|
+
const fullyShipped = tagExists() && remoteState.state === 'same' && (!wantGh || ghReleaseExists()) && (!wantNpm || alreadyPublished)
|
|
179
|
+
if (fullyShipped) blockers.push(`${name}@${version} is fully released (tag ${tag}${wantNpm ? ' + npm' : ''}) — bump the version first`)
|
|
180
|
+
else if (tagExists()) print(`WARN ${tag} exists but the release is incomplete — resuming the remaining steps`)
|
|
181
|
+
else if (alreadyPublished) print(`WARN ${version} is on npm but ${tag} is missing — resuming after publish`)
|
|
182
|
+
|
|
183
|
+
if (wantNpm && rc.npmTokenCommand == null) print('note: no release.npmTokenCommand — publishing with ambient npm auth')
|
|
184
|
+
if (wantGh && Bun.which('gh') == null) blockers.push('gh not installed and this unit wants a GitHub release — install gh or set githubRelease: false')
|
|
185
|
+
else if (wantGh && Bun.spawnSync(['gh', 'auth', 'status']).exitCode !== 0) blockers.push('gh is not authenticated (gh auth login)')
|
|
186
|
+
|
|
187
|
+
const steps = [
|
|
188
|
+
...(wantNpm && !alreadyPublished ? [`npm publish ${name}@${version}`] : []),
|
|
189
|
+
...(tagExists() ? [] : [`git tag ${tag}`]),
|
|
190
|
+
`git push + push tag ${tag}`,
|
|
191
|
+
...(wantGh ? [`gh release create ${tag}`] : []),
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
print(`note: ${unit.id} · ${name}@${version} → tag ${tag} · ${wantNpm ? 'npm' : unit.isPrivate ? 'PRIVATE (no npm)' : 'no npm target'} · ${notes === '' ? 'no notes' : `${notes.split('\n').length} line(s) of notes`}`)
|
|
195
|
+
for (const s of steps) print(`note: ${s}`)
|
|
196
|
+
|
|
197
|
+
if (blockers.length > 0) {
|
|
198
|
+
for (const b of blockers) print(`FAIL: ${b}`)
|
|
199
|
+
return 1
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// the gate runs BEFORE anything leaves the machine. A release that skips
|
|
203
|
+
// its own gate is exactly how a broken build, or a secret, ships. It runs
|
|
204
|
+
// in verify + frozen mode: a release publishes the reviewed commit, never
|
|
205
|
+
// a tree a fixer or a self-updating rule rewrote on the way out.
|
|
206
|
+
if (o.skipGate === true)
|
|
207
|
+
print(o.skipGateReason != null ? `ok: ci gate ${o.skipGateReason}` : 'WARN ci gate SKIPPED by --skip-gate — publishing unverified')
|
|
208
|
+
else {
|
|
209
|
+
if ((await runReleaseGate(root, config, print)) !== 0) { print('FAIL: ci gate is red — fix it, commit, then release'); return 1 }
|
|
210
|
+
// frozen is a declaration, not a guarantee: any check may still write.
|
|
211
|
+
// npm packs the DIRECTORY, so a rewrite here would publish a tarball
|
|
212
|
+
// that exists in no commit while the tag describes another tree.
|
|
213
|
+
const afterGate = dirtyPaths(root, config)
|
|
214
|
+
if (afterGate !== '' && o.allowDirty !== true) {
|
|
215
|
+
print(`FAIL: the gate rewrote files, so the tree no longer matches what the tag would describe:\n${afterGate.split('\n').slice(0, 10).join('\n')}`)
|
|
216
|
+
print(' fix: commit those changes, then run release again')
|
|
217
|
+
return 1
|
|
218
|
+
}
|
|
219
|
+
print('ok: ci gate green')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (o.dryRun === true) { print(`ok: dry run clean — nothing was touched`); return 0 }
|
|
223
|
+
|
|
224
|
+
const remoteName = (remote as { remote: string }).remote
|
|
225
|
+
const work = mkdtempSync(join(tmpdir(), 'shipkit-release-'))
|
|
226
|
+
try {
|
|
227
|
+
if (wantNpm && !alreadyPublished) {
|
|
228
|
+
const env: Record<string, string> = { ...process.env as Record<string, string> }
|
|
229
|
+
const argv = rc.npmTokenCommand
|
|
230
|
+
let npmrc: string | null = null
|
|
231
|
+
if (argv != null && argv[0] != null) {
|
|
232
|
+
const t = Bun.spawnSync(argv)
|
|
233
|
+
const token = t.exitCode === 0 ? t.stdout.toString().trim() : ''
|
|
234
|
+
if (token === '') { print(`FAIL: release.npmTokenCommand produced no token (exit ${t.exitCode})`); return 1 }
|
|
235
|
+
// token via a throwaway userconfig, never argv, never a
|
|
236
|
+
// committed .npmrc, never a line in a log
|
|
237
|
+
npmrc = join(work, 'npmrc')
|
|
238
|
+
writeFileSync(npmrc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 })
|
|
239
|
+
}
|
|
240
|
+
const p = Bun.spawnSync(npmPublishArgv({ npmrc }), { cwd: unit.dir, env, stdout: 'inherit', stderr: 'inherit' })
|
|
241
|
+
if (p.exitCode !== 0) { print(`FAIL: npm publish failed for ${unit.id} (exit ${p.exitCode})`); return 1 }
|
|
242
|
+
print(`ok: published ${name}@${version}`)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// every step below is recoverable by re-running: the failure message
|
|
246
|
+
// says so, because a half-released package is the scariest state
|
|
247
|
+
const resume = ` fix: shipkit release ${unit.id}`
|
|
248
|
+
if (!tagExists()) {
|
|
249
|
+
const t = git(root, ['tag', '-a', tag, '-m', `${name} ${version}`])
|
|
250
|
+
if (t.code !== 0) { print(`FAIL: git tag ${tag} failed — ${t.out}`); print(resume); return 1 }
|
|
251
|
+
}
|
|
252
|
+
const push = git(root, ['push', remoteName])
|
|
253
|
+
if (push.code !== 0) { print(`FAIL: git push failed — ${push.out}`); print(resume); return 1 }
|
|
254
|
+
const pushTag = git(root, ['push', remoteName, tag])
|
|
255
|
+
if (pushTag.code !== 0) { print(`FAIL: pushing ${tag} failed — ${pushTag.out}`); print(resume); return 1 }
|
|
256
|
+
print(`ok: tagged ${tag} and pushed to ${remoteName}`)
|
|
257
|
+
|
|
258
|
+
if (wantGh) {
|
|
259
|
+
const notesFile = join(work, 'notes.md')
|
|
260
|
+
writeFileSync(notesFile, notes)
|
|
261
|
+
const args = ['gh', 'release', 'create', tag, '--verify-tag', '--title', `${name} ${version}`, '--notes-file', notesFile]
|
|
262
|
+
const g = Bun.spawnSync(args, { cwd: root, stdout: 'inherit', stderr: 'inherit' })
|
|
263
|
+
if (g.exitCode !== 0) { print(`FAIL: gh release create failed (exit ${g.exitCode})`); print(`${resume} — the tag is already pushed`); return 1 }
|
|
264
|
+
print(`ok: GitHub release ${tag} live`)
|
|
265
|
+
}
|
|
266
|
+
print(`ok: ${name}@${version} released${wantNpm ? ' (npm + tag' : ' (tag'}${wantGh ? ' + github release)' : ')'}`)
|
|
267
|
+
return 0
|
|
268
|
+
} finally {
|
|
269
|
+
rmSync(work, { recursive: true, force: true })
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// `shipkit releases` — where every release unit actually stands. Report only:
|
|
2
|
+
// it never gates, never writes, and exits 0 even when everything is drifted,
|
|
3
|
+
// because it exists to end the "what is published and what is not" question,
|
|
4
|
+
// not to add another red light.
|
|
5
|
+
//
|
|
6
|
+
// the table stays one line per unit and carries only facts that fit in a
|
|
7
|
+
// column. Anything that needs a sentence (drift, stale notes, an unreachable
|
|
8
|
+
// remote) prints BELOW the table, so the noise is proportional to the problem.
|
|
9
|
+
import { existsSync, readFileSync } from 'fs'
|
|
10
|
+
import { relative } from 'path'
|
|
11
|
+
import { hasUnreleasedHeading, hasVersionSection } from './changelog.ts'
|
|
12
|
+
import { deriveRemote, git, remoteTags } from './git-facts.ts'
|
|
13
|
+
import { formatUnitError, resolveUnits, type ReleaseUnit } from './units.ts'
|
|
14
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
15
|
+
|
|
16
|
+
export type UnitReport = {
|
|
17
|
+
id: string
|
|
18
|
+
rel: string
|
|
19
|
+
name: string
|
|
20
|
+
version: string
|
|
21
|
+
tag: string
|
|
22
|
+
/** local tag present */
|
|
23
|
+
tagLocal: boolean
|
|
24
|
+
/** on the remote; null when the remote could not be read */
|
|
25
|
+
tagRemote: boolean | null
|
|
26
|
+
/** npm dist-tags.latest, 'unpublished' when 404, null when not an npm target or unreadable */
|
|
27
|
+
npmLatest: string | null
|
|
28
|
+
/** why the registry could not be read — an unmeasured registry must never
|
|
29
|
+
* render as "in sync" */
|
|
30
|
+
npmError: string | null
|
|
31
|
+
npmTarget: boolean
|
|
32
|
+
isPrivate: boolean
|
|
33
|
+
ghWanted: boolean
|
|
34
|
+
/** null when gh could not answer */
|
|
35
|
+
ghRelease: boolean | null
|
|
36
|
+
/** commits touching this unit since its tag; null when the tag is absent */
|
|
37
|
+
commitsSinceTag: number | null
|
|
38
|
+
/** the unit's tree differs from the tree at its tag; null when unassessable */
|
|
39
|
+
contentDiffers: boolean | null
|
|
40
|
+
/** the changelog carries a `## <version>` section */
|
|
41
|
+
hasSection: boolean
|
|
42
|
+
/** commits touching the unit AFTER the commit that wrote that section —
|
|
43
|
+
* the version was stamped and work kept landing under notes that
|
|
44
|
+
* describe none of it */
|
|
45
|
+
commitsAfterNotes: number | null
|
|
46
|
+
hasUnreleased: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const pathspec = (u: ReleaseUnit): string[] => (u.rel === '' ? [] : ['--', u.rel])
|
|
50
|
+
|
|
51
|
+
/** the commit that introduced this version's HEADING. Line-anchored on
|
|
52
|
+
* purpose: a substring pickaxe also matches prose that MENTIONS `## 1.2.3`,
|
|
53
|
+
* so a sentence in the Unreleased section moves the anchor to itself and
|
|
54
|
+
* silences the stale-notes warning it was talking about. */
|
|
55
|
+
export function notesAnchor(root: string, changelogRel: string, version: string): string | null {
|
|
56
|
+
const h = git(root, ['log', '-1', '--format=%H', '-G', `^## ${version.replace(/\./g, '\\.')}`, '--', changelogRel])
|
|
57
|
+
return h.code === 0 && h.out !== '' ? h.out : null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** the git tree hash of the unit dir at a ref. Tree hashes, never commit
|
|
61
|
+
* counts: a folder rename must not read as a content change. A tag can
|
|
62
|
+
* predate the move, so a miss falls back to finding the directory by name
|
|
63
|
+
* inside that ref's tree, or drift goes silently unassessed. */
|
|
64
|
+
function treeAt(root: string, u: ReleaseUnit, ref: string): string | null {
|
|
65
|
+
const rev = (path: string): string | null => {
|
|
66
|
+
const r = git(root, ['rev-parse', path === '' ? ref : `${ref}:${path}`])
|
|
67
|
+
return r.code === 0 ? r.out : null
|
|
68
|
+
}
|
|
69
|
+
const direct = rev(u.rel)
|
|
70
|
+
if (direct != null || u.rel === '') return direct
|
|
71
|
+
const base = u.rel.split('/').at(-1)!
|
|
72
|
+
const ls = git(root, ['ls-tree', '-r', '-d', '--name-only', ref])
|
|
73
|
+
if (ls.code !== 0) return null
|
|
74
|
+
const hit = ls.out.split('\n').find((p) => p === base || p.endsWith(`/${base}`))
|
|
75
|
+
return hit != null ? rev(hit) : null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function collectReports(root: string, config: RepoConfig, o: { offline?: boolean } = {}): Promise<{ reports: UnitReport[]; errors: string[]; remote: string | null; remoteError: string | null }> {
|
|
79
|
+
// a unit that does not resolve is still news: it is named, and everything
|
|
80
|
+
// that DID resolve is still reported beside it
|
|
81
|
+
const { units, errors } = resolveUnits(root, config)
|
|
82
|
+
const derived = deriveRemote(root)
|
|
83
|
+
const remote = 'remote' in derived ? derived.remote : null
|
|
84
|
+
let remoteError = 'error' in derived ? derived.error : null
|
|
85
|
+
let pushed: Set<string> | null = null
|
|
86
|
+
if (remote != null && o.offline !== true) {
|
|
87
|
+
const r = remoteTags(root, remote)
|
|
88
|
+
if ('tags' in r) pushed = r.tags
|
|
89
|
+
else remoteError = `git ls-remote ${remote} unreachable (${r.error})`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const reports: UnitReport[] = []
|
|
93
|
+
for (const u of units) {
|
|
94
|
+
const tagLocal = git(root, ['rev-parse', '--quiet', '--verify', `refs/tags/${u.tag}`]).code === 0
|
|
95
|
+
const changelog = existsSync(u.changelog) ? readFileSync(u.changelog, 'utf8') : ''
|
|
96
|
+
const hasSection = hasVersionSection(changelog, u.version)
|
|
97
|
+
const changelogRel = relative(root, u.changelog)
|
|
98
|
+
// the commit that introduced this version's heading, then everything
|
|
99
|
+
// that touched the unit afterwards but is not the changelog itself
|
|
100
|
+
let commitsAfterNotes: number | null = null
|
|
101
|
+
const anchor = hasSection ? notesAnchor(root, changelogRel, u.version) : null
|
|
102
|
+
if (anchor != null) {
|
|
103
|
+
const c = git(root, ['rev-list', '--count', `${anchor}..HEAD`, ...(u.rel === '' ? ['--'] : ['--', u.rel]), `:(exclude)${changelogRel}`])
|
|
104
|
+
if (c.code === 0) commitsAfterNotes = Number(c.out)
|
|
105
|
+
}
|
|
106
|
+
const since = tagLocal ? git(root, ['rev-list', '--count', `${u.tag}..HEAD`, ...pathspec(u)]) : null
|
|
107
|
+
const tagTree = tagLocal ? treeAt(root, u, u.tag) : null
|
|
108
|
+
const headTree = tagLocal ? treeAt(root, u, 'HEAD') : null
|
|
109
|
+
|
|
110
|
+
let npmLatest: string | null = null
|
|
111
|
+
let npmError: string | null = null
|
|
112
|
+
if (u.targets.npm && o.offline !== true) {
|
|
113
|
+
const res = await fetch(`https://registry.npmjs.org/${u.name}`, { signal: AbortSignal.timeout(10000) }).catch(() => null)
|
|
114
|
+
if (res == null) npmError = 'registry unreachable'
|
|
115
|
+
else if (res.ok) {
|
|
116
|
+
npmLatest = ((await res.json()) as { 'dist-tags'?: { latest?: string } })['dist-tags']?.latest ?? null
|
|
117
|
+
if (npmLatest == null) npmError = 'registry answered without a dist-tags.latest'
|
|
118
|
+
} else if (res.status === 404) npmLatest = 'unpublished'
|
|
119
|
+
else npmError = `registry answered ${res.status}`
|
|
120
|
+
}
|
|
121
|
+
const ghRelease = u.targets.githubRelease && o.offline !== true && Bun.which('gh') != null
|
|
122
|
+
? Bun.spawnSync(['gh', 'release', 'view', u.tag], { cwd: root }).exitCode === 0
|
|
123
|
+
: null
|
|
124
|
+
|
|
125
|
+
reports.push({
|
|
126
|
+
id: u.id,
|
|
127
|
+
rel: u.rel === '' ? '.' : u.rel,
|
|
128
|
+
name: u.name,
|
|
129
|
+
version: u.version,
|
|
130
|
+
tag: u.tag,
|
|
131
|
+
tagLocal,
|
|
132
|
+
tagRemote: pushed == null ? null : pushed.has(u.tag),
|
|
133
|
+
npmLatest,
|
|
134
|
+
npmError,
|
|
135
|
+
npmTarget: u.targets.npm,
|
|
136
|
+
isPrivate: u.isPrivate,
|
|
137
|
+
ghWanted: u.targets.githubRelease,
|
|
138
|
+
ghRelease,
|
|
139
|
+
commitsSinceTag: since != null && since.code === 0 ? Number(since.out) : null,
|
|
140
|
+
contentDiffers: tagTree != null && headTree != null ? tagTree !== headTree : null,
|
|
141
|
+
hasSection,
|
|
142
|
+
commitsAfterNotes,
|
|
143
|
+
hasUnreleased: hasUnreleasedHeading(changelog),
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
return { reports, errors: errors.map(formatUnitError), remote, remoteError }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const npmCell = (r: UnitReport): string => {
|
|
150
|
+
if (!r.npmTarget) return r.isPrivate ? 'private' : 'off'
|
|
151
|
+
if (r.npmLatest == null) return '?'
|
|
152
|
+
return r.npmLatest
|
|
153
|
+
}
|
|
154
|
+
const tagCell = (r: UnitReport): string => {
|
|
155
|
+
if (!r.tagLocal) return 'missing'
|
|
156
|
+
if (r.tagRemote == null) return 'local, remote ?'
|
|
157
|
+
return r.tagRemote ? 'pushed' : 'LOCAL ONLY'
|
|
158
|
+
}
|
|
159
|
+
const ghCell = (r: UnitReport): string => (!r.ghWanted ? 'off' : r.ghRelease == null ? '?' : r.ghRelease ? 'live' : 'missing')
|
|
160
|
+
/** work that landed since the tag. A COLUMN, because it is the normal state of
|
|
161
|
+
* a repo between releases, and a warning that is always on says nothing. */
|
|
162
|
+
const sinceCell = (r: UnitReport): string => {
|
|
163
|
+
if (!r.tagLocal) return 'never tagged'
|
|
164
|
+
if (r.commitsSinceTag == null) return '?'
|
|
165
|
+
if (r.commitsSinceTag > 0) return `${r.commitsSinceTag} commit(s)`
|
|
166
|
+
return r.contentDiffers === true ? 'edited' : 'clean'
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** the sentences the table cannot hold. At most ONE next action per unit:
|
|
170
|
+
* two lines telling you to publish and not to publish the same version is
|
|
171
|
+
* the tool being useless out loud. Independent facts (a stranded tag, a
|
|
172
|
+
* changelog with nothing in it) print beside it.
|
|
173
|
+
*
|
|
174
|
+
* work landing after a release is NORMAL, so it is a table column and never
|
|
175
|
+
* a warning: a line that fires on every commit after every release trains
|
|
176
|
+
* the reader to skip the ones that mean something. ZERO commits since the tag
|
|
177
|
+
* with a differing tree is a different state, and the column word for it
|
|
178
|
+
* ("edited") cannot say what it means. */
|
|
179
|
+
export function attentionLines(r: UnitReport): string[] {
|
|
180
|
+
const out: string[] = []
|
|
181
|
+
const behindNpm = r.npmTarget && r.npmLatest != null && r.npmLatest !== 'unpublished' && r.npmLatest !== r.version
|
|
182
|
+
const npmClause = behindNpm ? `, and npm still has ${r.npmLatest ?? ''}` : ''
|
|
183
|
+
const notesClause = r.hasUnreleased ? '' : 'write the notes under "## Unreleased", then '
|
|
184
|
+
// the tool knows the unit and its state, never whether the change is a fix,
|
|
185
|
+
// a feature or a break — stating a kind it cannot know is a guess in the
|
|
186
|
+
// shape of a command
|
|
187
|
+
const KIND = '<patch|minor|major>'
|
|
188
|
+
|
|
189
|
+
if (r.commitsAfterNotes != null && r.commitsAfterNotes > 0 && !r.tagLocal) {
|
|
190
|
+
// the trap: a version stamped into package.json while work kept
|
|
191
|
+
// landing. Publishing it ships that work under notes describing none
|
|
192
|
+
// of it, so the answer is a NEW version, never this one.
|
|
193
|
+
out.push(`WARN ${r.id}: ${r.version} was stamped but ${r.commitsAfterNotes} commit(s) landed after its notes${npmClause} — ${notesClause}shipkit bump ${KIND} ${r.id}`)
|
|
194
|
+
} else if (behindNpm && r.hasSection) {
|
|
195
|
+
// without the section `shipkit release` refuses (extractChangelogSection
|
|
196
|
+
// throws), so naming it here is an order the tool knows will fail
|
|
197
|
+
out.push(`WARN ${r.id}: local ${r.version} vs npm ${r.npmLatest} — shipkit release ${r.id}`)
|
|
198
|
+
} else if (r.hasUnreleased) {
|
|
199
|
+
// written notes with no release behind them are a DECISION waiting on
|
|
200
|
+
// a human, not a defect: a note, so the row still reads as healthy
|
|
201
|
+
out.push(`note: ${r.id}: "## Unreleased" notes are waiting — shipkit bump ${KIND} ${r.id}`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (r.commitsSinceTag === 0 && r.contentDiffers === true)
|
|
205
|
+
out.push(`WARN ${r.id}: nothing landed since ${r.tag}, yet its tree differs from it — ${r.tag} names contents this branch does not carry`)
|
|
206
|
+
if (r.npmTarget && r.npmError != null)
|
|
207
|
+
out.push(`WARN ${r.id}: npm ${r.npmError} — drift against the registry is UNASSESSED, not clean`)
|
|
208
|
+
if (r.tagLocal && r.tagRemote === false)
|
|
209
|
+
out.push(`WARN ${r.id}: ${r.tag} exists here and nowhere else — invisible on GitHub until it is pushed`)
|
|
210
|
+
if (!r.hasSection && !r.hasUnreleased)
|
|
211
|
+
out.push(`WARN ${r.id}: no "## ${r.version}" section and no "## Unreleased" in ${r.id === 'root' ? 'the root changelog' : 'its changelog'} — nothing to release from`)
|
|
212
|
+
return out
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function renderReports(reports: UnitReport[], o: { remoteError?: string | null; offline?: boolean } = {}): string[] {
|
|
216
|
+
const rows = [
|
|
217
|
+
['UNIT', 'PATH', 'VERSION', 'NPM', 'TAG', 'SINCE TAG', 'GITHUB'],
|
|
218
|
+
...reports.map((r) => [r.id, r.rel, r.version, npmCell(r), tagCell(r), sinceCell(r), ghCell(r)]),
|
|
219
|
+
]
|
|
220
|
+
const w = rows[0]!.map((_, i) => Math.max(...rows.map((row) => (row[i] ?? '').length)))
|
|
221
|
+
const lines = rows.map((row) => row.map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(w[i]!))).join(' ').trimEnd())
|
|
222
|
+
for (const r of reports) lines.push(...attentionLines(r))
|
|
223
|
+
if (o.remoteError != null) lines.push(`WARN ${o.remoteError} — tag push state UNKNOWN, not a tag problem`)
|
|
224
|
+
// every `?` in the table comes from the same decision, so it is explained
|
|
225
|
+
// once rather than once per cell
|
|
226
|
+
if (o.offline === true) lines.push('note: --offline, so npm, the remote and gh were not read (the ? cells)')
|
|
227
|
+
const subs = reports.length - 1
|
|
228
|
+
lines.push(`note: ${reports.length} release unit(s) — the repo root${subs > 0 ? ` + ${subs} declared` : ' only, nothing else declared'}`)
|
|
229
|
+
return lines
|
|
230
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// what a single unit must prove before its version is written or published.
|
|
2
|
+
// The ROOT unit is the repo, so `shipkit ci` already gates its tests and its
|
|
3
|
+
// typecheck; this adds the per-unit proof only a SUB-unit lacks, because a
|
|
4
|
+
// lib compiles and tests standalone under its own tsconfig and the root
|
|
5
|
+
// tsconfig deliberately excludes it.
|
|
6
|
+
import { existsSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { dirtyPaths } from './git-facts.ts'
|
|
9
|
+
import type { ReleaseUnit } from './units.ts'
|
|
10
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
11
|
+
|
|
12
|
+
/** the unit's own tsc: its node_modules, then the repo's, then PATH */
|
|
13
|
+
export function resolveTsc(root: string, unit: ReleaseUnit): string | null {
|
|
14
|
+
for (const c of [join(unit.dir, 'node_modules/.bin/tsc'), join(root, 'node_modules/.bin/tsc')]) if (existsSync(c)) return c
|
|
15
|
+
return Bun.which('tsc')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** the publish argv. `--access public` is not a default: npm restricts a
|
|
19
|
+
* scoped package unless it is asked, and a restricted publish looks
|
|
20
|
+
* successful right up until a consumer cannot install it. */
|
|
21
|
+
export function npmPublishArgv(o: { npmrc?: string | null } = {}): string[] {
|
|
22
|
+
return ['npm', 'publish', '--access', 'public', ...(o.npmrc != null ? ['--userconfig', o.npmrc] : [])]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** count of passing tests in a `bun test` run, or null when the output does
|
|
26
|
+
* not say — a suite whose result cannot be read is not a proven suite */
|
|
27
|
+
export const passCount = (out: string): number | null => {
|
|
28
|
+
const m = out.match(/(\d+) pass/)
|
|
29
|
+
return m?.[1] != null ? Number(m[1]) : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** everything that must hold for this unit, checked before anything is
|
|
33
|
+
* written. Empty means the unit is releasable as far as the machine can tell. */
|
|
34
|
+
export function unitBlockers(root: string, config: RepoConfig, unit: ReleaseUnit): string[] {
|
|
35
|
+
const out: string[] = []
|
|
36
|
+
// the tag must describe exactly what was packed, or every drift probe
|
|
37
|
+
// downstream compares a tree that never existed. --allow-dirty deliberately
|
|
38
|
+
// does NOT reach this: it exists for noise elsewhere in the worktree.
|
|
39
|
+
if (unit.rel !== '') {
|
|
40
|
+
const dirty = dirtyPaths(root, config, unit.rel)
|
|
41
|
+
if (dirty !== '') out.push(`unit '${unit.id}': uncommitted changes under ${unit.rel} — commit them, the tag must describe what ships:\n${dirty.split('\n').slice(0, 6).join('\n')}`)
|
|
42
|
+
}
|
|
43
|
+
if (unit.targets.npm && unit.tsconfig == null)
|
|
44
|
+
out.push(`unit '${unit.id}': publishes to npm but has no tsconfig.json — a package nobody typechecks standalone is a package that breaks on install`)
|
|
45
|
+
if (unit.id === 'root') return out
|
|
46
|
+
|
|
47
|
+
if (Bun.which('bun') == null) return [...out, 'bun is not on PATH']
|
|
48
|
+
const t = Bun.spawnSync(['bun', 'test'], { cwd: unit.dir })
|
|
49
|
+
const tOut = `${t.stdout.toString()}${t.stderr.toString()}`
|
|
50
|
+
if (t.exitCode !== 0) out.push(`unit '${unit.id}': its own test suite is red (exit ${t.exitCode}) — bun test in ${unit.rel}`)
|
|
51
|
+
else {
|
|
52
|
+
const pass = passCount(tOut)
|
|
53
|
+
if (pass == null || pass < 1) out.push(`unit '${unit.id}': its test suite ran 0 tests — a vacuous suite proves nothing, write a real one`)
|
|
54
|
+
}
|
|
55
|
+
if (unit.tsconfig != null) {
|
|
56
|
+
const tsc = resolveTsc(root, unit)
|
|
57
|
+
if (tsc == null) out.push(`unit '${unit.id}': tsc not resolvable — bun add -d typescript at the repo root`)
|
|
58
|
+
else {
|
|
59
|
+
const r = Bun.spawnSync([tsc, '--noEmit', '-p', unit.tsconfig], { cwd: root })
|
|
60
|
+
if (r.exitCode !== 0) {
|
|
61
|
+
const lines = `${r.stdout.toString()}${r.stderr.toString()}`.trim().split('\n').slice(0, 8).join('\n')
|
|
62
|
+
out.push(`unit '${unit.id}': does not typecheck under its own tsconfig — it cannot be published\n${lines}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|