rvlib-shipkit 0.16.0 → 0.17.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 +12 -0
- package/package.json +1 -1
- package/src/cli/cmd_Bump.ts +15 -6
- package/src/cli/cmd_Ci.ts +2 -2
- package/src/core/run-ci.ts +4 -1
- package/src/hooks/root-identity.ts +64 -0
- package/src/hooks/run-hooks.ts +19 -1
- package/src/release/changelog.ts +18 -1
- package/src/release/run-bump.ts +28 -11
- package/src/release/run-release.ts +4 -0
- package/src/rules/SK004-gitignore-policy.ts +3 -0
- package/src/rules/SK010-undeclared-release-unit.ts +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0
|
|
4
|
+
|
|
5
|
+
**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.
|
|
6
|
+
|
|
7
|
+
- **`bump --all` takes a kind per unit.** Write `## Unreleased (minor)` on a unit's changelog heading and a batch release gives each unit the kind it actually needs, in ONE gate. A unit whose heading declares nothing refuses rather than being guessed into a version number. Passing a kind on the command line still applies it to the whole selection, and promoting strips the marker. `shipkit bump <unit>` with no kind works the same way.
|
|
8
|
+
- **`notReleasable` can no longer cover a declared release unit.** An entry naming a container that holds one excused every future package under it while the rule reported green. The two lists state opposite intents, so overlap fails.
|
|
9
|
+
- **A release gates fresh.** SK005 caches a clean pass for a day to rate-limit its registry call, but its blocking branches are local facts about whether the tree can import its own dependencies. Those are measured at release time now, not replayed.
|
|
10
|
+
- **The bump rehearsal reads `git fetch`.** Offline or with an expired credential it could compare against stale remote refs and still report a clean merge. It warns and closes inconclusive instead.
|
|
11
|
+
- New pre-commit gate **root-identity**: the name in the root `package.json` and `repo.config.ts`, index against HEAD, plus staged `.bak`/`.orig`/`.rej`. A fixture builder that escapes its temp dir overwrites those files and every other gate still passes, because a gutted repo lints fine. A deliberate rename passes with `SHIPKIT_ALLOW_IDENTITY_CHANGE=1`.
|
|
12
|
+
- `.rv-private/` joins the SK004 gitignore policy. It holds the leak guard's own keyword file, which was committable in every repo until now.
|
|
13
|
+
- `ci --frozen` exists as a flag, and no TUI panel writes STATUS.md any more, so opening the dashboard cannot overwrite a full gate's verdict with a tasks-only one.
|
|
14
|
+
|
|
3
15
|
## 0.16.0
|
|
4
16
|
|
|
5
17
|
Upgrading from 0.14.0 lands straight here: 0.15.0 was stamped and never
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
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/cli/cmd_Bump.ts
CHANGED
|
@@ -15,32 +15,41 @@ export class BumpCommand extends Command {
|
|
|
15
15
|
['a feature release', 'shipkit bump minor'],
|
|
16
16
|
['a breaking release', 'shipkit bump major'],
|
|
17
17
|
['one sub-unit (ids: shipkit releases)', 'shipkit bump minor rvlib-shipkit'],
|
|
18
|
-
['
|
|
18
|
+
['a whole mixed batch in ONE gate, each unit at the kind its heading declares', 'shipkit bump --all'],
|
|
19
|
+
['every unit that has Unreleased notes, all at the same kind', 'shipkit bump patch --all'],
|
|
20
|
+
['one unit, kind from its own heading', 'shipkit bump rvlib-shipkit'],
|
|
19
21
|
['rehearse the whole thing', 'shipkit bump minor --dry-run'],
|
|
20
22
|
['bump and commit, release later', 'shipkit bump patch --no-release'],
|
|
21
23
|
['from a feature branch, refuse rather than merge', 'shipkit bump patch --no-merge'],
|
|
22
24
|
],
|
|
23
25
|
})
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
// both positionals are optional so `bump --all` can read each unit's own
|
|
28
|
+
// kind. The kinds are a closed set of three words, so a first positional
|
|
29
|
+
// that is not one of them is a unit id, never an ambiguity
|
|
30
|
+
kind = Option.String({ required: false })
|
|
26
31
|
unit = Option.String({ required: false })
|
|
27
32
|
dryRun = Option.Boolean('--dry-run', false, { description: 'rehearse: run every preflight, the gate and the prepublish scripts, then stop before any version is written. the gate can rewrite files, and the merge is probed rather than performed, so the release branch never moves' })
|
|
28
33
|
noRelease = Option.Boolean('--no-release', false, { description: 'stop after the version commit, do not tag or publish' })
|
|
29
34
|
noMerge = Option.Boolean('--no-merge', false, { description: 'refuse on a feature branch instead of merging it into the release branch' })
|
|
30
|
-
all = Option.Boolean('--all', false, { description: 'bump every unit whose changelog carries Unreleased notes, in declared order, root last.
|
|
35
|
+
all = Option.Boolean('--all', false, { description: 'bump every unit whose changelog carries Unreleased notes, in declared order, root last. Given a kind it applies to ALL of them. Given none, each unit takes the kind from its own "## Unreleased (minor)" heading, and a unit whose heading is silent refuses' })
|
|
31
36
|
|
|
32
37
|
async execute(): Promise<number> {
|
|
33
|
-
|
|
38
|
+
const KINDS = ['major', 'minor', 'patch']
|
|
39
|
+
// `bump rvlib-shipkit` names a unit and leaves the kind to its heading
|
|
40
|
+
const kind = this.kind != null && KINDS.includes(this.kind) ? (this.kind as BumpKind) : null
|
|
41
|
+
const unit = kind == null && this.kind != null ? this.kind : this.unit
|
|
42
|
+
if (kind == null && this.kind != null && this.unit != null) {
|
|
34
43
|
this.context.stderr.write(`FAIL: bump takes major, minor or patch (got '${this.kind}')\n`)
|
|
35
44
|
return 1
|
|
36
45
|
}
|
|
37
46
|
try {
|
|
38
47
|
const config = await loadRepoConfig(process.cwd())
|
|
39
|
-
return await runBump(process.cwd(), config,
|
|
48
|
+
return await runBump(process.cwd(), config, kind, {
|
|
40
49
|
dryRun: this.dryRun,
|
|
41
50
|
noRelease: this.noRelease,
|
|
42
51
|
noMerge: this.noMerge,
|
|
43
|
-
unit
|
|
52
|
+
unit,
|
|
44
53
|
all: this.all,
|
|
45
54
|
print: (l) => this.context.stdout.write(`${l}\n`),
|
|
46
55
|
})
|
package/src/cli/cmd_Ci.ts
CHANGED
|
@@ -21,9 +21,9 @@ export class CiCommand extends Command {
|
|
|
21
21
|
|
|
22
22
|
bail = Option.Boolean('--bail', false, { description: 'stop at the first failure' })
|
|
23
23
|
json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
|
|
24
|
-
fix = Option.Boolean('--fix', true, { description: 'repair the mechanical slots before gating. Default on. --no-fix skips the repair pass, checks can still write
|
|
24
|
+
fix = Option.Boolean('--fix', true, { description: 'repair the mechanical slots before gating. Default on. --no-fix skips the repair pass, checks can still write. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
|
|
25
25
|
fixRules = Option.Boolean('--fix-rules', true, { description: 'apply the structural rule autofixes too. Default on. --no-fix-rules leaves repo config alone' })
|
|
26
|
-
frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining rules (SK005 never touches package.json).
|
|
26
|
+
frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining rules (SK005 never touches package.json). STATUS.md is still refreshed, so no combination of flags makes the run write nothing' })
|
|
27
27
|
skip = Option.Array('--skip', [], { description: 'skip a task group (repeatable)' })
|
|
28
28
|
withIds = Option.Array('--with', [], { description: 'include an opt-in task (repeatable)' })
|
|
29
29
|
only = Option.Array('--only', [], { description: 'run only this task + its dep closure (repeatable)' })
|
package/src/core/run-ci.ts
CHANGED
|
@@ -27,6 +27,9 @@ 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
33
|
/** report-only for self-maintaining rules and checks (SHIPKIT_FROZEN
|
|
31
34
|
* travels to child checks). The release path passes it: a rule that
|
|
32
35
|
* rewrites package.json between the gate and `npm publish` packs a tree
|
|
@@ -93,7 +96,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
93
96
|
// declares whether it blocks, and that is the only question about it.
|
|
94
97
|
const checks = o.skipChecks === true
|
|
95
98
|
? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
|
|
96
|
-
: await collectChecks(root, { status: false, config: c, frozen: o.frozen })
|
|
99
|
+
: await collectChecks(root, { status: false, config: c, frozen: o.frozen, fresh: o.fresh })
|
|
97
100
|
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
98
101
|
for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
|
|
99
102
|
for (const u of checks.undeclared) {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// a commit must not silently change WHICH REPO this is.
|
|
2
|
+
//
|
|
3
|
+
// the failure this exists for: a fixture builder ran with its cwd on the real
|
|
4
|
+
// repo instead of a temp dir, overwrote the root package.json and
|
|
5
|
+
// repo.config.ts with a throwaway shape, and committed. Every other gate
|
|
6
|
+
// passed, because a gutted repo still lints, still typechecks and still has a
|
|
7
|
+
// valid package.json. Only the NAME betrayed it.
|
|
8
|
+
import { existsSync } from 'fs'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
|
|
11
|
+
export type IdentityOutcome =
|
|
12
|
+
| { status: 'skipped'; reason: string }
|
|
13
|
+
| { status: 'clean' }
|
|
14
|
+
| { status: 'changed'; changes: string[]; scratch: string[] }
|
|
15
|
+
|
|
16
|
+
/** scratch files a refactor leaves behind. They are never a deliberate commit,
|
|
17
|
+
* and one of them rode in with the fixture accident. */
|
|
18
|
+
const SCRATCH = /\.(bak|orig|rej)$/
|
|
19
|
+
|
|
20
|
+
const git = (root: string, args: string[]): { code: number; out: string } => {
|
|
21
|
+
const p = Bun.spawnSync(['git', ...args], { cwd: root, env: process.env })
|
|
22
|
+
return { code: p.exitCode, out: p.stdout.toString() }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** the `name:` a repo.config.ts declares, read textually: importing it would
|
|
26
|
+
* run the file, and the staged version is exactly the one not to trust. */
|
|
27
|
+
export function configName(source: string): string | null {
|
|
28
|
+
return source.match(/\bname\s*:\s*['"`]([^'"`]+)['"`]/)?.[1] ?? null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function packageName(source: string): string | null {
|
|
32
|
+
try { return (JSON.parse(source) as { name?: string }).name ?? null }
|
|
33
|
+
catch { return null }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** compare the INDEX against HEAD: what is about to be committed, versus what
|
|
37
|
+
* the repo already agreed it is. Absent on either side is not a change, so
|
|
38
|
+
* adding or removing a file is left to the rules that own that. */
|
|
39
|
+
function identityDrift(root: string, path: string, read: (s: string) => string | null): string | null {
|
|
40
|
+
const staged = git(root, ['show', `:${path}`])
|
|
41
|
+
const head = git(root, ['show', `HEAD:${path}`])
|
|
42
|
+
if (staged.code !== 0 || head.code !== 0) return null
|
|
43
|
+
const before = read(head.out)
|
|
44
|
+
const after = read(staged.out)
|
|
45
|
+
if (before == null || after == null || before === after) return null
|
|
46
|
+
return `${path}: name '${before}' → '${after}'`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function runRootIdentity(root: string): IdentityOutcome {
|
|
50
|
+
if (git(root, ['rev-parse', '--verify', 'HEAD']).code !== 0)
|
|
51
|
+
return { status: 'skipped', reason: 'no HEAD yet — nothing to compare the first commit against' }
|
|
52
|
+
if (process.env['SHIPKIT_ALLOW_IDENTITY_CHANGE'] === '1')
|
|
53
|
+
return { status: 'skipped', reason: 'SHIPKIT_ALLOW_IDENTITY_CHANGE=1' }
|
|
54
|
+
|
|
55
|
+
const changes = [
|
|
56
|
+
identityDrift(root, 'package.json', packageName),
|
|
57
|
+
...(existsSync(join(root, 'repo.config.ts')) ? [identityDrift(root, 'repo.config.ts', configName)] : []),
|
|
58
|
+
].filter((c): c is string => c != null)
|
|
59
|
+
|
|
60
|
+
const added = git(root, ['diff', '--cached', '--name-only', '--diff-filter=A'])
|
|
61
|
+
const scratch = added.out.split('\n').map((l) => l.trim()).filter((l) => l !== '' && SCRATCH.test(l))
|
|
62
|
+
|
|
63
|
+
return changes.length === 0 && scratch.length === 0 ? { status: 'clean' } : { status: 'changed', changes, scratch }
|
|
64
|
+
}
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
|
7
7
|
import { join } from 'path'
|
|
8
8
|
import { commitMessageOffenses } from './commit-message.ts'
|
|
9
9
|
import { runLeakGuard } from './leak-guard.ts'
|
|
10
|
+
import { runRootIdentity } from './root-identity.ts'
|
|
10
11
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
11
12
|
import type { RepoConfig } from '../repo-config.ts'
|
|
12
13
|
|
|
@@ -20,7 +21,7 @@ export type HooksConfig = {
|
|
|
20
21
|
/** keyword file for the leak guard (default '.rv-private/banned-keywords.txt') */
|
|
21
22
|
keywordFile?: string
|
|
22
23
|
/** disable a built-in gate, visible in `hooks list`, never silent */
|
|
23
|
-
disable?: ('leak-guard' | 'commit-message')[]
|
|
24
|
+
disable?: ('leak-guard' | 'commit-message' | 'root-identity')[]
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const HOOK_DIR = 'hooks'
|
|
@@ -66,6 +67,22 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
66
67
|
],
|
|
67
68
|
})
|
|
68
69
|
}
|
|
70
|
+
if (!(h.disable ?? []).includes('root-identity')) {
|
|
71
|
+
const r = runRootIdentity(root)
|
|
72
|
+
if (r.status === 'skipped') out.push({ id: 'root-identity', ok: true, lines: [`skip: root-identity (${r.reason})`] })
|
|
73
|
+
else if (r.status === 'clean') out.push({ id: 'root-identity', ok: true, lines: ['ok: root-identity unchanged'] })
|
|
74
|
+
else out.push({
|
|
75
|
+
id: 'root-identity',
|
|
76
|
+
ok: false,
|
|
77
|
+
lines: [
|
|
78
|
+
`FAIL: this commit changes WHICH REPO this is, which is almost always a fixture that escaped its temp dir`,
|
|
79
|
+
...r.changes.map((c) => ` ${c}`),
|
|
80
|
+
...r.scratch.map((s) => ` adds a scratch file: ${s}`),
|
|
81
|
+
` fix: restore the real files (git checkout HEAD -- package.json repo.config.ts) and drop the scratch files`,
|
|
82
|
+
` a deliberate rename: SHIPKIT_ALLOW_IDENTITY_CHANGE=1 git commit ...`,
|
|
83
|
+
],
|
|
84
|
+
})
|
|
85
|
+
}
|
|
69
86
|
for (const c of h.preCommit ?? []) {
|
|
70
87
|
const r = await spawnWithTimeout(c.cmd, { cwd: root, timeoutMs: 120_000 })
|
|
71
88
|
out.push({
|
|
@@ -123,6 +140,7 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
|
123
140
|
const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) ? '' : ` (INACTIVE, ${kw} absent)`
|
|
124
141
|
return [
|
|
125
142
|
`pre-commit leak-guard${leakState} — staged content + paths vs ${kw}`,
|
|
143
|
+
`pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
126
144
|
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
|
127
145
|
`commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution`,
|
|
128
146
|
`commit-msg leak-guard${leakState} — the message text`,
|
package/src/release/changelog.ts
CHANGED
|
@@ -28,11 +28,28 @@ export function extractChangelogSection(p: { changelog: string; version: string
|
|
|
28
28
|
export const hasVersionSection = (changelog: string, version: string): boolean =>
|
|
29
29
|
changelog.split('\n').some((l) => changelogHeading(version).test(l.trim()))
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// the heading may carry the kind this release IS: `## Unreleased (minor)`.
|
|
32
|
+
// Whoever writes the notes knows whether they broke something, and that is
|
|
33
|
+
// the only moment the answer is cheap. A batch release then needs no single
|
|
34
|
+
// kind imposed across units that changed by different amounts.
|
|
35
|
+
const UNRELEASED = /^##\s*\[?unreleased\]?\s*(?:\((major|minor|patch)\))?\s*$/i
|
|
36
|
+
|
|
37
|
+
export type ReleaseKind = 'major' | 'minor' | 'patch'
|
|
32
38
|
|
|
33
39
|
export const hasUnreleasedHeading = (changelog: string): boolean =>
|
|
34
40
|
changelog.split('\n').some((l) => UNRELEASED.test(l.trim()))
|
|
35
41
|
|
|
42
|
+
/** the kind declared on the Unreleased heading, or null when it says nothing.
|
|
43
|
+
* Null is never guessed into a kind: `--all` refuses and names the unit,
|
|
44
|
+
* because a wrong version number reaches a registry and cannot be recalled. */
|
|
45
|
+
export function declaredKind(changelog: string): ReleaseKind | null {
|
|
46
|
+
for (const l of changelog.split('\n')) {
|
|
47
|
+
const m = l.trim().match(UNRELEASED)
|
|
48
|
+
if (m != null) return (m[1]?.toLowerCase() as ReleaseKind | undefined) ?? null
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
/** rename the Unreleased heading to the new version. Returns the new text, or
|
|
37
54
|
* an error when there is no such section or it carries no notes: a version
|
|
38
55
|
* with empty notes is a version nobody can read later. */
|
package/src/release/run-bump.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// that cannot roll back is a lie.
|
|
29
29
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
30
30
|
import { relative } from 'path'
|
|
31
|
-
import { hasUnreleasedNotes, promoteUnreleased } from './changelog.ts'
|
|
31
|
+
import { declaredKind, hasUnreleasedNotes, promoteUnreleased } from './changelog.ts'
|
|
32
32
|
import { deriveRemote, dirtyPaths, git as gitAt, mergeProbe, remoteTags, tagExistsLocally } from './git-facts.ts'
|
|
33
33
|
import { unitBlockers } from './unit-gate.ts'
|
|
34
34
|
import { formatUnitError, releaseOrder, renderTag, resolveUnits, unitById, unitPrepublishScripts, type ReleaseUnit } from './units.ts'
|
|
@@ -145,7 +145,12 @@ function probeMergeIntoReleaseBranch(
|
|
|
145
145
|
p: { from: string; to: string; remote: string; print: (l: string) => void },
|
|
146
146
|
): MergeRehearsal {
|
|
147
147
|
const { from, to, remote, print } = p
|
|
148
|
-
|
|
148
|
+
// a rehearsal's ONLY product is this answer, so it must not be computed
|
|
149
|
+
// from a remote ref that never refreshed. Offline, an expired credential
|
|
150
|
+
// or a declined prompt all land here.
|
|
151
|
+
const fetched = git(['fetch', remote, '--quiet'])
|
|
152
|
+
const stale = fetched.code !== 0
|
|
153
|
+
if (stale) print(`WARN could not fetch '${remote}' (git fetch exited ${fetched.code}) — the remote refs below may be stale`)
|
|
149
154
|
const remoteRef = `${remote}/${to}`
|
|
150
155
|
const localExists = git(['rev-parse', '--quiet', '--verify', to]).code === 0
|
|
151
156
|
const remoteExists = git(['rev-parse', '--quiet', '--verify', remoteRef]).code === 0
|
|
@@ -173,12 +178,14 @@ function probeMergeIntoReleaseBranch(
|
|
|
173
178
|
return { refuse: false, inconclusive: true }
|
|
174
179
|
}
|
|
175
180
|
print(`note: '${from}' merges cleanly into '${to}' (checked in the object database, no ref moved)`)
|
|
176
|
-
|
|
181
|
+
// a clean answer computed from refs that never refreshed proves less than
|
|
182
|
+
// it says, so the rehearsal declines to close clean
|
|
183
|
+
return { refuse: false, inconclusive: stale }
|
|
177
184
|
}
|
|
178
185
|
|
|
179
|
-
type Selected = { unit: ReleaseUnit; version: string; changelogRel: string }
|
|
186
|
+
type Selected = { unit: ReleaseUnit; version: string; changelogRel: string; kind: BumpKind }
|
|
180
187
|
|
|
181
|
-
export async function runBump(root: string, config: RepoConfig, kind: BumpKind, o: BumpOpts = {}): Promise<number> {
|
|
188
|
+
export async function runBump(root: string, config: RepoConfig, kind: BumpKind | null, o: BumpOpts = {}): Promise<number> {
|
|
182
189
|
const print = o.print ?? ((l: string) => console.log(l))
|
|
183
190
|
if (o.all === true && o.unit != null) { print('FAIL: pass a unit or --all, never both'); return 1 }
|
|
184
191
|
|
|
@@ -283,12 +290,22 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind,
|
|
|
283
290
|
const blockers: string[] = []
|
|
284
291
|
const selected: Selected[] = []
|
|
285
292
|
for (const unit of picked) {
|
|
286
|
-
let version: string
|
|
287
|
-
try { version = nextVersion(unit.version, kind) }
|
|
288
|
-
catch (e) { blockers.push(`unit '${unit.id}': ${e instanceof Error ? e.message : String(e)}`); continue }
|
|
289
293
|
const changelogRel = relative(root, unit.changelog)
|
|
290
294
|
if (!existsSync(unit.changelog)) { blockers.push(`unit '${unit.id}': ${changelogRel} not found — a release states what changed`); continue }
|
|
291
|
-
const
|
|
295
|
+
const source = readFileSync(unit.changelog, 'utf8')
|
|
296
|
+
// an explicit kind on the command line wins for the whole selection.
|
|
297
|
+
// Without one, each unit answers for itself from its own heading, and
|
|
298
|
+
// a unit that never said is a refusal: guessing a version number
|
|
299
|
+
// publishes it, and a published number cannot be recalled.
|
|
300
|
+
const unitKind = kind ?? declaredKind(source)
|
|
301
|
+
if (unitKind == null) {
|
|
302
|
+
blockers.push(`unit '${unit.id}': ${changelogRel} does not say what kind of release this is — write "## Unreleased (minor)", or pass a kind that applies to every selected unit`)
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
let version: string
|
|
306
|
+
try { version = nextVersion(unit.version, unitKind) }
|
|
307
|
+
catch (e) { blockers.push(`unit '${unit.id}': ${e instanceof Error ? e.message : String(e)}`); continue }
|
|
308
|
+
const promoted = promoteUnreleased(source, version)
|
|
292
309
|
if ('error' in promoted) { blockers.push(`unit '${unit.id}': ${promoted.error}`); continue }
|
|
293
310
|
const tag = renderTag(unit, version)
|
|
294
311
|
if (tagExistsLocally(root, tag)) {
|
|
@@ -300,7 +317,7 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind,
|
|
|
300
317
|
continue
|
|
301
318
|
}
|
|
302
319
|
blockers.push(...unitBlockers(root, config, unit))
|
|
303
|
-
selected.push({ unit, version, changelogRel })
|
|
320
|
+
selected.push({ unit, version, changelogRel, kind: unitKind })
|
|
304
321
|
}
|
|
305
322
|
// everything the release needs that does not depend on the new version.
|
|
306
323
|
// HEAD is the release branch by now, so the branch check applies — except
|
|
@@ -312,7 +329,7 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind,
|
|
|
312
329
|
}))
|
|
313
330
|
if (blockers.length > 0) { for (const b of blockers) print(`FAIL: ${b}`); rollBack(); return 1 }
|
|
314
331
|
|
|
315
|
-
for (const s of selected) print(`note: ${s.unit.id} ${s.unit.version} → ${s.version} (${kind})`)
|
|
332
|
+
for (const s of selected) print(`note: ${s.unit.id} ${s.unit.version} → ${s.version} (${s.kind})`)
|
|
316
333
|
|
|
317
334
|
// phase 2: ONE gate for the whole repo. The repo is one worktree, so N
|
|
318
335
|
// gates cost N times as much and prove the same thing. Frozen, and fatal
|
|
@@ -92,6 +92,10 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
|
|
|
92
92
|
fix: o.fix ?? false,
|
|
93
93
|
frozen: true,
|
|
94
94
|
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,
|
|
95
99
|
config,
|
|
96
100
|
failOnAdvisory: config.release?.requireHealthy === true,
|
|
97
101
|
print: (l) => print(` ${l}`),
|
|
@@ -18,6 +18,9 @@ const END = '# <<< shipkit gitignore policy'
|
|
|
18
18
|
const CONTRIB = '# repo-contributed lines (repo.config.ts gitignore:)'
|
|
19
19
|
|
|
20
20
|
const POLICY_LINES = [
|
|
21
|
+
// the leak guard reads .rv-private/banned-keywords.txt: the file listing
|
|
22
|
+
// what must never be committed cannot itself be committable
|
|
23
|
+
'.rv-private/',
|
|
21
24
|
'.rv-reflections/',
|
|
22
25
|
'.rv-social/',
|
|
23
26
|
'.rv-journal/',
|
|
@@ -98,7 +98,18 @@ export const undeclaredReleaseUnit: Rule = {
|
|
|
98
98
|
return
|
|
99
99
|
}
|
|
100
100
|
const excused = decls.map((d) => join(env.root, d))
|
|
101
|
-
const
|
|
101
|
+
const declaredDirs = units.filter((u) => u.rel !== '').map((u) => u.dir)
|
|
102
|
+
// an excuse that also covers a RELEASE UNIT is the kill switch one
|
|
103
|
+
// level down: `notReleasable: ['libs']` beside `units: [libs/a]` reads
|
|
104
|
+
// as green while every new package under libs/ hides. The two lists
|
|
105
|
+
// describe opposite intents, so they must not overlap.
|
|
106
|
+
const overlapping = decls.filter((d) => declaredDirs.some((u) => u === join(env.root, d) || u.startsWith(`${join(env.root, d)}/`)))
|
|
107
|
+
if (overlapping.length > 0) {
|
|
108
|
+
for (const d of overlapping)
|
|
109
|
+
t.fail(`notReleasable '${d}' also covers a declared release unit, so it excuses every package under it while reading as green`, `repo.config notReleasable: name the dirs that are NOT release units, never a container holding one`)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
const claimed = [...declaredDirs, ...excused]
|
|
102
113
|
const orphans = pkgs.filter((d) => !covered(claimed, d))
|
|
103
114
|
if (orphans.length === 0) {
|
|
104
115
|
// PACKAGES, not entries: one entry over a nested tree excuses more
|