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 +12 -0
- package/package.json +1 -1
- package/src/core/run-check.ts +10 -1
- package/src/core/run-ci.ts +11 -5
- package/src/core/run-fixers.ts +22 -4
- package/src/release/run-release.ts +11 -4
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.
|
|
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",
|
package/src/core/run-check.ts
CHANGED
|
@@ -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,
|
package/src/core/run-ci.ts
CHANGED
|
@@ -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
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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,
|
|
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) {
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -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 (
|
|
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
|
})
|