rvlib-shipkit 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +6 -22
  3. package/package.json +1 -1
  4. package/src/assets/kits/identity-kit.ts +1 -1
  5. package/src/assets/panels/geometry-rects.ts +3 -17
  6. package/src/assets/server/studio-server.ts +1 -1
  7. package/src/cli/cmd_Bump.ts +52 -0
  8. package/src/cli/cmd_Check.ts +2 -2
  9. package/src/cli/cmd_Ci.ts +10 -2
  10. package/src/cli/cmd_Commands.ts +126 -0
  11. package/src/cli/cmd_Hooks.ts +66 -0
  12. package/src/cli/cmd_Mv.ts +29 -6
  13. package/src/cli/cmd_Release.ts +41 -0
  14. package/src/cli/cmd_Releases.ts +42 -0
  15. package/src/cli/cmd_Rules.ts +20 -6
  16. package/src/cli/cmd_Start.ts +2 -2
  17. package/src/cli.ts +14 -1
  18. package/src/collections/posts.ts +1 -1
  19. package/src/collections/providers.ts +1 -1
  20. package/src/contracts/manifests/discord.contract.ts +1 -1
  21. package/src/contracts/manifests/itch.contract.ts +2 -4
  22. package/src/core/canonical-scripts.ts +9 -14
  23. package/src/core/check-suite.ts +1 -1
  24. package/src/core/discover-checks.ts +21 -6
  25. package/src/core/run-check.ts +69 -21
  26. package/src/core/run-ci.ts +75 -9
  27. package/src/core/run-fixers.ts +144 -0
  28. package/src/core/write-status.ts +14 -5
  29. package/src/hooks/commit-message.ts +42 -0
  30. package/src/hooks/leak-guard.ts +0 -0
  31. package/src/hooks/run-hooks.ts +131 -0
  32. package/src/init-repo.ts +10 -5
  33. package/src/reflect/prompt-corpus.ts +3 -4
  34. package/src/release/changelog.ts +53 -0
  35. package/src/release/git-facts.ts +133 -0
  36. package/src/release/run-bump.ts +419 -0
  37. package/src/release/run-release.ts +267 -0
  38. package/src/release/run-releases.ts +230 -0
  39. package/src/release/unit-gate.ts +67 -0
  40. package/src/release/units.ts +246 -0
  41. package/src/repo-config.ts +56 -2
  42. package/src/rules/SK001-package-json-shape.ts +39 -17
  43. package/src/rules/SK002-contracts-fulfillness.ts +1 -0
  44. package/src/rules/SK003-claude-md-shape.ts +2 -1
  45. package/src/rules/SK004-gitignore-policy.ts +1 -0
  46. package/src/rules/SK005-rvlib-versions.ts +22 -3
  47. package/src/rules/SK006-npm-publish-safety.ts +1 -0
  48. package/src/rules/SK007-github-visibility.ts +5 -1
  49. package/src/rules/SK008-reviewer-agent.ts +1 -0
  50. package/src/rules/SK009-commit-ai-tells.ts +66 -0
  51. package/src/rules/SK010-undeclared-release-unit.ts +114 -0
  52. package/src/rules/rule-registry.ts +26 -6
  53. package/src/rules/rule.ts +8 -1
  54. package/src/rules/run-fix.ts +15 -8
  55. package/src/serve/hub-app/col-card.tsx +2 -3
  56. package/src/serve/hub-app/hub-app.tsx +6 -8
  57. package/src/serve/hub-app/hub-st.ts +4 -7
  58. package/src/serve/hub-app/media-view.tsx +2 -2
  59. package/src/serve/run-serve.ts +4 -5
  60. package/src/serve/serve-hub.ts +2 -3
  61. package/src/shipkit.ts +9 -1
  62. package/src/tui/checks-panel.ts +1 -1
  63. package/src/tui/run-tui.ts +2 -3
  64. package/src/tui/tasks-panel.ts +10 -4
  65. package/src/tui/tui-store.ts +2 -2
