rvlib-shipkit 0.17.0 → 0.17.2

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.2
4
+
5
+ - **A release no longer probes the world.** The release gate runs only the checks that can BLOCK. A check declaring `blocking: false` cannot change the outcome, so running it spent time and a third party API call per publish to learn whether a DNS provider was reachable. `release.requireHealthy` still makes those failures fatal, and then they run.
6
+ - **The release gate stopped busting the check caches.** 0.17.0's gate ran fresh, so every cached probe refired on every publish. Caching exists to rate-limit exactly those calls.
7
+ - **New `release.skipTasks`.** Names the repo-wide tasks a release does not need, for a monorepo whose commit gate runs every package's suite. Releasing one unit already runs that unit's own tests and typecheck, and the other packages are not in its tarball.
8
+
9
+
10
+ ## 0.17.1
11
+
12
+ - **Fixed: `shipkit ci`'s fix pass ignored `defaults: { 'imports:check': false }`** and rewrote every relative import anyway. Reported from a repo that deliberately keeps relative imports: one plain `shipkit ci` rewrote 215 files with no tsconfig paths wired, so typecheck and the tests went red on the gate's own repair. A fix slot repairs the same concern its floor task checks, so one disable governs both directions now.
13
+ - **A disabled fixer says so.** The fix pass had no skipped-by-config vocabulary and printed `ok: fix pass clean` whether a slot was off or merely had nothing to do, so a cold agent read that as "imports are canonical here". Disabled slots print `skip: <id> (skipped-by-config …)`, the same line the task side already prints one row above.
14
+
3
15
  ## 0.17.0
4
16
 
5
17
  **Correction to 0.16.0.** Its notes and its `--frozen` flag description claimed some combination of flags made `shipkit ci` write nothing. None does: STATUS.md is refreshed on every run unless a caller passes `status: false`. If you wired `ci --no-fix --frozen` into CI expecting a read-only run, it leaves STATUS.md behind, and `--json` is the only output that writes nothing. 0.16.0 also flipped rule SK005 from warning to BLOCKING without saying so: a `link:` dependency that no longer resolves, or a declared dependency that is not installed, now fails the gate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,6 +36,12 @@ export type CheckOpts = {
36
36
  * decides severity and the engine that runs the rules must read the same
37
37
  * object, or a blocking rule failure silently reclassifies as a probe */
38
38
  config?: RepoConfig
39
+ /** run only the checks that can BLOCK. A check declaring blocking:false
40
+ * cannot change the outcome, so running it is pure cost: a release does
41
+ * not need to know whether a DNS provider is reachable, and probing one
42
+ * spends a third-party API call per publish. Undeclared checks are still
43
+ * collected, because an unclassified check stays fatal. */
44
+ onlyBlocking?: boolean
39
45
  }
40
46
 
41
47
  export type CheckRun = {
@@ -78,13 +84,15 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
78
84
  t.failed = false
79
85
  t.warned = true
80
86
  }
