rvlib-shipkit 0.30.0 → 0.32.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 (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +3 -0
  3. package/package.json +1 -1
  4. package/src/cli/cmd_Bump.ts +2 -0
  5. package/src/cli/cmd_Ci.ts +3 -1
  6. package/src/cli/cmd_Pb.ts +27 -0
  7. package/src/cli/cmd_Release.ts +1 -1
  8. package/src/cli/cmd_Wiki.ts +2 -2
  9. package/src/cli.ts +49 -39
  10. package/src/collections/graphs.ts +17 -19
  11. package/src/collections/providers.ts +7 -2
  12. package/src/contracts/contracts-once.ts +43 -0
  13. package/src/core/canonical-scripts.ts +33 -0
  14. package/src/core/check-ctx.ts +13 -0
  15. package/src/core/check-suite.ts +121 -53
  16. package/src/core/floor.ts +82 -0
  17. package/src/core/motherhome.ts +14 -1
  18. package/src/core/run-check.ts +34 -0
  19. package/src/core/run-ci.ts +63 -2
  20. package/src/core/run-fixers.ts +6 -1
  21. package/src/ledger/toolchain-identity.ts +134 -0
  22. package/src/pb/collection-scaffold.ts +299 -0
  23. package/src/pb/pb-types.ts +233 -0
  24. package/src/pb/run-pb.ts +157 -0
  25. package/src/release/git-facts.ts +3 -2
  26. package/src/release/release-scope.ts +52 -0
  27. package/src/release/releases-share.ts +85 -0
  28. package/src/release/run-bump.ts +31 -8
  29. package/src/release/run-release.ts +8 -1
  30. package/src/release/run-releases.ts +20 -6
  31. package/src/repo-config.ts +22 -65
  32. package/src/rules/SK001-package-json-shape.ts +65 -2
  33. package/src/rules/SK002-contracts-fulfillness.ts +2 -2
  34. package/src/rules/SK004-gitignore-policy.ts +27 -3
  35. package/src/rules/SK012-config-barrel-import.ts +60 -0
  36. package/src/rules/rule-registry.ts +2 -0
  37. package/src/rules/untrack-generated.ts +117 -0
  38. package/src/scaffold/project-scaffold.ts +1 -1
  39. package/src/serve/hub-port.ts +17 -0
  40. package/src/serve/run-serve.ts +54 -20
  41. package/src/serve/serve-hub.ts +13 -15
  42. package/src/shipkit.ts +3 -2
  43. package/src/wiki/graph-mount.ts +21 -0
  44. package/src/wiki/run-wiki.ts +18 -8
@@ -0,0 +1,157 @@
1
+ // `shipkit pb collection <name>` — the fs half. Runs in the CONSUMING app's
2
+ // cwd, reads its generated pb types, writes src/collections/<name>/ and
3
+ // registers the folder in src/collections/index.ts.
4
+ //
5
+ // Writes are CREATE-ONLY for the folder: a path that exists is reported and
6
+ // left byte for byte as it is, so re-running after a schema change can never
7
+ // eat hand-written UI. What that costs is that a stale file is never
8
+ // refreshed, which is the trade a scaffold has to make. The registration
9
+ // index is the one ADDITIVE write: a folder nothing imports is dead code, and
10
+ // the failure mode is a runtime throw rather than a compile error.
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
12
+ import { dirname, join } from 'path'
13
+ import { scaffoldCollectionUI, type ScaffoldedFile } from './collection-scaffold.ts'
14
+ import { isSystemCollection, parsePbTypes, type PbCollection } from './pb-types.ts'
15
+
16
+ export type PbIo = { print: (line: string) => void; printErr: (line: string) => void }
17
+
18
+ /** where an app keeps pocketbase-typegen output, in the order we look */
19
+ export const TYPES_CANDIDATES = ['src/pb.types.ts', 'src/pb.types.tsx', 'src/pb.types.d.ts', 'pb.types.ts'] as const
20
+
21
+ export function findTypesFile(root: string): string | null {
22
+ for (const rel of TYPES_CANDIDATES) if (existsSync(join(root, rel))) return rel
23
+ return null
24
+ }
25
+
26
+ const TYPEGEN_HINT = 'npx pocketbase-typegen --url <instance-url> --email <admin> --password <pw> --out src/pb.types.ts'
27
+
28
+ const REGISTRY = 'src/collections/index.ts'
29
+ const REGISTRY_HEADER = [
30
+ '// ONE import wires every collection\'s UI. main.tsx imports this file and',
31
+ '// nothing else, so a collection that is not registered here is a missing',
32
+ '// import rather than a blank table at runtime.',
33
+ '//',
34
+ '// `shipkit pb collection <name>` creates a folder here from pb.types.ts.',
35
+ '',
36
+ ].join('\n')
37
+
38
+ /** names close enough to be the one that was meant */
39
+ function nearest(want: string, have: string[]): string[] {
40
+ const w = want.toLowerCase().replace(/[_-]/g, '')
41
+ return have.filter((n) => {
42
+ const h = n.toLowerCase().replace(/[_-]/g, '')
43
+ return h === w || h.startsWith(w) || w.startsWith(h)
44
+ })
45
+ }
46
+
47
+ export type PbCollectionOpts = { dryRun: boolean }
48
+
49
+ export function runPbCollection(root: string, name: string, io: PbIo, opts: PbCollectionOpts): number {
50
+ const typesRel = findTypesFile(root)
51
+ if (typesRel == null) {
52
+ io.printErr(`FAIL: no ${TYPES_CANDIDATES[0]} here — this command runs in the root of a PocketBase app`)
53
+ io.printErr(` fix: generate the types first: ${TYPEGEN_HINT}`)
54
+ return 1
55
+ }
56
+ let collections: PbCollection[]
57
+ try {
58
+ collections = parsePbTypes(readFileSync(join(root, typesRel), 'utf8'))
59
+ } catch (e) {
60
+ io.printErr(`FAIL: ${typesRel} could not be read: ${e instanceof Error ? e.message : String(e)}`)
61
+ return 1
62
+ }
63
+ if (collections.length === 0) {
64
+ io.printErr(`FAIL: ${typesRel} describes no collection — it does not look like pocketbase-typegen output`)
65
+ io.printErr(` fix: regenerate it: ${TYPEGEN_HINT}`)
66
+ return 1
67
+ }
68
+ const col = collections.find((c) => c.name === name)
69
+ if (col == null) {
70
+ // pocketbase's own `_`-prefixed collections are never an app surface, so
71
+ // offering them as choices sends the reader somewhere they cannot go
72
+ const names = collections.filter((c) => !isSystemCollection(c.name)).map((c) => c.name)
73
+ const close = nearest(name, names)
74
+ io.printErr(`FAIL: no collection '${name}' in ${typesRel}`)
75
+ if (close.length > 0) io.printErr(` fix: did you mean ${close.join(', ')} ?`)
76
+ io.printErr(` fix: known collections: ${names.join(', ')}`)
77
+ return 1
78
+ }
79
+ if (isSystemCollection(col.name)) {
80
+ io.printErr(`FAIL: '${col.name}' is one of pocketbase's internal collections, not an app surface`)
81
+ io.printErr(' fix: scaffold one of your own collections instead')
82
+ return 1
83
+ }
84
+ if (!col.readable) {
85
+ io.printErr(`FAIL: ${typesRel} names '${col.name}' but its ${col.prefix}Record block is missing or unreadable`)
86
+ io.printErr(` fix: regenerate the types: ${TYPEGEN_HINT}`)
87
+ return 1
88
+ }
89
+ // a field the parser could not read is a column the developer never sees,
90
+ // so it is named rather than dropped
91
+ for (const line of col.unparsed) io.print(`WARN ${typesRel}: unread field line in ${col.prefix}Record: ${line}`)
92
+ const unknown = col.fields.filter((f) => f.kind === 'unknown')
93
+ // every DECLARED field unrecognised is a PARSE failure wearing a schema's
94
+ // clothes: the folder it would write is entirely commented out and useless.
95
+ // The response fields we append ourselves cannot vouch for the parse
96
+ const declared = col.fields.filter((f) => !f.system)
97
+ if (declared.length > 0 && unknown.length === declared.length) {
98
+ io.printErr(`FAIL: not one field of ${col.prefix}Record parsed — ${typesRel} is not in a shape this reads`)
99
+ io.printErr(` fix: regenerate it unformatted: ${TYPEGEN_HINT}`)
100
+ return 1
101
+ }
102
+ for (const f of unknown) io.print(`WARN ${col.prefix}Record.${f.name}: unrecognised type '${f.type}', its column is commented out`)
103
+
104
+ return report(scaffoldCollectionUI(col), io, col, root, opts.dryRun)
105
+ }
106
+
107
+ /** the `import "./<name>"` line the registry needs, added without touching
108
+ * anything already in the file. Returns what happened. */
109
+ function registerInIndex(root: string, name: string, dry: boolean): 'added' | 'present' | 'created' {
110
+ const abs = join(root, REGISTRY)
111
+ const line = `import "./${name}"`
112
+ if (!existsSync(abs)) {
113
+ if (!dry) {
114
+ mkdirSync(dirname(abs), { recursive: true })
115
+ writeFileSync(abs, `${REGISTRY_HEADER}${line}\n`)
116
+ }
117
+ return 'created'
118
+ }
119
+ const cur = readFileSync(abs, 'utf8')
120
+ if (cur.includes(line)) return 'present'
121
+ if (!dry) writeFileSync(abs, `${cur.replace(/\n*$/, '')}\n${line}\n`)
122
+ return 'added'
123
+ }
124
+
125
+ function report(files: ScaffoldedFile[], io: PbIo, col: PbCollection, root: string, dry: boolean): number {
126
+ const written: string[] = []
127
+ const kept: string[] = []
128
+ for (const f of files) {
129
+ const abs = join(root, f.rel)
130
+ if (existsSync(abs)) {
131
+ kept.push(f.rel)
132
+ io.print(`skip: ${f.rel} (exists, left as is)`)
133
+ continue
134
+ }
135
+ written.push(f.rel)
136
+ io.print(`ok: ${dry ? 'would write' : 'wrote'} ${f.rel} (${f.body.split('\n').length - 1} lines)`)
137
+ if (dry) continue
138
+ mkdirSync(dirname(abs), { recursive: true })
139
+ writeFileSync(abs, f.body)
140
+ }
141
+ const reg = registerInIndex(root, col.name, dry)
142
+ const reword = { added: dry ? 'would register' : 'registered', created: dry ? 'would create' : 'created', present: 'already registers' } as const
143
+ io.print(`${reg === 'present' ? 'skip' : 'ok'}: ${REGISTRY} ${reword[reg]} ./${col.name}`)
144
+
145
+ if (dry) {
146
+ for (const f of files) {
147
+ if (kept.includes(f.rel)) continue
148
+ io.print(`=== ${f.rel}`)
149
+ for (const line of f.body.replace(/\n$/, '').split('\n')) io.print(line)
150
+ }
151
+ io.print(`ok: ${col.name} — dry run, ${written.length} file(s) would be written, ${kept.length} left as is`)
152
+ return 0
153
+ }
154
+ io.print(`ok: ${col.name} — ${written.length} file(s) written, ${kept.length} left as is`)
155
+ if (written.length > 0) io.print(`ok: prune what you do not need, then render <${col.prefix}Grid list={list} /> from a page`)
156
+ return 0
157
+ }
@@ -30,9 +30,10 @@ export function deriveRemote(root: string): { remote: string } | { error: string
30
30
  /** the worktree's dirty paths, minus the runner's own log dir. running the
31
31
  * gate creates that dir, so counting it would make every gated release refuse
32
32
  * in any repo that does not gitignore it. */
33
- export function dirtyPaths(root: string, config: RepoConfig, pathspec?: string): string {
33
+ export function dirtyPaths(root: string, config: RepoConfig, pathspec?: string | string[]): string {
34
34
  const logRoot = (config.logDir ?? 'tmp/shipkit-ci').replace(/^\.\//, '').split('/')[0]!
35
- const args = pathspec != null && pathspec !== '' ? ['status', '--porcelain', '--', pathspec] : ['status', '--porcelain']
35
+ const specs = (typeof pathspec === 'string' ? [pathspec] : (pathspec ?? [])).filter((p) => p !== '')
36
+ const args = specs.length > 0 ? ['status', '--porcelain', '--', ...specs] : ['status', '--porcelain']
36
37
  return git(root, args).out
37
38
  .split('\n')
38
39
  .filter((l) => l.trim() !== '' && !l.slice(3).trim().replace(/^"|"$/g, '').startsWith(`${logRoot}/`) && l.slice(3).trim() !== `${logRoot}/`)
@@ -0,0 +1,52 @@
1
+ // WHAT DIRT ACTUALLY BLOCKS A RELEASE.
2
+ //
3
+ // The clean-tree rule exists so a release is REPRODUCIBLE from a commit: the
4
+ // tag must describe exactly what shipped. That argument covers the paths the
5
+ // release writes or packs, and nothing else. It was implemented as "the whole
6
+ // worktree", which is the same one-checkout-one-agent assumption that used to
7
+ // live in the launcher: with several agents in one repo, a sub-unit bump got
8
+ // refused three times in a row for uncommitted files in OTHER units, none of
9
+ // which can reach its tarball and none of which the tag would contain either
10
+ // way. Correctness across the repo is the GATE's job, and the gate runs.
11
+ //
12
+ // So a SUB-UNIT bump refuses on dirt it could actually be wrong about:
13
+ // - the unit's own directory, and its changelog wherever it is declared
14
+ // - every unit's package.json, because a bump rewrites dependents' rvlib
15
+ // ranges (SK011) and that write lands in the version commit
16
+ // - the lockfiles, which a version change can move
17
+ // A ROOT bump keeps the whole-tree rule, because the root unit IS the repo.
18
+ import { relative } from 'path'
19
+
20
+ import type { ReleaseUnit } from './units.ts'
21
+
22
+ /** lockfiles a version write can move, at the repo root */
23
+ const LOCKFILES = ['bun.lock', 'bun.lockb', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']
24
+
25
+ export type ReleaseScope = {
26
+ /** pathspecs to test for dirt, or null for the whole worktree */
27
+ specs: string[] | null
28
+ /** one line saying what was examined, for the refusal and the note */
29
+ describe: string
30
+ }
31
+
32
+ /**
33
+ * the paths a bump of `targets` may write. `null` specs means "the whole
34
+ * tree", which is the honest answer for a root bump.
35
+ */
36
+ export function releaseScope(root: string, allUnits: ReleaseUnit[], targets: ReleaseUnit[]): ReleaseScope {
37
+ // the root unit is the repo: nothing in the tree is outside its release
38
+ if (targets.length === 0 || targets.some((u) => u.rel === '')) return { specs: null, describe: 'the whole worktree (a root release ships the repo)' }
39
+ const specs = new Set<string>()
40
+ for (const u of targets) {
41
+ specs.add(u.rel)
42
+ // a unit can declare its changelog outside its own directory
43
+ specs.add(relative(root, u.changelog))
44
+ }
45
+ // a bump rewrites the rvlib range in every dependent's package.json
46
+ for (const u of allUnits) specs.add(u.rel === '' ? 'package.json' : `${u.rel}/package.json`)
47
+ for (const f of LOCKFILES) specs.add(f)
48
+ return {
49
+ specs: [...specs].sort(),
50
+ describe: `${targets.map((u) => u.rel).join(', ')} plus every package.json and lockfile the version commit can touch`,
51
+ }
52
+ }
@@ -0,0 +1,85 @@
1
+ // ONE release survey per gate run, shared across processes.
2
+ //
3
+ // WHY A FILE AND NOT A MEMO: `collectReports` costs ~3.9s (a registry fetch
4
+ // and a `gh release view` per unit, now concurrent) and FIVE discovered checks
5
+ // call it. Those checks each run in their own `bun <path>` subprocess, so an
6
+ // in-process memo cannot see the other four: the run paid for the same survey
7
+ // five times. The parent hands children a path through the environment, the
8
+ // first one to arrive computes and writes it, the rest read it.
9
+ //
10
+ // It is a RUN scope, never a TTL: the path is created per gate run and the
11
+ // data is never reused across runs. A survey that outlived its run would let a
12
+ // stale green describe a repo that has since published, which is exactly the
13
+ // class of lie a release gate must not tell.
14
+ import { existsSync, mkdirSync, openSync, readFileSync, writeSync, closeSync, unlinkSync } from 'fs'
15
+ import { dirname } from 'path'
16
+
17
+ /** the env var the parent sets and children read */
18
+ export const SHARE_ENV = 'SHIPKIT_RELEASES_SHARE'
19
+
20
+ const LOCK_SUFFIX = '.lock'
21
+ /** how long a reader waits for the writer before doing the work itself. Well
22
+ * past the ~4s survey, and a timeout costs a duplicate survey, never a wrong
23
+ * answer, so it is deliberately generous. */
24
+ const WAIT_MS = 30_000
25
+ const POLL_MS = 50
26
+
27
+ const read = <T,>(path: string): T | null => {
28
+ try {
29
+ const raw = readFileSync(path, 'utf8')
30
+ return raw === '' ? null : (JSON.parse(raw) as T)
31
+ } catch { return null }
32
+ }
33
+
34
+ /** claim the right to compute. Atomic: `wx` fails if anyone else holds it. */
35
+ const claim = (lock: string): number | null => {
36
+ try {
37
+ mkdirSync(dirname(lock), { recursive: true })
38
+ return openSync(lock, 'wx')
39
+ } catch { return null }
40
+ }
41
+
42
+ /**
43
+ * run `compute` at most once per gate run, across every process in it.
44
+ *
45
+ * With no share path set (a direct `shipkit releases`, a test, any single
46
+ * process) this is just `compute()`, so the sharing is invisible to callers.
47
+ */
48
+ export async function shared<T>(compute: () => Promise<T>): Promise<T> {
49
+ const path = process.env[SHARE_ENV]
50
+ if (path == null || path === '') return await compute()
51
+ const hit = read<T>(path)
52
+ if (hit != null) return hit
53
+
54
+ const lock = `${path}${LOCK_SUFFIX}`
55
+ const fd = claim(lock)
56
+ if (fd == null) {
57
+ // someone else is surveying: wait for their answer rather than
58
+ // duplicating a network round trip per unit
59
+ const until = Date.now() + WAIT_MS
60
+ while (Date.now() < until) {
61
+ await Bun.sleep(POLL_MS)
62
+ const late = read<T>(path)
63
+ if (late != null) return late
64
+ // the writer died without writing: the lock is gone and so is the
65
+ // answer, so stop waiting for it
66
+ if (!existsSync(lock)) break
67
+ }
68
+ return await compute()
69
+ }
70
+ try {
71
+ const value = await compute()
72
+ // written through the lock fd's sibling path, then the lock released,
73
+ // so a reader never sees a half-written file
74
+ const tmp = `${path}.tmp-${process.pid}`
75
+ const t = openSync(tmp, 'w')
76
+ writeSync(t, JSON.stringify(value))
77
+ closeSync(t)
78
+ await Bun.write(path, Bun.file(tmp))
79
+ try { unlinkSync(tmp) } catch {}
80
+ return value
81
+ } finally {
82
+ closeSync(fd)
83
+ try { unlinkSync(lock) } catch {}
84
+ }
85
+ }
@@ -32,6 +32,7 @@ import { join, relative } from 'path'
32
32
  import { declaredKind, hasUnreleasedNotes, promoteUnreleased } from './changelog.ts'
33
33
  import { deriveRemote, dirtyPathList, dirtyPaths, git as gitAt, mergeProbe, remoteTags, tagExistsLocally } from './git-facts.ts'
34
34
  import { unitBlockers } from './unit-gate.ts'
35
+ import { releaseScope } from './release-scope.ts'
35
36
  import { formatUnitError, releaseOrder, renderTag, resolveUnits, unitById, unitPrepublishScripts, type ReleaseUnit } from './units.ts'
36
37
  import { environmentBlockers, runReleaseGate, releaseUnit } from './run-release.ts'
37
38
  import type { RepoConfig } from '../repo-config.ts'
@@ -62,6 +63,10 @@ export type BumpOpts = {
62
63
  unit?: string
63
64
  /** bump every unit whose changelog carries non-empty Unreleased notes */
64
65
  all?: boolean
66
+ /** proceed even with dirt inside the release scope. The scoping below
67
+ * already ignores dirt that cannot reach this release; this is the
68
+ * documented way through when it judges wrong, and matches `release`. */
69
+ allowDirty?: boolean
65
70
  print?: (line: string) => void
66
71
  }
67
72
 
@@ -207,6 +212,14 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
207
212
  return 1
208
213
  }
209
214
  }
215
+ // WHICH dirt can actually reach this release. A sub-unit bump writes its
216
+ // own directory, its changelog, every dependent's package.json (SK011
217
+ // range rewrites land in the version commit) and the lockfiles; nothing
218
+ // else in the worktree can enter its tarball or its tag. Refusing on the
219
+ // rest stalls three agents in one repo for work that cannot affect them.
220
+ const targets = o.all === true ? onDisk.units : [unitById(onDisk.units, o.unit ?? 'root')!]
221
+ const scope = releaseScope(root, onDisk.units, targets)
222
+
210
223
  // surfaces the toolkit itself rewrites on gate runs, safe to auto-commit
211
224
  const machineWrittenPaths = (r: string, c: RepoConfig): Set<string> => {
212
225
  const s = new Set(['STATUS.md'])
@@ -223,7 +236,7 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
223
236
  // the motherhome row) re-dirty on every gate run, so when they are the
224
237
  // ONLY dirt the preflight commits them itself instead of bouncing the
225
238
  // release back to a human (field wart 2026-08-04: three refusals in a row)
226
- const preDirty = dirtyPaths(root, config)
239
+ const preDirty = dirtyPaths(root, config, scope.specs ?? undefined)
227
240
  if (preDirty !== '') {
228
241
  const machine = machineWrittenPaths(root, config)
229
242
  const paths = dirtyPathList(preDirty)
@@ -238,8 +251,15 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
238
251
  print(`note: dirt beyond the machine rows (${paths.filter((p) => !machine.has(p)).join(', ')}), not auto-committing`)
239
252
  }
240
253
  }
241
- const dirty = dirtyPaths(root, config)
242
- if (dirty !== '') { print(`FAIL: working tree not clean, commit your work first:\n${dirty.split('\n').slice(0, 10).join('\n')}`); return 1 }
254
+ const dirty = dirtyPaths(root, config, scope.specs ?? undefined)
255
+ if (dirty !== '' && o.allowDirty !== true) {
256
+ print(`FAIL: uncommitted changes inside what this release writes:\n${dirty.split('\n').slice(0, 10).join('\n')}`)
257
+ print(` fix: commit them, or re-run with --allow-dirty (examined ${scope.describe})`)
258
+ if (scope.specs != null) print(' note: dirt elsewhere in the worktree is deliberately ignored, it cannot reach this tarball or this tag')
259
+ return 1
260
+ }
261
+ if (dirty !== '') print(`WARN --allow-dirty: releasing with uncommitted changes inside the release scope, the tag will not contain them:\n${dirty.split('\n').slice(0, 6).join('\n')}`)
262
+ if (scope.specs != null) print(`note: dirt checked in ${scope.describe}`)
243
263
 
244
264
  const wantMerge = cur !== branch && o.noMerge !== true && (config.release?.mergeFromBranch ?? true)
245
265
  if (cur !== branch && !wantMerge) { print(`FAIL: releases ship from '${branch}', HEAD is on '${cur}'`); return 1 }
@@ -369,13 +389,16 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
369
389
  // the gate's fix pass can rewrite files. writing a version on top of that
370
390
  // produces a version commit that excludes the rewrite and a release that
371
391
  // then refuses on a dirty tree, which is a half-released repo.
372
- const afterGate = dirtyPaths(root, config)
373
- if (afterGate !== '')
392
+ // scoped like the preflight, and for the same reason: this exists to catch
393
+ // a FIXER rewriting what is about to ship, not to notice that another
394
+ // agent saved a file in an unrelated unit while the gate ran
395
+ const afterGate = dirtyPaths(root, config, scope.specs ?? undefined)
396
+ if (afterGate !== '' && o.allowDirty !== true)
374
397
  return fail(
375
- `the gate rewrote files, so the tree is no longer clean:\n${afterGate.split('\n').slice(0, 10).join('\n')}`,
398
+ `the gate rewrote files this release writes, so what would ship is no longer what was gated:\n${afterGate.split('\n').slice(0, 10).join('\n')}`,
376
399
  rollback != null
377
- ? 'the rollback below discards them. run `shipkit ci` on your branch, commit what it rewrites, then bump again'
378
- : 'run `shipkit ci`, commit what it rewrites, then bump again',
400
+ ? 'the rollback below discards them. run `shipkit ci` on your branch, commit what it rewrites, then bump again — or --allow-dirty to ship anyway'
401
+ : 'run `shipkit ci`, commit what it rewrites, then bump again — or --allow-dirty to ship anyway',
379
402
  )
380
403
  print('ok: ci gate green')
381
404
 
@@ -101,13 +101,20 @@ export type ReleaseOpts = {
101
101
  * check, and a check's own `blocking` declaration is the only severity axis.
102
102
  *
103
103
  * the config travels INTO the gate: classifying severity from one config
104
- * while the rules ran from another downgrades a blocking failure to a warning. */
104
+ * while the rules ran from another downgrades a blocking failure to a warning.
105
+ *
106
+ * it also runs the FULL test tier, which `shipkit ci` skips while you work. */
105
107
  export async function runReleaseGate(root: string, config: RepoConfig, print: (l: string) => void, o: { fix?: boolean } = {}): Promise<number> {
106
108
  return await runCi(root, {
107
109
  fix: o.fix ?? false,
108
110
  frozen: true,
109
111
  status: false,
110
112
  config,
113
+ // the FULL tier, always. A release runs on a clean tree with time to
114
+ // spare, and the suites the working gate skips (the ones that build
115
+ // real repositories) are exactly the ones a publish must not be wrong
116
+ // about. Publishing behind a partial suite is a green nobody measured.
117
+ tests: 'full',
111
118
  // a check that cannot block cannot change the outcome, so a release
112
119
  // never probes the world: publishing a lib does not depend on whether
113
120
  // a DNS provider answers, and asking spends an API call per publish.
@@ -12,6 +12,7 @@ import { hasUnreleasedHeading, hasVersionSection } from './changelog.ts'
12
12
  import { deriveRemote, git, remoteTags } from './git-facts.ts'
13
13
  import { formatUnitError, resolveUnits, type ReleaseUnit } from './units.ts'
14
14
  import type { RepoConfig } from '../repo-config.ts'
15
+ import { shared } from './releases-share.ts'
15
16
 
16
17
  export type UnitReport = {
17
18
  id: string
@@ -79,7 +80,16 @@ function treeAt(root: string, u: ReleaseUnit, ref: string): string | null {
79
80
  return hit != null ? rev(hit) : null
80
81
  }
81
82
 
82
- export async function collectReports(root: string, config: RepoConfig, o: { offline?: boolean } = {}): Promise<{ reports: UnitReport[]; errors: string[]; remote: string | null; remoteError: string | null }> {
83
+ export type ReleaseSurvey = { reports: UnitReport[]; errors: string[]; remote: string | null; remoteError: string | null }
84
+
85
+ /** the survey, at most ONCE per gate run across every process in it (see
86
+ * releases-share.ts). An `offline` survey is never shared: it answers a
87
+ * different question than the networked one and must not satisfy it. */
88
+ export async function collectReports(root: string, config: RepoConfig, o: { offline?: boolean } = {}): Promise<ReleaseSurvey> {
89
+ return o.offline === true ? await surveyUnits(root, config, o) : await shared(() => surveyUnits(root, config, o))
90
+ }
91
+
92
+ async function surveyUnits(root: string, config: RepoConfig, o: { offline?: boolean } = {}): Promise<ReleaseSurvey> {
83
93
  // a unit that does not resolve is still news: it is named, and everything
84
94
  // that DID resolve is still reported beside it
85
95
  const { units, errors } = resolveUnits(root, config)
@@ -93,8 +103,12 @@ export async function collectReports(root: string, config: RepoConfig, o: { offl
93
103
  else remoteError = `git ls-remote ${remote} unreachable (${r.error})`
94
104
  }
95
105
 
96
- const reports: UnitReport[] = []
97
- for (const u of units) {
106
+ // CONCURRENT per unit, and the reason is measured: each unit does a
107
+ // registry fetch and a `gh release view`, both network, and in series
108
+ // seven units cost 13.9s. This function runs inside five separate check
109
+ // subprocesses on every gate, so that was the single largest cost in the
110
+ // whole run. Order is preserved by mapping rather than pushing.
111
+ const reports: UnitReport[] = await Promise.all(units.map(async (u): Promise<UnitReport> => {
98
112
  const tagLocal = git(root, ['rev-parse', '--quiet', '--verify', `refs/tags/${u.tag}`]).code === 0
99
113
  const changelog = existsSync(u.changelog) ? readFileSync(u.changelog, 'utf8') : ''
100
114
  const hasSection = hasVersionSection(changelog, u.version)
@@ -126,7 +140,7 @@ export async function collectReports(root: string, config: RepoConfig, o: { offl
126
140
  ? Bun.spawnSync(['gh', 'release', 'view', u.tag], { cwd: root }).exitCode === 0
127
141
  : null
128
142
 
129
- reports.push({
143
+ return {
130
144
  id: u.id,
131
145
  rel: u.rel === '' ? '.' : u.rel,
132
146
  name: u.name,
@@ -145,8 +159,8 @@ export async function collectReports(root: string, config: RepoConfig, o: { offl
145
159
  hasSection,
146
160
  commitsAfterNotes,
147
161
  hasUnreleased: hasUnreleasedHeading(changelog),
148
- })
149
- }
162
+ }
163
+ }))
150
164
  return { reports, errors: errors.map(formatUnitError), remote, remoteError }
151
165
  }
152
166
 
@@ -12,7 +12,10 @@ import { existsSync, readFileSync } from 'fs'
12
12
  import { isAbsolute, join, normalize } from 'path'
13
13
  import { fileURLToPath } from 'url'
14
14
  import { activeContracts, applyContractsResult, contractsConfigFrom } from './contracts/apply-contracts.ts'
15
- import { evaluateContracts } from './contracts/contracts-engine.ts'
15
+ import { ROUTED_SCRIPTS } from './core/canonical-scripts.ts'
16
+ import { FLOOR_IDS, resolveFloor, type FloorResolution } from './core/floor.ts'
17
+ export { resolveFloor, type FloorResolution, type FloorSource } from './core/floor.ts'
18
+ import { evaluateContractsOnce } from './contracts/contracts-once.ts'
16
19
  import type { ContractManifest } from './contracts/contract-types.ts'
17
20
  import { contractsFulfillness } from './rules/SK002-contracts-fulfillness.ts'
18
21
  import { BUILTIN_RULE_IDS, validateRules } from './rules/rule-registry.ts'
@@ -64,6 +67,12 @@ export type RepoConfig = {
64
67
  /** repo-contributed .gitignore lines, rendered INSIDE rule SK004's managed
65
68
  * block — for ecosystem-specific names the published lib may not carry */
66
69
  gitignore?: string[]
70
+ /** untrack the paths shipkit generates on every run (STATUS.md and
71
+ * friends) the first time it sees them tracked, in one commit. Default
72
+ * ON: a generated file left in the index dirties every tree forever and
73
+ * rides into unrelated commits. `false` opts a repo out entirely, and
74
+ * `--frozen` suppresses it for one run the way it suppresses SK005. */
75
+ untrackGenerated?: boolean
67
76
  /** declared github visibility INTENT (absent = 'private') — rule SK007
68
77
  * fails loud when `gh repo view` disagrees in either direction */
69
78
  visibility?: 'private' | 'public'
@@ -74,6 +83,13 @@ export type RepoConfig = {
74
83
  * Rule SK008 lints it, `check --fix SK008` writes it. Absent = no
75
84
  * mandated reviewer. */
76
85
  reviewer?: { name: string; brief: string; tools?: string[] }
86
+ /** ONE WAY IN escape hatch (build/shipkit.md): governed script names this
87
+ * repo keeps as a direct tool call. Declared per NAME, never as a list of
88
+ * banned binaries: the invariant is about which VERBS an agent reaches
89
+ * for, and a binary blocklist goes stale the day a repo changes runner.
90
+ * A name outside ROUTED_SCRIPTS throws here, so a typo can never silently
91
+ * un-govern a script. Rule SK001 reads it. */
92
+ oneWayIn?: { exempt?: string[] }
77
93
  /** repo-local rules, run by `shipkit check` beside the built-in SK ones and
78
94
  * listed by `shipkit rules`. Same shape, same id-tagging, same fix pointers;
79
95
  * the repo picks its own id prefix (SK is reserved). This is how a repo's
@@ -115,7 +131,7 @@ export type RepoConfig = {
115
131
  logDir?: string
116
132
  }
117
133
 
118
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'notifyCommand', 'contracts', 'gitignore', 'visibility', 'reviewer', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir'])
134
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir'])
119
135
 
120
136
  export function defineRepo(c: RepoConfig): RepoConfig {
121
137
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -145,6 +161,9 @@ export function defineRepo(c: RepoConfig): RepoConfig {
145
161
  for (const k of Object.keys(c.defaults ?? {}))
146
162
  if (!(FLOOR_IDS as readonly string[]).includes(k))
147
163
  throw new Error(`defineRepo: defaults.${k} is not a floor task (floor: ${FLOOR_IDS.join(', ')})`)
164
+ for (const n of c.oneWayIn?.exempt ?? [])
165
+ if (!(ROUTED_SCRIPTS as readonly string[]).includes(n))
166
+ throw new Error(`defineRepo: oneWayIn.exempt '${n}' is not a governed script name (governed: ${ROUTED_SCRIPTS.join(', ')})`)
148
167
  // repo rules are validated against the built-ins, so a bad id, a duplicate,
149
168
  // or an SK squatter dies at config load, not silently mid-check
150
169
  if (c.rules != null) validateRules(c.rules, { label: 'defineRepo: rules', taken: BUILTIN_RULE_IDS })
@@ -166,73 +185,11 @@ export function contractsTask(c: RepoConfig, root: string): TaskDef {
166
185
  return {
167
186
  id: 'contracts',
168
187
  logic: async (t) => {
169
- applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)), contractsFulfillness)
188
+ applyContractsResult(t, await evaluateContractsOnce(activeContracts(c), contractsConfigFrom(c, root)), contractsFulfillness)
170
189
  },
171
190
  }
172
191
  }
173
192
 
174
- const FLOOR_IDS = ['lint', 'typecheck', 'test', 'imports:check', 'cycles'] as const
175
-
176
- export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)'
177
- export type FloorResolution = {
178
- tasks: TaskDef[]
179
- /** slots disabled by `defaults: { x: false }` — always printed */
180
- disabled: string[]
181
- /** slots nothing could fill — always printed, never silent */
182
- unfilled: { id: string; reason: string }[]
183
- /** where each registered slot came from (init prints this) */
184
- sources: Record<string, FloorSource>
185
- }
186
-
187
- // zero-config floor (O6): with no script, imports:check/cycles resolve to
188
- // shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc. Returns
189
- // the task, or the reason the slot stays empty.
190
- function builtinFloorTask(id: string, root: string): { task: TaskDef; source: FloorSource } | string {
191
- if (id === 'imports:check' || id === 'cycles') {
192
- let cli: string
193
- // shipkit's OWN module, resolved from this file: no dependency to be
194
- // out of date with, and no resolution that can fail in a consumer tree
195
- cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
196
- return {
197
- task: { id, cmd: id === 'cycles' ? ['bun', cli, 'cycles'] : ['bun', cli, 'imports', '--check'] },
198
- source: 'built-in (shipkit)',
199
- }
200
- }
201
- if (id === 'typecheck') {
202
- if (!existsSync(join(root, 'tsconfig.json'))) return `no 'typecheck' script and no tsconfig.json`
203
- const local = join(root, 'node_modules/.bin/tsc')
204
- const tsc = existsSync(local) ? local : Bun.which('tsc')
205
- if (tsc == null) return `no 'typecheck' script and tsc not found — add typescript or a typecheck script`
206
- return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
207
- }
208
- return `no package.json '${id}' script`
209
- }
210
-
211
- export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
212
- const pkgPath = join(root, 'package.json')
213
- let scripts: Record<string, string> = {}
214
- if (existsSync(pkgPath)) {
215
- // unparseable package.json must NOT silently empty the floor — the
216
- // gate would run only `contracts` and report green
217
- try { scripts = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {} }
218
- catch (e) { throw new Error(`package.json unparseable — floor tasks cannot register: ${String(e).slice(0, 120)}`) }
219
- }
220
- const tasks: TaskDef[] = []
221
- const disabled: string[] = []
222
- const unfilled: { id: string; reason: string }[] = []
223
- const sources: Record<string, FloorSource> = {}
224
- for (const id of FLOOR_IDS) {
225
- const override = c.defaults?.[id]
226
- if (override === false) { disabled.push(id); continue }
227
- if (override != null) { tasks.push(override); sources[id] = 'repo.config override'; continue }
228
- if (scripts[id] != null) { tasks.push({ id, cmd: ['bun', 'run', id] }); sources[id] = 'package.json script'; continue }
229
- const b = builtinFloorTask(id, root)
230
- if (typeof b === 'string') unfilled.push({ id, reason: b })
231
- else { tasks.push(b.task); sources[id] = b.source }
232
- }
233
- return { tasks, disabled, unfilled, sources }
234
- }
235
-
236
193
  export function allTasks(c: RepoConfig, root: string): FloorResolution {
237
194
  const floor = resolveFloor(c, root)
238
195
  return { ...floor, tasks: [...floor.tasks, contractsTask(c, root), ...(c.tasks ?? [])] }