@@ -0,0 +1,267 @@
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
+ config,
96
+ failOnAdvisory: config.release?.requireHealthy === true,
97
+ print: (l) => print(` ${l}`),
98
+ })
99
+ }
100
+
101
+ /** `shipkit release [unit]` — resolve the unit, then ship it. */
102
+ export async function runRelease(root: string, config: RepoConfig, o: ReleaseOpts = {}): Promise<number> {
103
+ const print = o.print ?? ((l: string) => console.log(l))
104
+ const { units, errors } = resolveUnits(root, config)
105
+ const id = o.unit ?? 'root'
106
+ // only THIS unit's own resolution has to hold: a broken sibling is not a
107
+ // reason to refuse a release of something that resolves fine
108
+ const mine = errors.filter((e) => e.id === id)
109
+ if (mine.length > 0) { for (const e of mine) print(`FAIL: ${formatUnitError(e)}`); return 1 }
110
+ const unit = unitById(units, id)
111
+ if (unit == null) {
112
+ print(`FAIL: no release unit '${id}' — known units: ${units.map((u) => u.id).join(', ')}`)
113
+ print(' fix: shipkit releases')
114
+ return 1
115
+ }
116
+ return await releaseUnit(root, config, unit, o)
117
+ }
118
+
119
+ /** ship ONE resolved unit. Every gate above it has already run when bump
120
+ * calls this; a bare `shipkit release` runs them here. */
121
+ export async function releaseUnit(root: string, config: RepoConfig, unit: ReleaseUnit, o: ReleaseOpts = {}): Promise<number> {
122
+ const print = o.print ?? ((l: string) => console.log(l))
123
+ const rc = config.release ?? {}
124
+ const branch = rc.branch ?? 'main'
125
+ const { name, version, tag } = unit
126
+ const wantNpm = unit.targets.npm
127
+ const wantGh = unit.targets.githubRelease
128
+
129
+ const blockers: string[] = []
130
+ const notesResult = ((): { notes: string } | { error: string } => {
131
+ if (!existsSync(unit.changelog)) return { error: `unit '${unit.id}': CHANGELOG not found at ${unit.changelog} — a release states what changed` }
132
+ try { return { notes: extractChangelogSection({ changelog: readFileSync(unit.changelog, 'utf8'), version }) } }
133
+ catch (e) { return { error: `unit '${unit.id}': ${e instanceof Error ? e.message : String(e)}` } }
134
+ })()
135
+ if ('error' in notesResult) blockers.push(notesResult.error)
136
+ const notes = 'notes' in notesResult ? notesResult.notes : ''
137
+
138
+ const cur = git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])
139
+ if (cur.code !== 0) blockers.push('not a git repository')
140
+ else if (cur.out !== branch) blockers.push(`releases ship from '${branch}', HEAD is on '${cur.out}'`)
141
+
142
+ const dirty = dirtyPaths(root, config)
143
+ if (dirty !== '' && o.allowDirty !== true)
144
+ blockers.push(`working tree not clean (--allow-dirty overrides):\n${dirty.split('\n').slice(0, 10).join('\n')}`)
145
+ if (o.skipUnitGate !== true) blockers.push(...unitBlockers(root, config, unit))
146
+
147
+ let alreadyPublished = false
148
+ if (wantNpm) {
149
+ const res = await fetch(`https://registry.npmjs.org/${name}`, { signal: AbortSignal.timeout(15000) }).catch(() => null)
150
+ if (res == null) blockers.push('npm registry unreachable — offline?')
151
+ else if (res.ok) {
152
+ const versions = ((await res.json()) as { versions?: Record<string, unknown> }).versions ?? {}
153
+ alreadyPublished = versions[version] != null
154
+ } else if (res.status !== 404) blockers.push(`npm registry lookup for ${name} → ${res.status}`)
155
+ }
156
+ // a tag that exists but was never pushed, or whose github release is
157
+ // missing, means a previous run died mid-pipeline. re-running must finish
158
+ // it, never demand a burnt version number. only a FULLY shipped version is
159
+ // a dead end.
160
+ const remote = deriveRemote(root)
161
+ if ('error' in remote) blockers.push(remote.error)
162
+ const remoteName0 = 'remote' in remote ? remote.remote : null
163
+ const tagExists = (): boolean => tagExistsLocally(root, tag)
164
+ const remoteState: RemoteTagState = remoteName0 == null ? { state: 'unreadable', error: 'no remote' } : remoteTagState(root, remoteName0, tag)
165
+ if (remoteName0 != null && remoteState.state === 'unreadable')
166
+ blockers.push(`cannot read tags on '${remoteName0}' (${remoteState.error}) — the push would be a guess`)
167
+ // a tag NAME the remote already carries at another commit is not a
168
+ // resumable release: this version would describe a tree that shipped as
169
+ // something else, and git refuses the push anyway
170
+ if (remoteState.state === 'different')
171
+ 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`)
172
+ const ghReleaseExists = (): boolean =>
173
+ wantGh && Bun.which('gh') != null && Bun.spawnSync(['gh', 'release', 'view', tag], { cwd: root }).exitCode === 0
174
+ const fullyShipped = tagExists() && remoteState.state === 'same' && (!wantGh || ghReleaseExists()) && (!wantNpm || alreadyPublished)
175
+ if (fullyShipped) blockers.push(`${name}@${version} is fully released (tag ${tag}${wantNpm ? ' + npm' : ''}) — bump the version first`)
176
+ else if (tagExists()) print(`WARN ${tag} exists but the release is incomplete — resuming the remaining steps`)
177
+ else if (alreadyPublished) print(`WARN ${version} is on npm but ${tag} is missing — resuming after publish`)
178
+
179
+ if (wantNpm && rc.npmTokenCommand == null) print('note: no release.npmTokenCommand — publishing with ambient npm auth')
180
+ if (wantGh && Bun.which('gh') == null) blockers.push('gh not installed and this unit wants a GitHub release — install gh or set githubRelease: false')
181
+ else if (wantGh && Bun.spawnSync(['gh', 'auth', 'status']).exitCode !== 0) blockers.push('gh is not authenticated (gh auth login)')
182
+
183
+ const steps = [
184
+ ...(wantNpm && !alreadyPublished ? [`npm publish ${name}@${version}`] : []),
185
+ ...(tagExists() ? [] : [`git tag ${tag}`]),
186
+ `git push + push tag ${tag}`,
187
+ ...(wantGh ? [`gh release create ${tag}`] : []),
188
+ ]
189
+
190
+ 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`}`)
191
+ for (const s of steps) print(`note: ${s}`)
192
+
193
+ if (blockers.length > 0) {
194
+ for (const b of blockers) print(`FAIL: ${b}`)
195
+ return 1
196
+ }
197
+
198
+ // the gate runs BEFORE anything leaves the machine. A release that skips
199
+ // its own gate is exactly how a broken build, or a secret, ships. It runs
200
+ // in verify + frozen mode: a release publishes the reviewed commit, never
201
+ // a tree a fixer or a self-updating rule rewrote on the way out.
202
+ if (o.skipGate === true)
203
+ print(o.skipGateReason != null ? `ok: ci gate ${o.skipGateReason}` : 'WARN ci gate SKIPPED by --skip-gate — publishing unverified')
204
+ else {
205
+ if ((await runReleaseGate(root, config, print)) !== 0) { print('FAIL: ci gate is red — fix it, commit, then release'); return 1 }
206
+ // frozen is a declaration, not a guarantee: any check may still write.
207
+ // npm packs the DIRECTORY, so a rewrite here would publish a tarball
208
+ // that exists in no commit while the tag describes another tree.
209
+ const afterGate = dirtyPaths(root, config)
210
+ if (afterGate !== '' && o.allowDirty !== true) {
211
+ 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')}`)
212
+ print(' fix: commit those changes, then run release again')
213
+ return 1
214
+ }
215
+ print('ok: ci gate green')
216
+ }
217
+
218
+ if (o.dryRun === true) { print(`ok: dry run clean — nothing was touched`); return 0 }
219
+
220
+ const remoteName = (remote as { remote: string }).remote
221
+ const work = mkdtempSync(join(tmpdir(), 'shipkit-release-'))
222
+ try {
223
+ if (wantNpm && !alreadyPublished) {
224
+ const env: Record<string, string> = { ...process.env as Record<string, string> }
225
+ const argv = rc.npmTokenCommand
226
+ let npmrc: string | null = null
227
+ if (argv != null && argv[0] != null) {
228
+ const t = Bun.spawnSync(argv)
229
+ const token = t.exitCode === 0 ? t.stdout.toString().trim() : ''
230
+ if (token === '') { print(`FAIL: release.npmTokenCommand produced no token (exit ${t.exitCode})`); return 1 }
231
+ // token via a throwaway userconfig, never argv, never a
232
+ // committed .npmrc, never a line in a log
233
+ npmrc = join(work, 'npmrc')
234
+ writeFileSync(npmrc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 })
235
+ }
236
+ const p = Bun.spawnSync(npmPublishArgv({ npmrc }), { cwd: unit.dir, env, stdout: 'inherit', stderr: 'inherit' })
237
+ if (p.exitCode !== 0) { print(`FAIL: npm publish failed for ${unit.id} (exit ${p.exitCode})`); return 1 }
238
+ print(`ok: published ${name}@${version}`)
239
+ }
240
+
241
+ // every step below is recoverable by re-running: the failure message
242
+ // says so, because a half-released package is the scariest state
243
+ const resume = ` fix: shipkit release ${unit.id}`
244
+ if (!tagExists()) {
245
+ const t = git(root, ['tag', '-a', tag, '-m', `${name} ${version}`])
246
+ if (t.code !== 0) { print(`FAIL: git tag ${tag} failed — ${t.out}`); print(resume); return 1 }
247
+ }
248
+ const push = git(root, ['push', remoteName])
249
+ if (push.code !== 0) { print(`FAIL: git push failed — ${push.out}`); print(resume); return 1 }
250
+ const pushTag = git(root, ['push', remoteName, tag])
251
+ if (pushTag.code !== 0) { print(`FAIL: pushing ${tag} failed — ${pushTag.out}`); print(resume); return 1 }
252
+ print(`ok: tagged ${tag} and pushed to ${remoteName}`)
253
+
254
+ if (wantGh) {
255
+ const notesFile = join(work, 'notes.md')
256
+ writeFileSync(notesFile, notes)
257
+ const args = ['gh', 'release', 'create', tag, '--verify-tag', '--title', `${name} ${version}`, '--notes-file', notesFile]
258
+ const g = Bun.spawnSync(args, { cwd: root, stdout: 'inherit', stderr: 'inherit' })
259
+ if (g.exitCode !== 0) { print(`FAIL: gh release create failed (exit ${g.exitCode})`); print(`${resume} — the tag is already pushed`); return 1 }
260
+ print(`ok: GitHub release ${tag} live`)
261
+ }
262
+ print(`ok: ${name}@${version} released${wantNpm ? ' (npm + tag' : ' (tag'}${wantGh ? ' + github release)' : ')'}`)
263
+ return 0
264
+ } finally {
265
+ rmSync(work, { recursive: true, force: true })
266
+ }
267
+ }
@@ -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
+ }