81
- for (const rule of [...RULES, ...(c.rules ?? [])])
87
+ for (const rule of [...RULES, ...(c.rules ?? [])]) {
88
+ if (o.onlyBlocking === true && !rule.blocking) continue
82
89
  suite.addCheck({
83
90
  name: rule.title,
84
91
  remainValidFor: rule.ttl,
85
92
  cacheExtra: rule.cacheKey != null ? () => rule.cacheKey!({ root, config: c }) : undefined,
86
93
  logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
87
94
  })
95
+ }
88
96
  const childEnv: Record<string, string | undefined> = {
89
97
  ...process.env,
90
98
  ...(o.fresh === true ? { SHIPKIT_FRESH: '1', RV_CHECK_FRESH: '1' } : {}),
@@ -95,6 +103,7 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
95
103
  // check is an error, never a guess
96
104
  const blocking = d.blocking ?? c.checkDefaults?.blocking
97
105
  if (blocking == null) undeclared.push(rel)
106
+ if (o.onlyBlocking === true && blocking !== true) continue
98
107
  suite.addCheck({
99
108
  name: d.name,
100
109
  remainValidFor: d.selfCached ? undefined : DISCOVERED_TTL,
@@ -27,9 +27,11 @@ export type CiOpts = {
27
27
  fix?: boolean
28
28
  /** also apply the structural SK rule fixers during the fix pass */
29
29
  fixRules?: boolean
30
- /** bust every check's TTL cache. A release gates with this on: a blocking
31
- * rule replaying a clean pass from hours ago is not a gate. */
32
- fresh?: boolean
30
+ /** run only the checks that can block (core/run-check onlyBlocking) */
31
+ onlyBlockingChecks?: boolean
32
+ /** task ids the caller does not need. A sub-unit release skips the tasks
33
+ * that gate the whole repo, since the unit's own gate already ran. */
34
+ skipTaskIds?: string[]
33
35
  /** report-only for self-maintaining rules and checks (SHIPKIT_FROZEN
34
36
  * travels to child checks). The release path passes it: a rule that
35
37
  * rewrites package.json between the gate and `npm publish` packs a tree
@@ -76,7 +78,11 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
76
78
  if (o.fix !== false)
77
79
  fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, print: sink })).failed
78
80
  else sink('skip: fix pass (--no-fix)')
79
- const { tasks, disabled, unfilled } = allTasks(c, root)
81
+ const all = allTasks(c, root)
82
+ const { disabled, unfilled } = all
83
+ const skipIds = new Set(o.skipTaskIds ?? [])
84
+ const tasks = all.tasks.filter((t) => !skipIds.has(t.id))
85
+ for (const t of all.tasks) if (skipIds.has(t.id)) sink(`skip: ${t.id} (not part of this release, the unit's own gate covers it)`)
80
86
  for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
81
87
  // a floor slot nothing could fill is VISIBLE — a thin gate must never
82
88
  // look like a full one (the fresh-repo floor gap)
@@ -96,7 +102,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
96
102
  // declares whether it blocks, and that is the only question about it.
97
103
  const checks = o.skipChecks === true
98
104
  ? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
99
- : await collectChecks(root, { status: false, config: c, frozen: o.frozen, fresh: o.fresh })
105
+ : await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks })
100
106
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
101
107
  for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
102
108
  for (const u of checks.undeclared) {
@@ -27,13 +27,21 @@ export type FixerResolution = {
27
27
  slots: FixerSlot[]
28
28
  /** slots nothing could fill, printed, never silent */
29
29
  unfilled: { id: string; reason: string }[]
30
+ /** slots the repo turned off, printed with the same vocabulary the task
31
+ * side uses, so a disable is never invisible in the output */
32
+ disabled: { id: string; reason: string }[]
30
33
  }
31
34
 
35
+ // a fix slot repairs the same concern a floor task checks, so ONE disable
36
+ // governs both. Turning a check off and having the fixer rewrite the files
37
+ // anyway is the config saying one thing and the tool doing another.
38
+ const GOVERNED_BY: Record<string, string> = { 'imports:fix': 'imports:check' }
39
+
32
40
  // one fixer per mechanical concern. `fmt` accepts the two names the house
33
41
  // vocabulary allows so a repo that calls it `format` is not silently unfixed.
34
42
  const FMT_SCRIPTS = ['fmt', 'format'] as const
35
43
 
36
- export function resolveFixers(root: string): FixerResolution {
44
+ export function resolveFixers(root: string, config?: RepoConfig): FixerResolution {
37
45
  const pkgPath = join(root, 'package.json')
38
46
  let scripts: Record<string, string> = {}
39
47
  if (existsSync(pkgPath)) {
@@ -42,19 +50,28 @@ export function resolveFixers(root: string): FixerResolution {
42
50
  }
43
51
  const slots: FixerSlot[] = []
44
52
  const unfilled: { id: string; reason: string }[] = []
53
+ const disabled: { id: string; reason: string }[] = []
54
+ const offByConfig = (id: string): boolean => {
55
+ const governing = GOVERNED_BY[id]
56
+ if (governing == null) return false
57
+ if (config?.defaults?.[governing] !== false) return false
58
+ disabled.push({ id, reason: `skipped-by-config (defaults: { '${governing}': false })` })
59
+ return true
60
+ }
45
61
 
46
62
  const fmtName = FMT_SCRIPTS.find((n) => scripts[n] != null)
47
63
  if (fmtName != null) slots.push({ id: 'fmt', cmd: ['bun', 'run', fmtName], source: 'package.json script' })
48
64
  else unfilled.push({ id: 'fmt', reason: `no 'fmt' or 'format' script — formatting stays a manual step` })
49
65
 
50
- if (scripts['imports:fix'] != null) slots.push({ id: 'imports:fix', cmd: ['bun', 'run', 'imports:fix'], source: 'package.json script' })
66
+ if (offByConfig('imports:fix')) { /* the repo said no, in the one place that governs both directions */ }
67
+ else if (scripts['imports:fix'] != null) slots.push({ id: 'imports:fix', cmd: ['bun', 'run', 'imports:fix'], source: 'package.json script' })
51
68
  else {
52
69
  let cli: string | null = null
53
70
  try { cli = fileURLToPath(import.meta.resolve('rvlib-ts-tools/src/cli.ts')) } catch { cli = null }
54
71
  if (cli == null) unfilled.push({ id: 'imports:fix', reason: `no 'imports:fix' script and rvlib-ts-tools is unresolvable` })
55
72
  else slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (rvlib-ts-tools)' })
56
73
  }
57
- return { slots, unfilled }
74
+ return { slots, unfilled, disabled }
58
75
  }
59
76
 
60
77
  /** worktree fingerprint: every dirty path mapped to `<status>:<content hash>`,
@@ -103,8 +120,9 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
103
120
  // verify mode: same fixers, but rewriting anything means the COMMIT was
104
121
  // not clean, and the gate says so instead of quietly repairing it
105
122
  const verify = process.env.CI != null && process.env.CI !== ''
106
- const { slots, unfilled } = resolveFixers(root)
123
+ const { slots, unfilled, disabled } = resolveFixers(root, config)
107
124
  for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
125
+ for (const d of disabled) o.print(`skip: ${d.id} (${d.reason})`)
108
126
  const before = treeState(root)
109
127
 
110
128
  for (const s of slots) {
@@ -39,6 +39,11 @@ export type ReleaseConfig = {
39
39
  /** repo-relative tsconfig for the ROOT unit's own typecheck (default
40
40
  * '<root>/tsconfig.json' when it exists) */
41
41
  tsconfig?: string
42
+ /** task ids the release gate skips. A monorepo's repo-wide task (every
43
+ * package's suite, a full matrix build) re-proves for six units what the
44
+ * released unit's own gate already proved for one. Naming them here is
45
+ * the repo saying which tasks are about the REPO rather than the release. */
46
+ skipTasks?: string[]
42
47
  /** `shipkit bump` run from a feature branch merges it into the release
43
48
  * branch first, with a merge commit. Default true. A branch is a GATE, so
44
49
  * this is deliberately reachable ONLY by running bump by hand. */
@@ -92,11 +97,13 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
92
97
  fix: o.fix ?? false,
93
98
  frozen: true,
94
99
  status: false,
95
- // a release must not gate on a clean pass cached hours ago: SK005's
96
- // blocking branches are local facts (a link: that stopped resolving, a
97
- // dep nobody installed) and a TTL replays them as green
98
- fresh: true,
99
100
  config,
101
+ // a check that cannot block cannot change the outcome, so a release
102
+ // never probes the world: publishing a lib does not depend on whether
103
+ // a DNS provider answers, and asking spends an API call per publish.
104
+ // requireHealthy is the repo saying it wants them fatal, so then they run.
105
+ onlyBlockingChecks: config.release?.requireHealthy !== true,
106
+ skipTaskIds: config.release?.skipTasks ?? [],
100
107
  failOnAdvisory: config.release?.requireHealthy === true,
101
108
  print: (l) => print(` ${l}`),
102
109
  })