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.
- package/CHANGELOG.md +14 -0
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/cli/cmd_Bump.ts +2 -0
- package/src/cli/cmd_Ci.ts +3 -1
- package/src/cli/cmd_Pb.ts +27 -0
- package/src/cli/cmd_Release.ts +1 -1
- package/src/cli/cmd_Wiki.ts +2 -2
- package/src/cli.ts +49 -39
- package/src/collections/graphs.ts +17 -19
- package/src/collections/providers.ts +7 -2
- package/src/contracts/contracts-once.ts +43 -0
- package/src/core/canonical-scripts.ts +33 -0
- package/src/core/check-ctx.ts +13 -0
- package/src/core/check-suite.ts +121 -53
- package/src/core/floor.ts +82 -0
- package/src/core/motherhome.ts +14 -1
- package/src/core/run-check.ts +34 -0
- package/src/core/run-ci.ts +63 -2
- package/src/core/run-fixers.ts +6 -1
- package/src/ledger/toolchain-identity.ts +134 -0
- package/src/pb/collection-scaffold.ts +299 -0
- package/src/pb/pb-types.ts +233 -0
- package/src/pb/run-pb.ts +157 -0
- package/src/release/git-facts.ts +3 -2
- package/src/release/release-scope.ts +52 -0
- package/src/release/releases-share.ts +85 -0
- package/src/release/run-bump.ts +31 -8
- package/src/release/run-release.ts +8 -1
- package/src/release/run-releases.ts +20 -6
- package/src/repo-config.ts +22 -65
- package/src/rules/SK001-package-json-shape.ts +65 -2
- package/src/rules/SK002-contracts-fulfillness.ts +2 -2
- package/src/rules/SK004-gitignore-policy.ts +27 -3
- package/src/rules/SK012-config-barrel-import.ts +60 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/rules/untrack-generated.ts +117 -0
- package/src/scaffold/project-scaffold.ts +1 -1
- package/src/serve/hub-port.ts +17 -0
- package/src/serve/run-serve.ts +54 -20
- package/src/serve/serve-hub.ts +13 -15
- package/src/shipkit.ts +3 -2
- package/src/wiki/graph-mount.ts +21 -0
- package/src/wiki/run-wiki.ts +18 -8
package/src/pb/run-pb.ts
ADDED
|
@@ -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
|
+
}
|
package/src/release/git-facts.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/release/run-bump.ts
CHANGED
|
@@ -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 !== ''
|
|
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
|
-
|
|
373
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
package/src/repo-config.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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 ?? [])] }
|