rvlib-shipkit 0.34.0 → 0.35.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 +4 -0
- package/package.json +1 -1
- package/src/cli/cmd_Ci.ts +3 -0
- package/src/cli/cmd_Keys.ts +10 -4
- package/src/core/check-suite.ts +37 -92
- package/src/core/discover-checks.ts +1 -7
- package/src/core/floor.ts +13 -4
- package/src/core/motherhome.ts +54 -1
- package/src/core/run-check.ts +92 -12
- package/src/core/run-ci.ts +103 -29
- package/src/core/task-runner.ts +7 -0
- package/src/ledger/gate-units.ts +29 -3
- package/src/ledger/git-key.ts +56 -5
- package/src/ledger/git-scan.ts +15 -2
- package/src/ledger/ledger.ts +202 -0
- package/src/ledger/store.ts +122 -0
- package/src/ledger/toolchain-identity.ts +30 -0
- package/src/pb/collection-scaffold.ts +4 -4
- package/src/release/run-release.ts +5 -0
- package/src/release/run-releases.ts +7 -1
- package/src/repo-config.ts +26 -4
- package/src/rules/SK004-gitignore-policy.ts +1 -2
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// ONE STORE. Everything this toolkit remembers between runs lives here, in
|
|
2
|
+
// one shape, under one root, busted by one flag.
|
|
3
|
+
//
|
|
4
|
+
// There used to be two: `tmp/.shipkit-cache` for the check orchestrator and
|
|
5
|
+
// `tmp/.check-cache` for the page checks, each with its own bust env var, each
|
|
6
|
+
// per worktree. Two stores means two staleness windows over one question, and
|
|
7
|
+
// the stamps in this repo's own history went BACKWARDS between checkouts
|
|
8
|
+
// because of it. This module is the single mechanism; what differs between
|
|
9
|
+
// populations is the KEY, never the storage.
|
|
10
|
+
//
|
|
11
|
+
// THE SHAPE, unchanged from the check cache slice 2 landed and proved:
|
|
12
|
+
// one immutable file per key, named by the key, written tmp + rename. No
|
|
13
|
+
// lock, no merge, no read-modify-write, and no prune of another runner's
|
|
14
|
+
// entries. Three agents in one checkout is the normal case, so the only safe
|
|
15
|
+
// aggregate is no aggregate: a run with a different key writes a DIFFERENT
|
|
16
|
+
// file, a run with the same key replaces its own whole answer or none of it,
|
|
17
|
+
// and housekeeping sweeps by AGE, which needs no knowledge of what the
|
|
18
|
+
// current run knows about.
|
|
19
|
+
//
|
|
20
|
+
// WHERE. `<git-common-dir>/shipkit/store/`, which is SHARED by every linked
|
|
21
|
+
// worktree of the repo. That is safe only because no key component is a path:
|
|
22
|
+
// the toolchain identity content-hashes the gate binary and every installed
|
|
23
|
+
// package, so two worktrees carrying different installs produce different
|
|
24
|
+
// keys and simply miss each other (ledger/toolchain-identity.ts owns the
|
|
25
|
+
// measurement). It is also outside the work tree, so nothing here can dirty a
|
|
26
|
+
// diff or reach a commit.
|
|
27
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
|
|
28
|
+
import { dirname, join } from 'path'
|
|
29
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
30
|
+
|
|
31
|
+
/** the storage FORMAT. Bumped when the envelope changes, so an old file can
|
|
32
|
+
* never be handed to a new reader. The KEY carries everything about the
|
|
33
|
+
* question; this is only about the file around it. */
|
|
34
|
+
export const STORE_VERSION = 1
|
|
35
|
+
|
|
36
|
+
/** the ONE env var that busts every remembered answer, content or TTL. */
|
|
37
|
+
export const FRESH_ENV = 'SHIPKIT_FRESH'
|
|
38
|
+
|
|
39
|
+
/** one remembered answer. `key` is stored as well as hashed into the file
|
|
40
|
+
* name, and re-checked on read: a name collision must miss, never serve a
|
|
41
|
+
* neighbour's answer. */
|
|
42
|
+
export type Stored<T> = {
|
|
43
|
+
v: number
|
|
44
|
+
key: string
|
|
45
|
+
/** when it was written, ms since epoch */
|
|
46
|
+
at: number
|
|
47
|
+
payload: T
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** how long a superseded entry survives. Far past the longest TTL any check
|
|
51
|
+
* declares, so a live entry is never swept out from under a replay. */
|
|
52
|
+
export const KEEP_MS = 30 * 24 * 60 * 60 * 1000
|
|
53
|
+
|
|
54
|
+
/** where this repo's remembered answers live.
|
|
55
|
+
*
|
|
56
|
+
* `--git-common-dir` rather than `--git-dir`: a linked worktree has its own
|
|
57
|
+
* git dir but shares the common one, and slice 3 measured that sharing is
|
|
58
|
+
* SAFE here because every key component is content or a version string.
|
|
59
|
+
* A tree that is not a git repo at all still gets a store, under `tmp/`,
|
|
60
|
+
* because a scaffold in progress must not crash on it. */
|
|
61
|
+
export function storeRoot(root: string): string {
|
|
62
|
+
const p = Bun.spawnSync(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
63
|
+
const dir = p.exitCode === 0 ? p.stdout.toString().trim() : ''
|
|
64
|
+
return dir === '' ? join(root, 'tmp/.shipkit-store') : join(dir, 'shipkit/store')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const slug = (s: string): string => s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)
|
|
68
|
+
|
|
69
|
+
/** where ONE answer lives. The key is in the file NAME, so a run whose key
|
|
70
|
+
* moved writes a new file instead of rewriting the old one, and two runners
|
|
71
|
+
* with different keys never collide. `ns` keeps populations apart so a sweep
|
|
72
|
+
* or a manual `rm` can be scoped. */
|
|
73
|
+
export function entryPath(store: string, ns: string, name: string, key: string): string {
|
|
74
|
+
const h = new Bun.CryptoHasher('sha256').update(`${ns}\0${name}\0${key}`).digest('hex').slice(0, 12)
|
|
75
|
+
return join(store, slug(ns), `${slug(name)}.${h}.json`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** a missing or half-written file reads as "no answer", which is the safe
|
|
79
|
+
* side: the unit runs. tmp + rename means half-written cannot happen from a
|
|
80
|
+
* shipkit write, so this only ever catches a truncated file from something
|
|
81
|
+
* else, and treats it the same way. */
|
|
82
|
+
export function readStored<T>(path: string, key: string): Stored<T> | null {
|
|
83
|
+
try {
|
|
84
|
+
const e = JSON.parse(readFileSync(path, 'utf8')) as Partial<Stored<T>>
|
|
85
|
+
if (e.v !== STORE_VERSION || e.key !== key || typeof e.at !== 'number' || e.payload == null) return null
|
|
86
|
+
return e as Stored<T>
|
|
87
|
+
} catch { return null }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function writeStored<T>(path: string, key: string, payload: T, at: number): void {
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
93
|
+
atomicWrite(path, JSON.stringify({ v: STORE_VERSION, key, at, payload }))
|
|
94
|
+
} catch {
|
|
95
|
+
// a store that cannot be written is slow, never wrong: the answer was
|
|
96
|
+
// already printed, and the next run measures again
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function dropStored(path: string): void {
|
|
101
|
+
try { rmSync(path, { force: true }) } catch { /* raced with another runner: it is gone either way */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** age-based housekeeping. Sweeping by "the units I know about" is what made
|
|
105
|
+
* the old prune wrong: a run narrowed with --only knows about one unit and
|
|
106
|
+
* would delete the other sixty-five. */
|
|
107
|
+
export function sweepStore(store: string, ns: string, now: number, keepMs = KEEP_MS): void {
|
|
108
|
+
const dir = join(store, slug(ns))
|
|
109
|
+
const cutoff = now - keepMs
|
|
110
|
+
try {
|
|
111
|
+
for (const f of readdirSync(dir)) {
|
|
112
|
+
// `.<name>.tmp-<pid>` is what atomicWrite lands beside its target,
|
|
113
|
+
// and a writer killed between the write and the rename leaves one
|
|
114
|
+
// behind. Sweeping only *.json accreted them without bound.
|
|
115
|
+
if (!f.endsWith('.json') && !/\.tmp-\d+$/.test(f)) continue
|
|
116
|
+
const p = join(dir, f)
|
|
117
|
+
try { if (statSync(p).mtimeMs < cutoff) rmSync(p, { force: true }) } catch { /* raced: gone either way */ }
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// no dir yet, or unreadable: housekeeping never fails a run
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -75,6 +75,36 @@ function hashCliTree(h: ReturnType<typeof createHash>, cli: string): void {
|
|
|
75
75
|
h.update(`clifiles=${files.length}\n`)
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* what ONE install holds, as a hash: every top-level package's name, version
|
|
80
|
+
* and link target.
|
|
81
|
+
*
|
|
82
|
+
* Exported because a repo's own install is not the only one a gate can run
|
|
83
|
+
* against. Sub-packages carry their own `node_modules`, gitignored like every
|
|
84
|
+
* other, so no git key can see them and the root toolchain does not describe
|
|
85
|
+
* them either. A unit whose declared inputs COVER such a directory folds this
|
|
86
|
+
* in beside the path, or the toolchain component stops at the repo root while
|
|
87
|
+
* the task it is meant to describe does not.
|
|
88
|
+
*
|
|
89
|
+
* ~2ms per install, and only for a path a scan already reported as ignored.
|
|
90
|
+
*/
|
|
91
|
+
export function installIdentity(nodeModules: string): string {
|
|
92
|
+
if (!existsSync(nodeModules)) return 'install=absent'
|
|
93
|
+
const h = createHash('sha256')
|
|
94
|
+
let names: string[] = []
|
|
95
|
+
try { names = installedNames(nodeModules) } catch { return 'install=unreadable' }
|
|
96
|
+
for (const n of names) {
|
|
97
|
+
const dir = join(nodeModules, n)
|
|
98
|
+
let version = 'MISSING'
|
|
99
|
+
try { version = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { version?: string }).version ?? '?' } catch {}
|
|
100
|
+
let link = ''
|
|
101
|
+
try { if (lstatSync(dir).isSymbolicLink()) link = `->${realpathSync(dir)}` } catch {}
|
|
102
|
+
h.update(`${n}@${version}${link}\n`)
|
|
103
|
+
}
|
|
104
|
+
h.update(`n=${names.length}\n`)
|
|
105
|
+
return `install=${h.digest('hex').slice(0, 16)}`
|
|
106
|
+
}
|
|
107
|
+
|
|
78
108
|
/**
|
|
79
109
|
* the identity of the program about to run, for `repoRoot`, driven by `cliEntry`.
|
|
80
110
|
*
|
|
@@ -101,7 +101,7 @@ function columnsFile(col: PbCollection): string {
|
|
|
101
101
|
lines.push('// means the cell is derived and the database cannot order by it.')
|
|
102
102
|
lines.push('// labels are plain text, swap them for i18n.t.<collection>.<field> when the')
|
|
103
103
|
lines.push('// app carries a dict.')
|
|
104
|
-
lines.push(`import type { Column } from "rvlib-pb
|
|
104
|
+
lines.push(`import type { Column } from "rvlib-pb/ui"`)
|
|
105
105
|
lines.push(`import type { ${col.prefix}Response } from "${TYPES}"`)
|
|
106
106
|
lines.push('')
|
|
107
107
|
lines.push(`export const columns: Column<${col.prefix}Response>[] = [`)
|
|
@@ -144,8 +144,8 @@ function gridFile(col: PbCollection): string {
|
|
|
144
144
|
return [
|
|
145
145
|
`// the default grid for \`${col.name}\`. A page that wants the plain table renders`,
|
|
146
146
|
'// this; a page that wants extra columns passes its own to LiveGrid directly.',
|
|
147
|
-
`import type { LiveList } from "rvlib-pb
|
|
148
|
-
`import { LiveGrid } from "rvlib-pb
|
|
147
|
+
`import type { LiveList } from "rvlib-pb"`,
|
|
148
|
+
`import { LiveGrid } from "rvlib-pb/ui"`,
|
|
149
149
|
`import type { ${col.prefix}Response } from "${TYPES}"`,
|
|
150
150
|
`import { columns } from "./columns"`,
|
|
151
151
|
'',
|
|
@@ -247,7 +247,7 @@ function indexFile(col: PbCollection, hasForm: boolean): string {
|
|
|
247
247
|
lines.push('// registration: after this import, a LiveGrid or LiveCards over')
|
|
248
248
|
lines.push(`// \`${col.name}\` with no columns of its own knows what to render, and any`)
|
|
249
249
|
lines.push('// relation cell pointing AT one knows how to name it.')
|
|
250
|
-
lines.push(`import { defineCollectionUI } from "rvlib-pb
|
|
250
|
+
lines.push(`import { defineCollectionUI } from "rvlib-pb/ui"`)
|
|
251
251
|
lines.push(`import { db } from "${PB}"`)
|
|
252
252
|
lines.push(`import { ${col.prefix}Card } from "./card"`)
|
|
253
253
|
lines.push(`import { columns } from "./columns"`)
|
|
@@ -110,6 +110,11 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
|
|
|
110
110
|
frozen: true,
|
|
111
111
|
status: false,
|
|
112
112
|
config,
|
|
113
|
+
// THE CAP ON EVERY REMAINING HOLE IN THE KEY. Not a flag, not a config
|
|
114
|
+
// field: a release re-measures every unit, so the worst a wrong key
|
|
115
|
+
// can ever produce is an agent looking at a stale local green, never a
|
|
116
|
+
// stale green that authorised a publish (ledger/ledger.ts rule 4).
|
|
117
|
+
ledgerRead: false,
|
|
113
118
|
// the FULL tier, always. A release runs on a clean tree with time to
|
|
114
119
|
// spare, and the suites the working gate skips (the ones that build
|
|
115
120
|
// real repositories) are exactly the ones a publish must not be wrong
|
|
@@ -204,7 +204,13 @@ export function attentionLines(r: UnitReport): string[] {
|
|
|
204
204
|
// shape of a command
|
|
205
205
|
const KIND = '<patch|minor|major>'
|
|
206
206
|
|
|
207
|
-
if (r.
|
|
207
|
+
if (r.npmTarget && r.npmLatest === 'unpublished') {
|
|
208
|
+
// NO version of this name is on the registry, which every drift branch
|
|
209
|
+
// below reads as "a bit behind" and none of them says. Every `bun add
|
|
210
|
+
// <name>` in a doc, a seed or a scaffold answers 404 while this holds,
|
|
211
|
+
// and that is the one state where the docs are not stale but dead
|
|
212
|
+
out.push(`WARN ${r.id}: nothing is published under the name ${r.name} — every "bun add ${r.name}" 404s until ${notesClause}shipkit bump ${KIND} ${r.id}`)
|
|
213
|
+
} else if (r.commitsAfterNotes != null && r.commitsAfterNotes > 0 && !r.tagLocal) {
|
|
208
214
|
// the trap: a version stamped into package.json while work kept
|
|
209
215
|
// landing. Publishing it ships that work under notes describing none
|
|
210
216
|
// of it, so the answer is a NEW version, never this one.
|
package/src/repo-config.ts
CHANGED
|
@@ -23,7 +23,7 @@ import type { Rule } from './rules/rule.ts'
|
|
|
23
23
|
import type { HooksConfig } from './hooks/run-hooks.ts'
|
|
24
24
|
import type { ReleaseConfig } from './release/run-release.ts'
|
|
25
25
|
import { UNIT_DECL_KEYS, type ReleaseUnitDecl } from './release/units.ts'
|
|
26
|
-
import type
|
|
26
|
+
import { TASK_DEF_KEYS, type TaskDef } from './core/task-runner.ts'
|
|
27
27
|
import type { TuiPanelDef } from './tui/tui-panel.ts'
|
|
28
28
|
import type { MdGraph } from './wiki/md-graph.ts'
|
|
29
29
|
|
|
@@ -51,8 +51,13 @@ export type RepoConfig = {
|
|
|
51
51
|
social?: string[]
|
|
52
52
|
/** prefix for contract fix: pointers (docs checkout or URL) — written by init, never baked in */
|
|
53
53
|
docsBase?: string
|
|
54
|
-
/**
|
|
55
|
-
|
|
54
|
+
/** a floor slot: `false` disables it (always visible), a def with a
|
|
55
|
+
* `cmd` or `logic` REPLACES it, and a def with neither DECORATES whatever
|
|
56
|
+
* fills it. The decoration is how a repo declares what its own `test`
|
|
57
|
+
* script reads (`{ test: { inputs: ['tests', 'src'] } }`) without
|
|
58
|
+
* restating the command: the read set is repo knowledge, the command is
|
|
59
|
+
* already in package.json, and restating it is a second source of truth. */
|
|
60
|
+
defaults?: Record<string, Partial<TaskDef> | false>
|
|
56
61
|
/** repo-specific commit-gate tasks; deps double as race guards */
|
|
57
62
|
tasks?: TaskDef[]
|
|
58
63
|
/** emptyText: the `shipkit asks` all-clear line, in the repo's own voice */
|
|
@@ -158,9 +163,19 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
158
163
|
if (isAbsolute(d) || rel === '.' || rel === '' || rel === '..' || rel.startsWith('../'))
|
|
159
164
|
throw new Error(`defineRepo: notReleasable '${d}' is the repo root or outside it, and would excuse every package at once — name one package dir per entry`)
|
|
160
165
|
}
|
|
161
|
-
for (const k of Object.
|
|
166
|
+
for (const [k, slot] of Object.entries(c.defaults ?? {})) {
|
|
162
167
|
if (!(FLOOR_IDS as readonly string[]).includes(k))
|
|
163
168
|
throw new Error(`defineRepo: defaults.${k} is not a floor task (floor: ${FLOOR_IDS.join(', ')})`)
|
|
169
|
+
// the slot's own FIELDS, not just its name. A slot with no cmd or logic
|
|
170
|
+
// decorates the resolved task, so a typo there (`input` for `inputs`)
|
|
171
|
+
// is not a type error at any call site and lands as a task that is
|
|
172
|
+
// simply never cached, silently, which is the one direction this file
|
|
173
|
+
// promises a typo cannot go.
|
|
174
|
+
if (slot === false || slot == null) continue
|
|
175
|
+
for (const f of Object.keys(slot))
|
|
176
|
+
if (!(TASK_DEF_KEYS as readonly string[]).includes(f))
|
|
177
|
+
throw new Error(`defineRepo: defaults.${k}.${f} is not a task field (fields: ${TASK_DEF_KEYS.join(', ')})`)
|
|
178
|
+
}
|
|
164
179
|
for (const n of c.oneWayIn?.exempt ?? [])
|
|
165
180
|
if (!(ROUTED_SCRIPTS as readonly string[]).includes(n))
|
|
166
181
|
throw new Error(`defineRepo: oneWayIn.exempt '${n}' is not a governed script name (governed: ${ROUTED_SCRIPTS.join(', ')})`)
|
|
@@ -181,6 +196,13 @@ export async function loadRepoConfig(root: string): Promise<RepoConfig> {
|
|
|
181
196
|
// the contracts commit-gate task — venue deploy tasks should dep on it.
|
|
182
197
|
// Same SK002-tagged rendering as `shipkit check` and `shipkit contracts`
|
|
183
198
|
// (contracts/apply-contracts.ts is the one bridge).
|
|
199
|
+
//
|
|
200
|
+
// DELIBERATELY UNDECLARED for the ledger, and this is where that decision
|
|
201
|
+
// lives rather than in a commit message. It runs in process, shares one
|
|
202
|
+
// evaluation with rule SK002 through `withContractsScope`, and measures 0.0s,
|
|
203
|
+
// so a key would be risk against no reward. It is not `never:` either: the
|
|
204
|
+
// contract dirs describe it perfectly well, so claiming nothing can is false,
|
|
205
|
+
// and `never` has to keep meaning "somebody looked and no key exists".
|
|
184
206
|
export function contractsTask(c: RepoConfig, root: string): TaskDef {
|
|
185
207
|
return {
|
|
186
208
|
id: 'contracts',
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// SK004 — the .gitignore policy block (meta/contracts.md git policy; tmp/
|
|
2
|
-
// included because shipkit
|
|
3
|
-
// repo). A marker-keyed managed block so the always-gitignored contract
|
|
2
|
+
// included because shipkit writes its per-run logs there in every repo). A marker-keyed managed block so the always-gitignored contract
|
|
4
3
|
// surfaces can never silently rot per repo. Ecosystem-specific names the
|
|
5
4
|
// published lib may not carry ride as repo-contributed lines:
|
|
6
5
|
// `defineRepo({ gitignore: [...] })` renders them INSIDE the block, so the
|