rvlib-shipkit 0.13.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 +163 -0
- package/README.md +6 -22
- package/package.json +2 -2
- 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/contract-types.ts +5 -2
- package/src/contracts/contracts-engine.ts +13 -3
- package/src/contracts/manifests/discord.contract.ts +2 -2
- package/src/contracts/manifests/identity.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +17 -2
- 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 +13 -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 +66 -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 +99 -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 +28 -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
package/src/core/run-ci.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
// the ci gate — floor + contracts + repo tasks through the DAG runner
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// atomic + lock) unless
|
|
1
|
+
// the ci gate — floor + contracts + repo tasks through the DAG runner, THEN
|
|
2
|
+
// every discovered check (core/run-check.ts owns severity). Plain module
|
|
3
|
+
// behind cmd_Ci (logic stays out of command classes) so tests can run the
|
|
4
|
+
// gate in-process. Returns the exit code; json prints the structured summary.
|
|
5
|
+
// Every run refreshes STATUS.md (write-status.ts — atomic + lock) unless
|
|
6
|
+
// status:false, and the fix pass plus some checks WRITE, so a run is not
|
|
7
|
+
// read-only unless frozen is set and --no-fix is passed.
|
|
6
8
|
import { join } from 'path'
|
|
7
|
-
import { allTasks, loadRepoConfig } from '../repo-config.ts'
|
|
9
|
+
import { allTasks, loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
8
10
|
import { reportToMotherhome } from './motherhome.ts'
|
|
11
|
+
import { collectChecks } from './run-check.ts'
|
|
12
|
+
import { runFixPass } from './run-fixers.ts'
|
|
13
|
+
import { formatLine } from './wire.ts'
|
|
9
14
|
import { runTasks, type TaskResult } from './task-runner.ts'
|
|
10
15
|
import { writeStatus } from './write-status.ts'
|
|
11
16
|
|
|
@@ -17,17 +22,57 @@ export type CiOpts = {
|
|
|
17
22
|
onlyIds?: string[]
|
|
18
23
|
/** refresh STATUS.md after the run (default true — O4: every repo gets it) */
|
|
19
24
|
status?: boolean
|
|
25
|
+
/** repair the mechanical slots before gating. default on locally, verify
|
|
26
|
+
* mode under CI. core/run-fixers.ts owns the scope. */
|
|
27
|
+
fix?: boolean
|
|
28
|
+
/** also apply the structural SK rule fixers during the fix pass */
|
|
29
|
+
fixRules?: boolean
|
|
30
|
+
/** report-only for self-maintaining rules and checks (SHIPKIT_FROZEN
|
|
31
|
+
* travels to child checks). The release path passes it: a rule that
|
|
32
|
+
* rewrites package.json between the gate and `npm publish` packs a tree
|
|
33
|
+
* that exists in no commit. */
|
|
34
|
+
frozen?: boolean
|
|
35
|
+
/** treat a NON-blocking check failure as fatal too. `release.requireHealthy`
|
|
36
|
+
* is the only caller: a repo that wants a missing cred to stop a release
|
|
37
|
+
* asks for it, and every other run keeps the declared severity. */
|
|
38
|
+
failOnAdvisory?: boolean
|
|
39
|
+
/** run the tasks only. The TUI owns this: it renders the checks in its own
|
|
40
|
+
* panel, and running the suite twice per refresh proves nothing twice. */
|
|
41
|
+
skipChecks?: boolean
|
|
20
42
|
/** line sink — commands pass their context stream; defaults to console */
|
|
21
43
|
print?: (line: string) => void
|
|
22
44
|
/** per-task completion tap — the TUI tasks panel streams results in */
|
|
23
45
|
onTaskFinish?: (r: TaskResult) => void
|
|
46
|
+
/** the already-resolved config. A caller that holds one MUST pass it: two
|
|
47
|
+
* loads of repo.config.ts can disagree, and the release path classifies
|
|
48
|
+
* rule-vs-probe severity from the config it holds. */
|
|
49
|
+
config?: RepoConfig
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** what STATUS.md may claim about this run. `--with` ADDS a task, so it stays
|
|
53
|
+
* the full gate; `--only`, `--skip` and skipChecks each remove coverage, and
|
|
54
|
+
* a narrowed run that stamps the full gate's verdict is a green nobody
|
|
55
|
+
* measured. The label names the invocation either way. */
|
|
56
|
+
function ciScope(o: CiOpts): { command: string; fullGate: boolean } {
|
|
57
|
+
const narrowing = [
|
|
58
|
+
...(o.onlyIds ?? []).map((i) => `--only ${i}`),
|
|
59
|
+
...(o.skipGroups ?? []).map((g) => `--skip ${g}`),
|
|
60
|
+
...(o.skipChecks === true ? ['(tasks only)'] : []),
|
|
61
|
+
]
|
|
62
|
+
const parts = [...narrowing, ...(o.withIds ?? []).map((i) => `--with ${i}`)]
|
|
63
|
+
return { command: parts.length === 0 ? 'shipkit ci' : `shipkit ci ${parts.join(' ')}`, fullGate: narrowing.length === 0 }
|
|
24
64
|
}
|
|
25
65
|
|
|
26
66
|
export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
27
67
|
const print = o.print ?? ((l: string) => console.log(l))
|
|
28
68
|
const collected: string[] = []
|
|
29
69
|
const sink = (l: string): void => { collected.push(l); print(l) }
|
|
30
|
-
const c = await loadRepoConfig(root)
|
|
70
|
+
const c = o.config ?? (await loadRepoConfig(root))
|
|
71
|
+
// mechanical failures are not information, so they never cost a round trip
|
|
72
|
+
let fixFailed = false
|
|
73
|
+
if (o.fix !== false)
|
|
74
|
+
fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, print: sink })).failed
|
|
75
|
+
else sink('skip: fix pass (--no-fix)')
|
|
31
76
|
const { tasks, disabled, unfilled } = allTasks(c, root)
|
|
32
77
|
for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
|
|
33
78
|
// a floor slot nothing could fill is VISIBLE — a thin gate must never
|
|
@@ -44,12 +89,33 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
44
89
|
print: sink,
|
|
45
90
|
onTaskFinish: o.onTaskFinish,
|
|
46
91
|
})
|
|
47
|
-
|
|
92
|
+
// the checks, in the SAME command. there is no second surface: a check
|
|
93
|
+
// declares whether it blocks, and that is the only question about it.
|
|
94
|
+
const checks = o.skipChecks === true
|
|
95
|
+
? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
|
|
96
|
+
: await collectChecks(root, { status: false, config: c, frozen: o.frozen })
|
|
97
|
+
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
98
|
+
for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
|
|
99
|
+
for (const u of checks.undeclared) {
|
|
100
|
+
sink(`FAIL: ${u} does not declare whether it blocks`)
|
|
101
|
+
sink(` fix: add \`export const blocking = true\` (about this repo) or \`false\` (about the world) to that file`)
|
|
102
|
+
}
|
|
103
|
+
let advisoryFatal = false
|
|
104
|
+
if (checks.advisoryFailures.length > 0) {
|
|
105
|
+
if (o.failOnAdvisory === true) {
|
|
106
|
+
advisoryFatal = true
|
|
107
|
+
sink(`FAIL: ${checks.advisoryFailures.length} non-blocking check(s) failing and release.requireHealthy is on: ${checks.advisoryFailures.join(', ')}`)
|
|
108
|
+
sink(' fix: shipkit check')
|
|
109
|
+
} else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
|
|
110
|
+
}
|
|
111
|
+
const code = summary.ok && !fixFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
|
|
48
112
|
// motherhome row (docsBase wiki on this disk) — never affects the gate
|
|
49
113
|
const mh = reportToMotherhome(root, c)
|
|
50
114
|
if (mh.note != null) print(mh.note)
|
|
51
115
|
if (o.status !== false) {
|
|
52
|
-
|
|
116
|
+
// scope is named in the file: ci proves the gate, not repo health, and
|
|
117
|
+
// a bare GREEN here would claim clear-to-ship on a repo with red checks
|
|
118
|
+
const st = writeStatus(root, { name: c.name, ...ciScope(o), lines: collected, exitCode: code })
|
|
53
119
|
if (!st.written) print(`WARN ${st.warn}`)
|
|
54
120
|
}
|
|
55
121
|
if (o.json === true)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// what `shipkit ci` repairs before it gates.
|
|
2
|
+
//
|
|
3
|
+
// mechanical slots only (formatter, import canonicaliser): they rewrite the
|
|
4
|
+
// files the agent is already editing and their output is total. structural
|
|
5
|
+
// SK rule fixers rewrite repo config, so they stay behind --fix-rules.
|
|
6
|
+
//
|
|
7
|
+
// under CI the same fixers run in verify mode instead: a gate that silently
|
|
8
|
+
// rewrites the tree it checks can turn red green with no commit. one
|
|
9
|
+
// mechanism, two modes, rather than a formatter plus a format:check that
|
|
10
|
+
// drift apart.
|
|
11
|
+
import { existsSync, readFileSync } from 'fs'
|
|
12
|
+
import { join } from 'path'
|
|
13
|
+
import { fileURLToPath } from 'url'
|
|
14
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
15
|
+
import { runFix } from '../rules/run-fix.ts'
|
|
16
|
+
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
17
|
+
|
|
18
|
+
const FIXER_TIMEOUT_MS = 120_000
|
|
19
|
+
|
|
20
|
+
export type FixerSlot = {
|
|
21
|
+
id: string
|
|
22
|
+
cmd: string[]
|
|
23
|
+
source: 'package.json script' | 'built-in (rvlib-ts-tools)'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type FixerResolution = {
|
|
27
|
+
slots: FixerSlot[]
|
|
28
|
+
/** slots nothing could fill, printed, never silent */
|
|
29
|
+
unfilled: { id: string; reason: string }[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// one fixer per mechanical concern. `fmt` accepts the two names the house
|
|
33
|
+
// vocabulary allows so a repo that calls it `format` is not silently unfixed.
|
|
34
|
+
const FMT_SCRIPTS = ['fmt', 'format'] as const
|
|
35
|
+
|
|
36
|
+
export function resolveFixers(root: string): FixerResolution {
|
|
37
|
+
const pkgPath = join(root, 'package.json')
|
|
38
|
+
let scripts: Record<string, string> = {}
|
|
39
|
+
if (existsSync(pkgPath)) {
|
|
40
|
+
try { scripts = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {} }
|
|
41
|
+
catch (e) { throw new Error(`package.json unparseable — fixers cannot register: ${String(e).slice(0, 120)}`) }
|
|
42
|
+
}
|
|
43
|
+
const slots: FixerSlot[] = []
|
|
44
|
+
const unfilled: { id: string; reason: string }[] = []
|
|
45
|
+
|
|
46
|
+
const fmtName = FMT_SCRIPTS.find((n) => scripts[n] != null)
|
|
47
|
+
if (fmtName != null) slots.push({ id: 'fmt', cmd: ['bun', 'run', fmtName], source: 'package.json script' })
|
|
48
|
+
else unfilled.push({ id: 'fmt', reason: `no 'fmt' or 'format' script — formatting stays a manual step` })
|
|
49
|
+
|
|
50
|
+
if (scripts['imports:fix'] != null) slots.push({ id: 'imports:fix', cmd: ['bun', 'run', 'imports:fix'], source: 'package.json script' })
|
|
51
|
+
else {
|
|
52
|
+
let cli: string | null = null
|
|
53
|
+
try { cli = fileURLToPath(import.meta.resolve('rvlib-ts-tools/src/cli.ts')) } catch { cli = null }
|
|
54
|
+
if (cli == null) unfilled.push({ id: 'imports:fix', reason: `no 'imports:fix' script and rvlib-ts-tools is unresolvable` })
|
|
55
|
+
else slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (rvlib-ts-tools)' })
|
|
56
|
+
}
|
|
57
|
+
return { slots, unfilled }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** worktree fingerprint: every dirty path mapped to `<status>:<content hash>`,
|
|
61
|
+
* or null outside a git repo. The fix pass diffs this before and after so the
|
|
62
|
+
* report names real files instead of asserting "fixed" and hoping.
|
|
63
|
+
*
|
|
64
|
+
* The hash is load-bearing, not decoration. Status alone is blind to the most
|
|
65
|
+
* common case there is: a file the agent already edited (` M`) that a fixer
|
|
66
|
+
* then reformats stays ` M`, so a status-only compare reports "nothing to
|
|
67
|
+
* rewrite" while the tree changed under it, and in verify mode that is a
|
|
68
|
+
* silent green on unformatted code. Hashing is confined to the dirty set,
|
|
69
|
+
* which is small by construction. */
|
|
70
|
+
function treeState(root: string): Map<string, string> | null {
|
|
71
|
+
const p = Bun.spawnSync(['git', 'status', '--porcelain'], { cwd: root })
|
|
72
|
+
if (p.exitCode !== 0) return null
|
|
73
|
+
const m = new Map<string, string>()
|
|
74
|
+
for (const l of p.stdout.toString().split('\n')) {
|
|
75
|
+
if (l.trim() === '') continue
|
|
76
|
+
const status = l.slice(0, 2)
|
|
77
|
+
// rename entries read `R old -> new`; the new path is what exists
|
|
78
|
+
const path = l.slice(3).trim().split(' -> ').at(-1) ?? ''
|
|
79
|
+
if (path === '') continue
|
|
80
|
+
const abs = join(root, path.replace(/^"|"$/g, ''))
|
|
81
|
+
let hash = 'absent'
|
|
82
|
+
try { if (existsSync(abs)) hash = String(Bun.hash(readFileSync(abs))) } catch { hash = 'unreadable' }
|
|
83
|
+
m.set(path, `${status}:${hash}`)
|
|
84
|
+
}
|
|
85
|
+
return m
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type FixPassOpts = {
|
|
89
|
+
/** also apply the structural SK rule fixers (default false) */
|
|
90
|
+
rules?: boolean
|
|
91
|
+
print: (line: string) => void
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type FixPassResult = {
|
|
95
|
+
ran: boolean
|
|
96
|
+
/** paths whose git status changed across the pass */
|
|
97
|
+
touched: string[]
|
|
98
|
+
/** verify mode only: a fixer had to rewrite something, so the commit is not clean */
|
|
99
|
+
failed: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpts): Promise<FixPassResult> {
|
|
103
|
+
// verify mode: same fixers, but rewriting anything means the COMMIT was
|
|
104
|
+
// not clean, and the gate says so instead of quietly repairing it
|
|
105
|
+
const verify = process.env.CI != null && process.env.CI !== ''
|
|
106
|
+
const { slots, unfilled } = resolveFixers(root)
|
|
107
|
+
for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
|
|
108
|
+
const before = treeState(root)
|
|
109
|
+
|
|
110
|
+
for (const s of slots) {
|
|
111
|
+
const r = await spawnWithTimeout(s.cmd, { cwd: root, timeoutMs: FIXER_TIMEOUT_MS })
|
|
112
|
+
// a broken fixer must never be mistaken for a clean one: the gate
|
|
113
|
+
// still runs, but the failure is loud and names the command
|
|
114
|
+
if (r.code !== 0)
|
|
115
|
+
o.print(`WARN fix ${s.id} failed (exit ${r.code}) — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}; the gate below still ran`)
|
|
116
|
+
}
|
|
117
|
+
// structural repo-config repair is never implicit, and never in verify mode
|
|
118
|
+
if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print })
|
|
119
|
+
|
|
120
|
+
const after = treeState(root)
|
|
121
|
+
const touched: string[] = []
|
|
122
|
+
if (before != null && after != null) {
|
|
123
|
+
for (const [path, st] of after) if (before.get(path) !== st) touched.push(path)
|
|
124
|
+
// a file the fixer restored to its committed state disappears from
|
|
125
|
+
// porcelain entirely, that is still a change worth naming
|
|
126
|
+
for (const path of before.keys()) if (!after.has(path)) touched.push(path)
|
|
127
|
+
}
|
|
128
|
+
const list = `${touched.slice(0, 8).join(' ')}${touched.length > 8 ? ` … +${touched.length - 8}` : ''}`
|
|
129
|
+
if (before == null) {
|
|
130
|
+
o.print(`ok: fix pass ran, ${slots.length} fixer(s) (not a git repo — rewritten files cannot be listed)`)
|
|
131
|
+
return { ran: true, touched, failed: false }
|
|
132
|
+
}
|
|
133
|
+
if (touched.length === 0) {
|
|
134
|
+
o.print(`ok: ${verify ? 'fixers verified' : 'fix pass'} clean (${slots.length} fixer(s), nothing to rewrite)`)
|
|
135
|
+
return { ran: true, touched, failed: false }
|
|
136
|
+
}
|
|
137
|
+
if (verify) {
|
|
138
|
+
o.print(`FAIL: ${touched.length} file(s) are not fixer-clean: ${list}`)
|
|
139
|
+
o.print(` fix: run \`shipkit ci\` locally (the fix pass rewrites them) and commit the result`)
|
|
140
|
+
return { ran: true, touched, failed: true }
|
|
141
|
+
}
|
|
142
|
+
o.print(`ok: fix pass rewrote ${touched.length} file(s): ${list}`)
|
|
143
|
+
return { ran: true, touched, failed: false }
|
|
144
|
+
}
|
package/src/core/write-status.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// THE STATUS.md writer — every repo gets one
|
|
2
|
-
//
|
|
3
|
-
// shape rv-doctor pioneered. Atomic + lock-guarded via core/locked-write:
|
|
1
|
+
// THE STATUS.md writer — every repo gets one: timestamp header + verdict +
|
|
2
|
+
// fenced compact render. Atomic + lock-guarded via core/locked-write:
|
|
4
3
|
// concurrent runners never interleave — the loser skips with a WARN
|
|
5
4
|
// outcome, never corrupts the file.
|
|
6
5
|
|
|
@@ -12,6 +11,10 @@ export type StatusInput = {
|
|
|
12
11
|
name: string
|
|
13
12
|
/** the command that produced this render, e.g. 'shipkit ci' */
|
|
14
13
|
command: string
|
|
14
|
+
/** the run covered the WHOLE gate, so a green may say "clear to ship".
|
|
15
|
+
* Absent means it did not: the claim is opt-in, because a caller that
|
|
16
|
+
* forgets it costs a nag and a caller that over-claims costs a release. */
|
|
17
|
+
fullGate?: boolean
|
|
15
18
|
/** the run's printed lines (ANSI stripped here) */
|
|
16
19
|
lines: string[]
|
|
17
20
|
exitCode: number
|
|
@@ -27,11 +30,17 @@ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
|
|
|
27
30
|
const clean = s.lines.map(stripAnsi)
|
|
28
31
|
const fails = clean.filter((l) => l.startsWith('FAIL')).length
|
|
29
32
|
const warns = clean.filter((l) => l.startsWith('WARN')).length
|
|
33
|
+
// only the full gate may say "clear to ship": a checks-only run proves
|
|
34
|
+
// the repo is sound, never that the build passes, and a narrowed gate
|
|
35
|
+
// proves only the slice it ran. the scope rides on the WARN verdict
|
|
36
|
+
// too, or a narrowed green hides behind a nag nobody reads twice
|
|
37
|
+
const full = s.fullGate === true
|
|
38
|
+
const scope = full ? 'clear to ship' : `${s.command} ran, not the whole gate`
|
|
30
39
|
const verdict = s.exitCode !== 0 || fails > 0
|
|
31
40
|
? `RED — ${Math.max(fails, 1)} FAIL line${fails === 1 ? '' : 's'}. Fixes are printed under each FAIL.`
|
|
32
41
|
: warns > 0
|
|
33
|
-
? `GREEN with nags — ${warns} WARN. Proceed.`
|
|
34
|
-
:
|
|
42
|
+
? `GREEN with nags — ${warns} WARN. ${full ? 'Proceed.' : `${scope}.`}`
|
|
43
|
+
: `GREEN — ${scope}.`
|
|
35
44
|
// local time, not UTC — an evening run must not stamp yesterday's date
|
|
36
45
|
const now = new Date()
|
|
37
46
|
const p = (n: number): string => String(n).padStart(2, '0')
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// the commit-message predicate, shared by the commit-msg hook (blocks at
|
|
2
|
+
// write time) and SK009 (audits history). one implementation, or the two
|
|
3
|
+
// drift and the drift only shows when a repo goes public.
|
|
4
|
+
//
|
|
5
|
+
// mechanical text rules only, so this stays publishable.
|
|
6
|
+
|
|
7
|
+
export type OffenseKind = 'dash' | 'attribution'
|
|
8
|
+
export type Offense = { line: number; text: string; kind: OffenseKind; why: string }
|
|
9
|
+
|
|
10
|
+
/** trailers and footers that name an assistant, plus the robot emoji. */
|
|
11
|
+
export const AI_ATTRIBUTION =
|
|
12
|
+
/(?:co-authored-by|assisted-by|signed-off-by):[^\n]*\b(?:claude|anthropic|copilot|chatgpt|openai|cursor|aider|devin|gemini)\b|generated with[^\n]*\b(?:claude|copilot|chatgpt|gemini)\b|🤖/i
|
|
13
|
+
|
|
14
|
+
/** everything below git's scissors line is the verbose-mode diff, which git
|
|
15
|
+
* strips before committing, diff content is not ours to police. */
|
|
16
|
+
export function stripNonMessage(message: string): string {
|
|
17
|
+
const scissors = message.search(/^# -+ >8 -+$/m)
|
|
18
|
+
return scissors === -1 ? message : message.slice(0, scissors)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** offenses in ONE commit message (or one commit body from `git log`).
|
|
22
|
+
* `#` comment lines never land in the message and are ignored; a `Revert "…"`
|
|
23
|
+
* title quotes the reverted subject verbatim and is exempt; a line-start
|
|
24
|
+
* `- ` bullet is a list marker, not punctuation. */
|
|
25
|
+
export function commitMessageOffenses(message: string): Offense[] {
|
|
26
|
+
const offenses: Offense[] = []
|
|
27
|
+
stripNonMessage(message).split('\n').forEach((raw, i) => {
|
|
28
|
+
if (raw.startsWith('#')) return
|
|
29
|
+
if (i === 0 && /^Revert "/.test(raw)) return
|
|
30
|
+
if (/[—–]/.test(raw))
|
|
31
|
+
offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'em/en dash: use a comma, a period, parentheses, or a new sentence' })
|
|
32
|
+
const body = raw.replace(/^\s*[-*] /, '')
|
|
33
|
+
if (body.includes(' - '))
|
|
34
|
+
offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
|
|
35
|
+
if (AI_ATTRIBUTION.test(raw))
|
|
36
|
+
offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, "Generated with" footer, or robot emoji' })
|
|
37
|
+
})
|
|
38
|
+
return offenses
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const hasDashPunctuation = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'dash')
|
|
42
|
+
export const hasAiAttribution = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'attribution')
|
|
Binary file
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// the git-hook gates, owned by the toolkit rather than by per-repo shell.
|
|
2
|
+
//
|
|
3
|
+
// hand-rolled hooks are invisible to `shipkit check`: a repo reports green
|
|
4
|
+
// while its next commit is already doomed, and nothing can enumerate what
|
|
5
|
+
// the hooks enforce. `shipkit hooks list` answers that without reading shell.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { commitMessageOffenses } from './commit-message.ts'
|
|
9
|
+
import { runLeakGuard } from './leak-guard.ts'
|
|
10
|
+
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
11
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
12
|
+
|
|
13
|
+
export type HooksConfig = {
|
|
14
|
+
/** repo-specific commands run in the pre-commit gate, after the built-ins.
|
|
15
|
+
* Non-zero exit blocks the commit; stdout/stderr are shown. */
|
|
16
|
+
preCommit?: { id: string; cmd: string[] }[]
|
|
17
|
+
/** repo-specific commands run on the commit message; the message file path
|
|
18
|
+
* is appended as the last argument. */
|
|
19
|
+
commitMsg?: { id: string; cmd: string[] }[]
|
|
20
|
+
/** keyword file for the leak guard (default '.rv-private/banned-keywords.txt') */
|
|
21
|
+
keywordFile?: string
|
|
22
|
+
/** disable a built-in gate, visible in `hooks list`, never silent */
|
|
23
|
+
disable?: ('leak-guard' | 'commit-message')[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const HOOK_DIR = 'hooks'
|
|
27
|
+
const HOOKS = ['pre-commit', 'commit-msg'] as const
|
|
28
|
+
|
|
29
|
+
/** wire `core.hooksPath` and write the two hook bodies. Idempotent. */
|
|
30
|
+
export function installHooks(root: string, binInvocation: string): { written: string[]; note: string } {
|
|
31
|
+
const dir = join(root, HOOK_DIR)
|
|
32
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
33
|
+
const written: string[] = []
|
|
34
|
+
for (const h of HOOKS) {
|
|
35
|
+
const body = h === 'commit-msg'
|
|
36
|
+
? `#!/bin/sh\nexec ${binInvocation} hooks commit-msg "$1"\n`
|
|
37
|
+
: `#!/bin/sh\nexec ${binInvocation} hooks pre-commit\n`
|
|
38
|
+
const path = join(dir, h)
|
|
39
|
+
const prev = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
40
|
+
if (prev !== body) { writeFileSync(path, body, { mode: 0o755 }); written.push(`${HOOK_DIR}/${h}`) }
|
|
41
|
+
}
|
|
42
|
+
const cfg = Bun.spawnSync(['git', 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
|
|
43
|
+
return {
|
|
44
|
+
written,
|
|
45
|
+
note: cfg.exitCode === 0 ? `core.hooksPath=${HOOK_DIR}` : `WARN could not set core.hooksPath (exit ${cfg.exitCode}) — hooks will not fire`,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type GateOutcome = { id: string; ok: boolean; lines: string[] }
|
|
50
|
+
|
|
51
|
+
/** the pre-commit gate: built-in leak guard, then the repo's own commands. */
|
|
52
|
+
export async function runPreCommit(root: string, config: RepoConfig): Promise<GateOutcome[]> {
|
|
53
|
+
const h = config.hooks ?? {}
|
|
54
|
+
const out: GateOutcome[] = []
|
|
55
|
+
if (!(h.disable ?? []).includes('leak-guard')) {
|
|
56
|
+
const r = runLeakGuard(root, { keywordFile: h.keywordFile })
|
|
57
|
+
if (r.status === 'skipped') out.push({ id: 'leak-guard', ok: true, lines: [`skip: leak-guard (${r.reason})`] })
|
|
58
|
+
else if (r.status === 'clean') out.push({ id: 'leak-guard', ok: true, lines: [`ok: leak-guard clean (${r.patterns} pattern(s))`] })
|
|
59
|
+
else out.push({
|
|
60
|
+
id: 'leak-guard',
|
|
61
|
+
ok: false,
|
|
62
|
+
lines: [
|
|
63
|
+
`FAIL: leak-guard matched ${r.hits.length} banned pattern(s) — this commit would leak private content`,
|
|
64
|
+
...r.hits.map((x) => ` ${x.where}: ${x.pattern}`),
|
|
65
|
+
` fix: remove the content, or edit the keyword file if the pattern is wrong`,
|
|
66
|
+
],
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
for (const c of h.preCommit ?? []) {
|
|
70
|
+
const r = await spawnWithTimeout(c.cmd, { cwd: root, timeoutMs: 120_000 })
|
|
71
|
+
out.push({
|
|
72
|
+
id: c.id,
|
|
73
|
+
ok: r.code === 0,
|
|
74
|
+
lines: r.code === 0 ? [`ok: ${c.id}`] : [`FAIL: ${c.id} (exit ${r.code})`, ...r.out.trim().split('\n').slice(-30).map((l) => ` ${l}`)],
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** the commit-msg gate: the shared message predicate, then the repo's own. */
|
|
81
|
+
export async function runCommitMsg(root: string, config: RepoConfig, messageFile: string): Promise<GateOutcome[]> {
|
|
82
|
+
const h = config.hooks ?? {}
|
|
83
|
+
const out: GateOutcome[] = []
|
|
84
|
+
const message = readFileSync(messageFile, 'utf8')
|
|
85
|
+
if (!(h.disable ?? []).includes('commit-message')) {
|
|
86
|
+
const offenses = commitMessageOffenses(message)
|
|
87
|
+
out.push(offenses.length === 0
|
|
88
|
+
? { id: 'commit-message', ok: true, lines: ['ok: commit message clean (no dash punctuation, no AI attribution)'] }
|
|
89
|
+
: {
|
|
90
|
+
id: 'commit-message',
|
|
91
|
+
ok: false,
|
|
92
|
+
lines: [
|
|
93
|
+
`FAIL: commit message has ${offenses.length} offense(s)`,
|
|
94
|
+
...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`),
|
|
95
|
+
],
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
// the leak guard also reads the MESSAGE, a private name pasted into a
|
|
99
|
+
// commit body is as public as one pasted into a file
|
|
100
|
+
if (!(h.disable ?? []).includes('leak-guard')) {
|
|
101
|
+
const r = runLeakGuard(root, { keywordFile: h.keywordFile, staged: false, text: [{ label: 'commit message', content: message }] })
|
|
102
|
+
if (r.status === 'hit')
|
|
103
|
+
out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map((x) => ` ${x.where}: ${x.pattern}`)] })
|
|
104
|
+
}
|
|
105
|
+
for (const c of h.commitMsg ?? []) {
|
|
106
|
+
const r = await spawnWithTimeout([...c.cmd, messageFile], { cwd: root, timeoutMs: 60_000 })
|
|
107
|
+
out.push({
|
|
108
|
+
id: c.id,
|
|
109
|
+
ok: r.code === 0,
|
|
110
|
+
lines: r.code === 0 ? [`ok: ${c.id}`] : [`FAIL: ${c.id} (exit ${r.code})`, ...r.out.trim().split('\n').slice(-30).map((l) => ` ${l}`)],
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** what blocks a commit here, the enumeration hand-rolled hooks never had. */
|
|
117
|
+
export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
118
|
+
const h = config.hooks ?? {}
|
|
119
|
+
const off = new Set(h.disable ?? [])
|
|
120
|
+
const kw = h.keywordFile ?? '.rv-private/banned-keywords.txt'
|
|
121
|
+
// a gate listed as active but unable to fire is worse than no listing:
|
|
122
|
+
// this surface exists so nobody has to guess
|
|
123
|
+
const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) ? '' : ` (INACTIVE, ${kw} absent)`
|
|
124
|
+
return [
|
|
125
|
+
`pre-commit leak-guard${leakState} — staged content + paths vs ${kw}`,
|
|
126
|
+
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
|
127
|
+
`commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution`,
|
|
128
|
+
`commit-msg leak-guard${leakState} — the message text`,
|
|
129
|
+
...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
|
|
130
|
+
]
|
|
131
|
+
}
|
package/src/init-repo.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// deps — every gap is printed, never silent). Plain module behind cmd_Init.
|
|
4
4
|
// Refuses to overwrite an existing config; script merge never clobbers
|
|
5
5
|
// existing keys. `bin` switches script bodies from the npm bin to an
|
|
6
|
-
// in-repo-source CLI path
|
|
7
|
-
//
|
|
6
|
+
// in-repo-source CLI path for repos that consume shipkit by path instead of
|
|
7
|
+
// from node_modules. Only bodies shipkit cannot express as a verb are
|
|
8
|
+
// scaffolded: an alias script would be a second name for one capability.
|
|
8
9
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
9
10
|
import { join } from 'path'
|
|
10
11
|
import { canonicalBodies } from './core/canonical-scripts.ts'
|
|
12
|
+
import { hasTestFiles } from './rules/SK001-package-json-shape.ts'
|
|
11
13
|
import { reportToMotherhome } from './core/motherhome.ts'
|
|
12
14
|
import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
|
|
13
15
|
import { gitignorePolicy } from './rules/SK004-gitignore-policy.ts'
|
|
@@ -19,6 +21,9 @@ export default defineRepo({
|
|
|
19
21
|
venues: [], // e.g. ['itch', 'steam'] — selects which .rv-<venue>/ contracts are REQUIRED
|
|
20
22
|
social: [], // e.g. ['x', 'discord'] — .rv-social/<channel>/ post folders (+ .rv-discord/)
|
|
21
23
|
tasks: [], // repo commit-gate tasks; deps double as race guards
|
|
24
|
+
// the mandated reviewer agent type (rule SK008) — uncomment with YOUR
|
|
25
|
+
// standing brief, then \`shipkit check --fix SK008\` writes the agent file:
|
|
26
|
+
// reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
|
|
22
27
|
// health checks: no config — drop a <name>.check.ts file anywhere,
|
|
23
28
|
// \`shipkit check\` auto-discovers and runs it (wire output, exit code)
|
|
24
29
|
})
|
|
@@ -56,7 +61,10 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
56
61
|
// canonical script bodies — ONE source (core/canonical-scripts.ts),
|
|
57
62
|
// shared with rule SK001's autofix; vocabulary owned by
|
|
58
63
|
// meta/rv-conventions/package-json.md
|
|
64
|
+
// `test` is scaffolded only once the repo has tests: `bun test` exits
|
|
65
|
+
// non-zero with none, and a scaffold that reds the gate is a bad scaffold
|
|
59
66
|
for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
|
|
67
|
+
if (k === 'test' && !hasTestFiles(root)) continue
|
|
60
68
|
if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
|
|
61
69
|
else if (pkg.scripts[k] !== v) console.log(`skip: package.json script '${k}' exists — left untouched`)
|
|
62
70
|
}
|
|
@@ -73,8 +81,8 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
73
81
|
const floor = resolveFloor(defineRepo({ name }), root)
|
|
74
82
|
for (const t of floor.tasks) console.log(`ok: floor ${t.id} ← ${floor.sources[t.id] ?? 'unknown'}`)
|
|
75
83
|
for (const u of floor.unfilled) console.log(`skip: ${u.id} (${u.reason})`)
|
|
76
|
-
//
|
|
77
|
-
// actually installed (
|
|
84
|
+
// every verb comes from the `shipkit` bin — dead on arrival unless the
|
|
85
|
+
// package is actually installed (bunx runs without adding the dep)
|
|
78
86
|
if (opts.bin == null) {
|
|
79
87
|
try {
|
|
80
88
|
Bun.resolveSync('rvlib-shipkit', root)
|
|
@@ -83,7 +91,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
83
91
|
console.log(' fix: bun add -d rvlib-shipkit')
|
|
84
92
|
}
|
|
85
93
|
}
|
|
86
|
-
console.log('next: fill venues/social in repo.config.ts, then `
|
|
94
|
+
console.log('next: fill venues/social in repo.config.ts, then `shipkit ci`')
|
|
87
95
|
console.log('next: identity+assets — mint .rv-identity/brand.ts + .rv-assets/kit.ts, `shipkit assets identity` (the identity contract expects public/ favicon+og+logo)')
|
|
88
96
|
return 0
|
|
89
97
|
}
|
|
@@ -35,8 +35,8 @@ const siblingConfigDirs = (home: string): string[] => {
|
|
|
35
35
|
* (`:` or `,` separated) PLUS ~/.claude PLUS any sibling ~/.claude-<name>
|
|
36
36
|
* holding a projects/ dir. Additive on purpose — a second local account keeps
|
|
37
37
|
* its own transcripts, and the env var only names the account running RIGHT
|
|
38
|
-
* NOW, so honoring it alone hides every session of the other one
|
|
39
|
-
*
|
|
38
|
+
* NOW, so honoring it alone hides every session of the other one — a corpus
|
|
39
|
+
* mined from a single root is silently partial. */
|
|
40
40
|
export function discoverConfigRoots(home: string = homedir()): ConfigRoot[] {
|
|
41
41
|
const fromEnv = (process.env.CLAUDE_CONFIG_DIR ?? '')
|
|
42
42
|
.split(/[:,]/)
|
|
@@ -137,8 +137,7 @@ export function promptTextOf(line: string): { text: string; entry: RawEntry } |
|
|
|
137
137
|
/** A prompt he typed while the agent was still working: the harness parks it as
|
|
138
138
|
* `{type:'queue-operation', operation:'enqueue', content}` and, if the run ends
|
|
139
139
|
* first, it may NEVER become a user message. Skipping these loses exactly the
|
|
140
|
-
* mid-flight corrections and standing conventions a retrospective is for
|
|
141
|
-
* (measured 2026-07-26: 72 such texts invisible in this repo alone). */
|
|
140
|
+
* mid-flight corrections and standing conventions a retrospective is for */
|
|
142
141
|
export function queuedTextOf(line: string): { text: string; entry: RawEntry } | null {
|
|
143
142
|
let parsed: unknown
|
|
144
143
|
try {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// reading a versioned CHANGELOG, in one place, because two readers of the
|
|
2
|
+
// same file always drift and the release then ships notes nobody wrote.
|
|
3
|
+
// House convention: newest first, one `## <version>` per released version,
|
|
4
|
+
// `## Unreleased` on top while work accumulates.
|
|
5
|
+
|
|
6
|
+
/** matches `## 1.2.3`, `## [1.2.3]`, and a dated heading in any of the dash
|
|
7
|
+
* shapes real changelogs use (`## 1.2.3 — 2026-07-28`). The dash here is
|
|
8
|
+
* DATA in someone's changelog, not prose this repo controls. */
|
|
9
|
+
export const changelogHeading = (version: string): RegExp =>
|
|
10
|
+
new RegExp(`^##\\s*\\[?${version.replace(/\./g, '\\.')}\\]?\\s*(?:[-–—:(].*)?$`)
|
|
11
|
+
|
|
12
|
+
/** the `## <version>` section of a versioned changelog, as release notes.
|
|
13
|
+
* Throws when the section is missing or empty: a release with no notes is a
|
|
14
|
+
* release nobody can read later. */
|
|
15
|
+
export function extractChangelogSection(p: { changelog: string; version: string }): string {
|
|
16
|
+
const lines = p.changelog.split('\n')
|
|
17
|
+
const head = changelogHeading(p.version)
|
|
18
|
+
const start = lines.findIndex((l) => head.test(l.trim()))
|
|
19
|
+
if (start === -1) throw new Error(`CHANGELOG has no "## ${p.version}" section — write the notes, then release`)
|
|
20
|
+
let end = lines.length
|
|
21
|
+
for (let i = start + 1; i < lines.length; i++)
|
|
22
|
+
if (lines[i]?.startsWith('## ')) { end = i; break }
|
|
23
|
+
const body = lines.slice(start + 1, end).join('\n').trim()
|
|
24
|
+
if (body === '') throw new Error(`CHANGELOG section "## ${p.version}" is empty — write the notes, then release`)
|
|
25
|
+
return body
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const hasVersionSection = (changelog: string, version: string): boolean =>
|
|
29
|
+
changelog.split('\n').some((l) => changelogHeading(version).test(l.trim()))
|
|
30
|
+
|
|
31
|
+
const UNRELEASED = /^##\s*\[?unreleased\]?\s*$/i
|
|
32
|
+
|
|
33
|
+
export const hasUnreleasedHeading = (changelog: string): boolean =>
|
|
34
|
+
changelog.split('\n').some((l) => UNRELEASED.test(l.trim()))
|
|
35
|
+
|
|
36
|
+
/** rename the Unreleased heading to the new version. Returns the new text, or
|
|
37
|
+
* an error when there is no such section or it carries no notes: a version
|
|
38
|
+
* with empty notes is a version nobody can read later. */
|
|
39
|
+
export function promoteUnreleased(changelog: string, version: string): { text: string } | { error: string } {
|
|
40
|
+
const lines = changelog.split('\n')
|
|
41
|
+
const at = lines.findIndex((l) => UNRELEASED.test(l.trim()))
|
|
42
|
+
if (at === -1) return { error: `CHANGELOG has no "## Unreleased" section — write the notes for this version under one, then bump` }
|
|
43
|
+
let end = lines.length
|
|
44
|
+
for (let i = at + 1; i < lines.length; i++) if (lines[i]?.startsWith('## ')) { end = i; break }
|
|
45
|
+
if (lines.slice(at + 1, end).join('\n').trim() === '')
|
|
46
|
+
return { error: `the "## Unreleased" section is empty — write what changed, then bump` }
|
|
47
|
+
lines[at] = `## ${version}`
|
|
48
|
+
return { text: lines.join('\n') }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** does this unit have notes waiting? `--all` selects on exactly this: having
|
|
52
|
+
* something to say IS the decision to ship. */
|
|
53
|
+
export const hasUnreleasedNotes = (changelog: string): boolean => !('error' in promoteUnreleased(changelog, '0.0.0'))
|