@natjswenson/shipflow 0.3.3 → 0.6.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.
@@ -0,0 +1,948 @@
1
+ // Component-scoped release engine: resolve a named component, read what is
2
+ // actually on main, propose a bump, write it, drive it to main, and prove the
3
+ // tag exists.
4
+ //
5
+ // Everything here is deterministic and mechanical on purpose. The two things
6
+ // this module deliberately does NOT decide are which bump to take and what the
7
+ // CHANGELOG says — those are judgment, and they belong to the caller (the
8
+ // `release` skill), not to a script.
9
+ //
10
+ // Three commands sit on top of this, in strictly increasing danger:
11
+ // readStatus() — read-only, no network writes, no local writes
12
+ // prepare() — local writes only, in a THROWAWAY WORKTREE (see below)
13
+ // cut() — the only irreversible one, gated on a status hash
14
+ //
15
+ // Why a throwaway worktree: `prepare` has to branch off dev and commit, and a
16
+ // real repo's working tree routinely has unrelated in-flight work in it (this
17
+ // monorepo's own tree did while this was written). Checking out a branch under
18
+ // that, or staging from it, is how another session's uncommitted work gets
19
+ // swept into a release commit. A `git worktree` is a clean, isolated checkout
20
+ // of dev that cannot see the user's dirt at all, so there is nothing to sweep.
21
+
22
+ import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
23
+ import { join, resolve, sep } from 'node:path';
24
+ import { tmpdir } from 'node:os';
25
+ import { readFileCapped, spawnArgs, git, ghApiJson, sha256 } from './gh.mjs';
26
+
27
+ // ─── component names are a substitution token, so they are validated ─────────
28
+ // `{name}` is substituted into filesystem paths, a git tag pattern and a
29
+ // workflow filename. It comes from .github/shipflow.json, which anyone with
30
+ // repo WRITE access can edit — the same strictly-lower-trust input class that
31
+ // made renderTemplate's branch-name tokens a Critical finding in the
32
+ // 2026-07-15 Siege audit. An unvalidated name is a path traversal
33
+ // (`../../../../etc/passwd`) or a tag/ref injection, so it is validated once,
34
+ // here, before any substitution happens anywhere.
35
+ //
36
+ // Dots are allowed because real repos are named `natejswenson.io` and `1.00s`
37
+ // and a single-component repo infers its component name from the repo
38
+ // directory. `..` in any position is rejected separately — the charset alone
39
+ // would happily admit `a..b`.
40
+ const COMPONENT_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
41
+ const MAX_COMPONENT_NAME_LENGTH = 64;
42
+
43
+ export function validateComponentName(name) {
44
+ if (typeof name !== 'string' || name.length === 0) return { ok: false, error: 'component name must be a non-empty string' };
45
+ if (name.length > MAX_COMPONENT_NAME_LENGTH) {
46
+ return { ok: false, error: `component name exceeds ${MAX_COMPONENT_NAME_LENGTH} characters` };
47
+ }
48
+ if (!COMPONENT_NAME_RE.test(name)) {
49
+ return { ok: false, error: `component name "${name}" must match ${COMPONENT_NAME_RE} (lowercase letters, digits, dot, dash, underscore; leading alphanumeric)` };
50
+ }
51
+ if (name.includes('..')) {
52
+ return { ok: false, error: `component name "${name}" contains ".." — rejected as a path-traversal attempt` };
53
+ }
54
+ return { ok: true };
55
+ }
56
+
57
+ // Belt-and-suspenders on top of the name validator: every path this module
58
+ // resolves must land INSIDE the target repo. The name regex already makes
59
+ // traversal unreachable, but a hand-written componentLayout entry
60
+ // (`"changelog": "../../../etc/passwd"`) is a second, independent way in, and
61
+ // that field is not a token — it is copied verbatim from config.
62
+ function assertInsideRepo(repoPath, absolutePath, what) {
63
+ const root = resolve(repoPath);
64
+ const target = resolve(absolutePath);
65
+ if (target !== root && !target.startsWith(root + sep)) {
66
+ throw new Error(`refusing to resolve ${what} to ${target}: outside the repo at ${root}`);
67
+ }
68
+ return target;
69
+ }
70
+
71
+ // ─── layout ──────────────────────────────────────────────────────────────────
72
+ // A repo with no `release.componentLayout` gets this: one component, the repo
73
+ // root, versioned by package.json and tagged `v<version>`. That is what makes
74
+ // `release-status` work in budget / natejswenson.io with zero config.
75
+ const DEFAULT_ROOT_LAYOUT = Object.freeze({
76
+ versionFiles: ['package.json'],
77
+ changelog: 'CHANGELOG.md',
78
+ tagPattern: 'v{version}',
79
+ paths: ['.'],
80
+ workflowFile: 'release.yml',
81
+ });
82
+
83
+ function expandName(template, name) {
84
+ return String(template).replaceAll('{name}', name);
85
+ }
86
+
87
+ export function resolveLayout(config) {
88
+ const declared = config?.release?.componentLayout;
89
+ if (!declared) return { ...DEFAULT_ROOT_LAYOUT, inferred: true };
90
+ return {
91
+ versionFiles: declared.versionFiles ?? DEFAULT_ROOT_LAYOUT.versionFiles,
92
+ changelog: declared.changelog ?? DEFAULT_ROOT_LAYOUT.changelog,
93
+ tagPattern: declared.tagPattern ?? DEFAULT_ROOT_LAYOUT.tagPattern,
94
+ paths: declared.paths ?? DEFAULT_ROOT_LAYOUT.paths,
95
+ workflowFile: declared.workflowFile ?? DEFAULT_ROOT_LAYOUT.workflowFile,
96
+ inferred: false,
97
+ };
98
+ }
99
+
100
+ // Accepts `["devlog", {"name":"press"}]` — a bare string is the common case and
101
+ // a bare list of 12 strings reads far better in a config file than 12 objects.
102
+ export function listComponentNames(config, repoPath) {
103
+ const declared = config?.release?.components;
104
+ if (Array.isArray(declared) && declared.length > 0) {
105
+ return declared.map((entry) => (typeof entry === 'string' ? entry : entry?.name)).filter(Boolean);
106
+ }
107
+ // Inferred single component: named after the repo directory, so
108
+ // `release-status --component budget` works in ~/localrepo/budget.
109
+ return [resolve(repoPath).split(sep).pop()];
110
+ }
111
+
112
+ export function resolveComponent(repoPath, config, name) {
113
+ const valid = validateComponentName(name);
114
+ if (!valid.ok) throw new Error(valid.error);
115
+ const layout = resolveLayout(config);
116
+ const rel = (p) => expandName(p, name);
117
+ const versionFiles = layout.versionFiles.map(rel);
118
+ const changelog = rel(layout.changelog);
119
+ for (const f of [...versionFiles, changelog]) {
120
+ assertInsideRepo(repoPath, join(repoPath, f), `component file "${f}"`);
121
+ }
122
+ return {
123
+ name,
124
+ versionFiles,
125
+ changelog,
126
+ // {version} is deliberately left unexpanded here — it is filled per-version
127
+ // by tagFor()/tagGlob() below, since one component has many tags.
128
+ tagPattern: rel(layout.tagPattern),
129
+ paths: layout.paths.map(rel),
130
+ workflowFile: rel(layout.workflowFile),
131
+ inferredLayout: layout.inferred,
132
+ };
133
+ }
134
+
135
+ export const tagFor = (component, version) => component.tagPattern.replaceAll('{version}', version);
136
+ const tagGlob = (component) => component.tagPattern.replaceAll('{version}', '*');
137
+
138
+ // ─── semver ──────────────────────────────────────────────────────────────────
139
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
140
+
141
+ export function parseSemver(v) {
142
+ const m = SEMVER_RE.exec(String(v ?? '').trim());
143
+ if (!m) return null;
144
+ return { major: +m[1], minor: +m[2], patch: +m[3], prerelease: m[4] ?? null };
145
+ }
146
+
147
+ export function cmpSemver(a, b) {
148
+ const pa = parseSemver(a);
149
+ const pb = parseSemver(b);
150
+ if (!pa || !pb) return null;
151
+ for (const k of ['major', 'minor', 'patch']) {
152
+ if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
153
+ }
154
+ // A prerelease sorts BELOW its own release (1.0.0-rc.1 < 1.0.0). Finer
155
+ // prerelease ordering is deliberately not implemented — no skill in this
156
+ // house uses prerelease tags, and a half-right ordering is worse than an
157
+ // explicitly coarse one.
158
+ if (pa.prerelease && !pb.prerelease) return -1;
159
+ if (!pa.prerelease && pb.prerelease) return 1;
160
+ if (pa.prerelease !== pb.prerelease) return pa.prerelease < pb.prerelease ? -1 : 1;
161
+ return 0;
162
+ }
163
+
164
+ export function bumpSemver(version, kind) {
165
+ const p = parseSemver(version);
166
+ if (!p) return null;
167
+ if (kind === 'major') return `${p.major + 1}.0.0`;
168
+ if (kind === 'minor') return `${p.major}.${p.minor + 1}.0`;
169
+ return `${p.major}.${p.minor}.${p.patch + 1}`;
170
+ }
171
+
172
+ // ─── reading a version out of the files that carry it ────────────────────────
173
+ // Frontmatter-only, mirroring _release.yml's awk exactly: read `version:` ONLY
174
+ // from the YAML block between the first two `---` lines. A whole-file grep
175
+ // would happily match a `version:` inside a body code block, and SKILL.md
176
+ // bodies are full of YAML samples.
177
+ export function readFrontmatterVersion(text) {
178
+ const lines = text.split('\n');
179
+ if (lines[0]?.trim() !== '---') return null;
180
+ for (let i = 1; i < lines.length; i++) {
181
+ if (lines[i].trim() === '---') return null;
182
+ const m = /^version:\s*(.+?)\s*$/.exec(lines[i]);
183
+ if (m) return m[1].replace(/^["']|["']$/g, '');
184
+ }
185
+ return null;
186
+ }
187
+
188
+ // TOML (`version = "1.2.3"`) and YAML (`version: 1.2.3`) are matched only at
189
+ // column zero. Both formats nest — a pyproject.toml has `version` keys under
190
+ // `[tool.*]` tables and a project.yml has them under target definitions — and an
191
+ // indented match is some dependency's pin, not the project's own version.
192
+ // Deliberately not a real parser: shipflow has no dependencies, and the one
193
+ // line it needs is unambiguous when anchored.
194
+ const TOML_VERSION_RE = /^version\s*=\s*["']([^"'\n]+)["']/m;
195
+ const YAML_VERSION_RE = /^version:\s*["']?([^"'\n#]+?)["']?\s*(?:#.*)?$/m;
196
+
197
+ function versionFromSource(relPath, text) {
198
+ if (relPath.endsWith('.json')) {
199
+ try {
200
+ return JSON.parse(text)?.version ?? null;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+ if (relPath.endsWith('.md')) return readFrontmatterVersion(text);
206
+ if (relPath.endsWith('.toml')) return TOML_VERSION_RE.exec(text)?.[1] ?? null;
207
+ if (relPath.endsWith('.yml') || relPath.endsWith('.yaml')) return YAML_VERSION_RE.exec(text)?.[1] ?? null;
208
+ return null;
209
+ }
210
+
211
+ // `ref === null` means "as it is in the working tree"; anything else is read
212
+ // with `git show <ref>:<path>` so main's version can be read without checking
213
+ // main out (the whole point — the user's tree stays untouched).
214
+ export function readVersionAt(repoPath, component, ref) {
215
+ const sources = [];
216
+ for (const relPath of component.versionFiles) {
217
+ let text = null;
218
+ if (ref === null) {
219
+ const abs = assertInsideRepo(repoPath, join(repoPath, relPath), `version file "${relPath}"`);
220
+ if (!existsSync(abs)) continue;
221
+ text = readFileCapped(abs);
222
+ } else {
223
+ const r = git(['show', `${ref}:${relPath}`], { cwd: repoPath });
224
+ if (r.status !== 0) continue; // absent at this ref — not an error
225
+ text = r.stdout;
226
+ }
227
+ const version = versionFromSource(relPath, text);
228
+ if (version) sources.push({ file: relPath, version });
229
+ }
230
+ if (sources.length === 0) {
231
+ return { ok: false, version: null, sources, error: `no version found in any of: ${component.versionFiles.join(', ')}` };
232
+ }
233
+ const distinct = [...new Set(sources.map((s) => s.version))];
234
+ if (distinct.length > 1) {
235
+ // This is the same invariant tools/lint_plugin.py enforces at PR time.
236
+ // Releasing from a disagreeing set would tag one version while shipping
237
+ // another, so it is a hard refusal rather than a "pick the highest".
238
+ return { ok: false, version: null, sources, error: `version files disagree: ${sources.map((s) => `${s.file}=${s.version}`).join(', ')}` };
239
+ }
240
+ return { ok: true, version: distinct[0], sources };
241
+ }
242
+
243
+ // ─── tags ────────────────────────────────────────────────────────────────────
244
+ export function listComponentVersions(repoPath, component) {
245
+ const r = git(['tag', '--list', tagGlob(component)], { cwd: repoPath });
246
+ if (r.status !== 0 || !r.stdout) return [];
247
+ const prefix = component.tagPattern.split('{version}')[0];
248
+ const suffix = component.tagPattern.split('{version}')[1] ?? '';
249
+ return r.stdout
250
+ .split('\n')
251
+ .map((t) => t.trim())
252
+ .filter(Boolean)
253
+ .map((t) => t.slice(prefix.length, suffix ? t.length - suffix.length : undefined))
254
+ .filter((v) => parseSemver(v))
255
+ .sort(cmpSemver);
256
+ }
257
+
258
+ export const latestVersionTagged = (repoPath, component) => listComponentVersions(repoPath, component).pop() ?? null;
259
+
260
+ export function tagExistsLocally(repoPath, tag) {
261
+ return git(['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`], { cwd: repoPath }).status === 0;
262
+ }
263
+
264
+ // The tag on the REMOTE is the only thing that counts as released. A local tag
265
+ // can be stale, hand-created, or left over from a deleted release — reading it
266
+ // back from origin is the difference between "the workflow was dispatched" and
267
+ // "the release exists."
268
+ export function tagExistsOnRemote(repoPath, tag) {
269
+ const r = git(['ls-remote', '--tags', 'origin', `refs/tags/${tag}`], { cwd: repoPath });
270
+ if (r.status !== 0) return { ok: false, exists: false, error: r.stderr };
271
+ return { ok: true, exists: r.stdout.trim().length > 0 };
272
+ }
273
+
274
+ // ─── conventional commits → a suggested bump ─────────────────────────────────
275
+ const CONVENTIONAL_RE = /^(?<type>[a-z]+)(?:\((?<scope>[^)]*)\))?(?<bang>!)?:\s*(?<subject>.+)$/;
276
+ const UNIT = '';
277
+ const RECORD = '';
278
+
279
+ export function parseCommitSubject(subject, body = '') {
280
+ const m = CONVENTIONAL_RE.exec(subject.trim());
281
+ const breaking = /(^|\n)BREAKING[ -]CHANGE:/.test(body) || Boolean(m?.groups.bang);
282
+ if (!m) return { conventional: false, type: null, scope: null, subject: subject.trim(), breaking };
283
+ return {
284
+ conventional: true,
285
+ type: m.groups.type,
286
+ scope: m.groups.scope ?? null,
287
+ subject: m.groups.subject.trim(),
288
+ breaking,
289
+ };
290
+ }
291
+
292
+ export function commitsSince(repoPath, component, sinceTag, ref) {
293
+ const range = sinceTag ? `${sinceTag}..${ref}` : ref;
294
+ const r = git(
295
+ ['log', range, `--format=%H${UNIT}%s${UNIT}%b${RECORD}`, '--', ...component.paths],
296
+ { cwd: repoPath }
297
+ );
298
+ if (r.status !== 0) return { ok: false, error: r.stderr, commits: [] };
299
+ const commits = r.stdout
300
+ .split(RECORD)
301
+ .map((c) => c.trim())
302
+ .filter(Boolean)
303
+ .map((c) => {
304
+ const [sha, subject = '', body = ''] = c.split(UNIT);
305
+ return { sha: sha.trim().slice(0, 8), ...parseCommitSubject(subject, body) };
306
+ });
307
+ return { ok: true, commits };
308
+ }
309
+
310
+ // feat → minor, anything else → patch, breaking → major. With one house rule:
311
+ // while a component is still 0.x, a breaking change is capped at minor, because
312
+ // the alternative is silently promoting an 0.x component to 1.0.0 — a release
313
+ // decision no commit message is entitled to make. The cap is reported, never
314
+ // applied silently.
315
+ export function suggestBump(commits, currentVersion) {
316
+ if (commits.length === 0) return { bump: null, reason: 'no commits touch this component since its last tag', capped: false };
317
+ const breaking = commits.some((c) => c.breaking);
318
+ const feat = commits.some((c) => c.type === 'feat');
319
+ const raw = breaking ? 'major' : feat ? 'minor' : 'patch';
320
+ const zeroMajor = parseSemver(currentVersion)?.major === 0;
321
+ if (raw === 'major' && zeroMajor) {
322
+ return { bump: 'minor', reason: 'a breaking change, capped to minor because this component is still 0.x — going to 1.0.0 is your call, not a commit message’s', capped: true };
323
+ }
324
+ const reason = breaking ? 'a breaking change' : feat ? 'at least one feat' : 'fixes and chores only';
325
+ return { bump: raw, reason, capped: false };
326
+ }
327
+
328
+ // ─── status ──────────────────────────────────────────────────────────────────
329
+ function revParse(repoPath, ref) {
330
+ const r = git(['rev-parse', '--verify', '--quiet', ref], { cwd: repoPath });
331
+ return r.status === 0 ? r.stdout.trim() : null;
332
+ }
333
+
334
+ function dirtyPaths(repoPath, relPaths) {
335
+ const r = git(['status', '--porcelain', '--', ...relPaths], { cwd: repoPath });
336
+ if (r.status !== 0 || !r.stdout) return [];
337
+ return r.stdout.split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
338
+ }
339
+
340
+ // Every OTHER component whose version at `dev` carries no tag. A promotion is
341
+ // atomic and carries all of dev, so these components' bumps land on `main`
342
+ // alongside the one being released, whether or not anyone asked.
343
+ //
344
+ // They are NOT released by that. Since the release jobs became
345
+ // workflow_dispatch-only, landing on main tags nothing — each of these simply
346
+ // becomes `untagged-bump-on-main`, releasable later by an explicit `release-cut`.
347
+ // That is a far safer default than the old behaviour, where the same promotion
348
+ // tagged and npm-published every one of them within seconds of merging.
349
+ //
350
+ // It is still worth saying out loud: the user should know what their promotion
351
+ // is moving to main, and which components are now sitting one dispatch away
352
+ // from a release they did not ask for.
353
+ export function collateralComponents(repoPath, config, exceptName, devRef) {
354
+ const out = [];
355
+ for (const name of listComponentNames(config, repoPath)) {
356
+ if (name === exceptName) continue;
357
+ let component;
358
+ try {
359
+ component = resolveComponent(repoPath, config, name);
360
+ } catch (e) {
361
+ out.push({ name, unresolvable: String(e.message) });
362
+ continue;
363
+ }
364
+ const atDev = readVersionAt(repoPath, component, devRef);
365
+ if (!atDev.ok) continue;
366
+ const tag = tagFor(component, atDev.version);
367
+ if (!tagExistsLocally(repoPath, tag)) out.push({ name, version: atDev.version, tag });
368
+ }
369
+ return out;
370
+ }
371
+
372
+ export function readStatus(repoPath, config, name) {
373
+ const component = resolveComponent(repoPath, config, name);
374
+ const mainBranch = config?.branches?.main ?? 'main';
375
+ const devBranch = config?.branches?.dev ?? 'dev';
376
+
377
+ // Read from the REMOTE-tracking refs, not the local branches: a local `main`
378
+ // that has not been fetched in a week would compute a bump against a stale
379
+ // baseline and silently propose a version that is already tagged.
380
+ const fetched = git(['fetch', 'origin', '--tags', '--prune'], { cwd: repoPath });
381
+ const mainRef = revParse(repoPath, `origin/${mainBranch}`) ? `origin/${mainBranch}` : mainBranch;
382
+ const devRef = revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch;
383
+
384
+ const onMain = readVersionAt(repoPath, component, mainRef);
385
+ const onDev = readVersionAt(repoPath, component, devRef);
386
+ const lastVersion = latestVersionTagged(repoPath, component);
387
+ const lastTag = lastVersion ? tagFor(component, lastVersion) : null;
388
+
389
+ const blockers = [];
390
+ const notes = [];
391
+ // A shallow clone cannot answer "what is unreleased?" — and it does not fail
392
+ // when asked, which is the dangerous part. `git log <tag>..<ref>` excludes
393
+ // everything reachable from <tag>, and that exclusion needs full ancestry;
394
+ // in a grafted history it silently under-applies and the range returns
395
+ // commits that were released long ago. Observed on this repo: a depth-1
396
+ // checkout of main reported 1 unreleased commit for a component that a full
397
+ // clone correctly reported as 0 — which would have proposed a patch release
398
+ // for nothing. A wrong commit list also means a wrong suggestedBump, so this
399
+ // is a blocker rather than a note: every number below it is untrustworthy.
400
+ if (git(['rev-parse', '--is-shallow-repository'], { cwd: repoPath }).stdout.trim() === 'true') {
401
+ blockers.push({
402
+ id: 'shallow-clone',
403
+ detail: 'this is a shallow clone, so commit ranges and the bump derived from them cannot be trusted — run `git fetch --unshallow` first',
404
+ });
405
+ }
406
+ if (!fetched || fetched.status !== 0) {
407
+ notes.push(`could not fetch origin (${fetched?.stderr || 'unknown error'}) — versions and tags below may be stale`);
408
+ }
409
+ if (!onMain.ok) blockers.push({ id: 'version-unreadable-on-main', detail: onMain.error });
410
+ if (!onDev.ok) blockers.push({ id: 'version-unreadable-on-dev', detail: onDev.error });
411
+
412
+ const changelogAbs = join(repoPath, component.changelog);
413
+ if (!existsSync(changelogAbs)) {
414
+ blockers.push({ id: 'changelog-missing', detail: `${component.changelog} does not exist — releases here carry notes from it` });
415
+ }
416
+ const workflowAbs = join(repoPath, '.github', 'workflows', component.workflowFile);
417
+ if (!existsSync(workflowAbs)) {
418
+ blockers.push({ id: 'release-workflow-missing', detail: `.github/workflows/${component.workflowFile} does not exist — nothing would cut the tag` });
419
+ }
420
+ // Only THIS component's own files count as blocking dirt. Unrelated
421
+ // uncommitted work is normal and routine (this monorepo's tree had four
422
+ // unrelated modified files and an untracked skill while this was written);
423
+ // blocking on it would make the command unusable, and prepare() works in an
424
+ // isolated worktree precisely so it cannot sweep that work up.
425
+ const ownDirt = dirtyPaths(repoPath, [...component.versionFiles, component.changelog]);
426
+ if (ownDirt.length > 0) {
427
+ blockers.push({ id: 'component-files-dirty', detail: `uncommitted changes in ${ownDirt.join(', ')} — commit or stash them first` });
428
+ }
429
+ const otherDirt = git(['status', '--porcelain'], { cwd: repoPath }).stdout.split('\n').filter(Boolean).length - ownDirt.length;
430
+ if (otherDirt > 0) notes.push(`${otherDirt} unrelated file(s) are dirty in the working tree — left alone; prepare() works in an isolated worktree`);
431
+
432
+ let state = 'unknown';
433
+ if (onMain.ok && lastVersion) {
434
+ const c = cmpSemver(onMain.version, lastVersion);
435
+ if (c > 0) state = 'untagged-bump-on-main';
436
+ else if (c < 0) {
437
+ state = 'version-behind-tag';
438
+ blockers.push({ id: 'version-behind-tag', detail: `${mainBranch} carries ${onMain.version} but ${lastTag} is already tagged` });
439
+ } else if (onDev.ok && cmpSemver(onDev.version, onMain.version) > 0) state = 'bump-on-dev-unpromoted';
440
+ else state = 'clean';
441
+ } else if (onMain.ok && !lastVersion) {
442
+ state = 'untagged-bump-on-main'; // never released; whatever is on main is the first release
443
+ }
444
+
445
+ // A fact, not a state — computed independently of the branch above so it is
446
+ // ALSO set when lastVersion is null (a component's first release). Folding
447
+ // this into `state` is the bug this field exists to fix (#173): "main has an
448
+ // untagged bump" and "dev already carries something higher" are
449
+ // independently true, and a single mutually-exclusive `state` string can
450
+ // only ever report one of them. `cut()`'s fast path acts on `state` alone —
451
+ // without `devAhead`, it would dispatch a release for whatever is on main
452
+ // while the version actually being released sits unread on dev.
453
+ const devAhead = onMain.ok && onDev.ok && cmpSemver(onDev.version, onMain.version) > 0
454
+ ? { version: onDev.version, aheadOfMain: true }
455
+ : null;
456
+
457
+ // The fast path is only armed in `untagged-bump-on-main`, so this blocker is
458
+ // deliberately scoped to that state alone. `bump-on-dev-unpromoted` also has
459
+ // `devAhead` set — that is its normal, expected shape (no fast path is
460
+ // reachable there, nothing can be mis-tagged) — and flagging it too would
461
+ // permanently mark a routine state as blocked, which is how a blocker stops
462
+ // being read.
463
+ if (devAhead && state === 'untagged-bump-on-main') {
464
+ blockers.push({
465
+ id: 'dev-ahead-of-main',
466
+ detail: `${mainBranch} carries ${onMain.version} but ${devBranch} carries ${devAhead.version} — cutting here would tag ` +
467
+ `${tagFor(component, onMain.version)}, not ${tagFor(component, devAhead.version)}. Promote ${devBranch} → ${mainBranch} ` +
468
+ `and re-run status, or pass --version ${onMain.version} to release exactly what is on ${mainBranch}.`,
469
+ });
470
+ }
471
+
472
+ const since = commitsSince(repoPath, component, lastTag, mainRef);
473
+ const suggestion = suggestBump(since.commits, onMain.version ?? '0.0.0');
474
+ const nextVersion = suggestion.bump && onMain.ok ? bumpSemver(onMain.version, suggestion.bump) : null;
475
+
476
+ const collateral = collateralComponents(repoPath, config, name, devRef);
477
+
478
+ // The TOCTOU guard for cut(). Everything that could change the meaning of a
479
+ // release decision between the moment it is shown to a human and the moment
480
+ // it is acted on: both branch heads, the versions, the last tag, and who
481
+ // else is riding along.
482
+ const statusHash = sha256(
483
+ JSON.stringify({
484
+ component: name,
485
+ mainSha: revParse(repoPath, mainRef),
486
+ devSha: revParse(repoPath, devRef),
487
+ versionOnMain: onMain.version,
488
+ versionOnDev: onDev.version,
489
+ lastTag,
490
+ collateral: collateral.map((c) => `${c.name}@${c.version ?? '?'}`).sort(),
491
+ })
492
+ );
493
+
494
+ return {
495
+ component: {
496
+ name,
497
+ versionFiles: component.versionFiles,
498
+ changelog: component.changelog,
499
+ workflowFile: component.workflowFile,
500
+ paths: component.paths,
501
+ inferredLayout: component.inferredLayout,
502
+ },
503
+ state,
504
+ versionOnMain: onMain.version,
505
+ versionOnDev: onDev.version,
506
+ devAhead,
507
+ versionSources: onMain.sources,
508
+ lastTag,
509
+ commits: since.commits,
510
+ suggestedBump: suggestion.bump,
511
+ suggestedBumpReason: suggestion.reason,
512
+ suggestedBumpCapped: suggestion.capped,
513
+ nextVersion,
514
+ collateral,
515
+ blockers,
516
+ notes,
517
+ statusHash,
518
+ };
519
+ }
520
+
521
+ // ─── prepare ─────────────────────────────────────────────────────────────────
522
+ function writeVersionInto(relPath, text, version) {
523
+ if (relPath.endsWith('.json')) {
524
+ // Line-targeted rather than JSON.parse → JSON.stringify: reserializing
525
+ // would reformat the whole file (key order, indentation, trailing
526
+ // newline), turning a one-line version bump into an unreviewable diff and
527
+ // breaking press's byte-exact region checks in files that carry them.
528
+ const re = /^(\s*"version"\s*:\s*")([^"]*)(")/m;
529
+ if (!re.test(text)) return null;
530
+ return text.replace(re, `$1${version}$3`);
531
+ }
532
+ if (relPath.endsWith('.md')) {
533
+ const lines = text.split('\n');
534
+ if (lines[0]?.trim() !== '---') return null;
535
+ for (let i = 1; i < lines.length; i++) {
536
+ if (lines[i].trim() === '---') return null;
537
+ if (/^version:\s*/.test(lines[i])) {
538
+ lines[i] = `version: ${version}`;
539
+ return lines.join('\n');
540
+ }
541
+ }
542
+ return null;
543
+ }
544
+ if (relPath.endsWith('.toml')) {
545
+ return TOML_VERSION_RE.test(text) ? text.replace(TOML_VERSION_RE, (m, v) => m.replace(v, version)) : null;
546
+ }
547
+ if (relPath.endsWith('.yml') || relPath.endsWith('.yaml')) {
548
+ return YAML_VERSION_RE.test(text) ? text.replace(YAML_VERSION_RE, (m, v) => m.replace(v, version)) : null;
549
+ }
550
+ return null;
551
+ }
552
+
553
+ // Keep-a-Changelog shape, matching what _release.yml's awk already extracts:
554
+ // the notes for a release are the lines under the first `## ` heading
555
+ // containing the version, up to the next `## `.
556
+ export function spliceChangelog(existing, version, notes, date) {
557
+ const heading = `## [${version}] - ${date}`;
558
+ // Plain string matching, deliberately not a constructed regex. Two reasons,
559
+ // and the second is why this is not merely a style preference:
560
+ //
561
+ // 1. It is exactly what _release.yml's awk does — `/^## / && index($0, ver)`
562
+ // — so "would this heading be found at release time?" is answered by the
563
+ // same test that will actually answer it.
564
+ // 2. Building `new RegExp` from `version` escaped only dots, leaving every
565
+ // other metacharacter (`\`, `*`, `+`, `(`, `[`) live. `prepare` rejects a
566
+ // non-semver version before reaching here, but this function is exported
567
+ // and independently callable, so it must not depend on a caller's guard.
568
+ // Found by CodeQL (js/incomplete-sanitization, high) on PR #158.
569
+ const alreadyPresent = existing
570
+ .split('\n')
571
+ .some((line) => line.startsWith('## ') && line.includes(version));
572
+ if (alreadyPresent) {
573
+ return { ok: false, error: `CHANGELOG already has a heading for ${version}` };
574
+ }
575
+ const lines = existing.split('\n');
576
+ // Insert above the first existing release heading, so the newest release is
577
+ // at the top and any preamble (title, Keep-a-Changelog blurb) is preserved.
578
+ const firstHeading = lines.findIndex((l) => /^## /.test(l));
579
+ const block = [heading, '', notes.trim(), ''];
580
+ if (firstHeading === -1) {
581
+ return { ok: true, content: `${existing.trimEnd()}\n\n${block.join('\n')}\n` };
582
+ }
583
+ lines.splice(firstHeading, 0, ...block);
584
+ return { ok: true, content: lines.join('\n') };
585
+ }
586
+
587
+ export const releaseBranchName = (name, version) => `feature/release-${name}-v${version}`;
588
+ const worktreeDir = (name, version) => join(tmpdir(), `shipflow-release-${name}-${version}`);
589
+
590
+ export function prepare(repoPath, config, name, version, notes, { date, featureBranchPrefix } = {}) {
591
+ const component = resolveComponent(repoPath, config, name);
592
+ const devBranch = config?.branches?.dev ?? 'dev';
593
+ const tag = tagFor(component, version);
594
+ const stamp = date ?? new Date().toISOString().slice(0, 10);
595
+
596
+ if (!parseSemver(version)) return { ok: false, error: `"${version}" is not a valid semver version` };
597
+ if (tagExistsLocally(repoPath, tag)) return { ok: false, error: `${tag} already exists — pick a higher version` };
598
+
599
+ const onMain = readVersionAt(repoPath, component, revParse(repoPath, `origin/${config?.branches?.main ?? 'main'}`) ? `origin/${config?.branches?.main ?? 'main'}` : (config?.branches?.main ?? 'main'));
600
+ const onDev = readVersionAt(repoPath, component, revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch);
601
+ for (const [where, read] of [['main', onMain], ['dev', onDev]]) {
602
+ if (read.ok && cmpSemver(version, read.version) <= 0) {
603
+ return { ok: false, error: `${version} is not higher than the ${read.version} already on ${where}` };
604
+ }
605
+ }
606
+
607
+ const branch = releaseBranchName(name, version);
608
+ if (featureBranchPrefix && !branch.startsWith(featureBranchPrefix)) {
609
+ return { ok: false, error: `release branch ${branch} does not start with the configured featureBranchPrefix ${featureBranchPrefix}` };
610
+ }
611
+ const dir = worktreeDir(name, version);
612
+
613
+ // A leftover worktree from an aborted run must not silently become the base
614
+ // for this one — remove it, then re-create from the CURRENT dev.
615
+ rmSync(dir, { recursive: true, force: true });
616
+ git(['worktree', 'prune'], { cwd: repoPath });
617
+ git(['branch', '-D', branch], { cwd: repoPath });
618
+ const base = revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch;
619
+ const added = git(['worktree', 'add', '-b', branch, dir, base], { cwd: repoPath });
620
+ if (added.status !== 0) return { ok: false, error: `git worktree add failed: ${added.stderr}` };
621
+
622
+ const changed = [];
623
+ try {
624
+ for (const relPath of component.versionFiles) {
625
+ const abs = join(dir, relPath);
626
+ if (!existsSync(abs)) continue;
627
+ const before = readFileCapped(abs);
628
+ const after = writeVersionInto(relPath, before, version);
629
+ if (after === null) {
630
+ return { ok: false, error: `could not find a version field to rewrite in ${relPath}` };
631
+ }
632
+ if (after !== before) {
633
+ writeFileSync(abs, after);
634
+ changed.push(relPath);
635
+ }
636
+ }
637
+ if (changed.length === 0) {
638
+ return { ok: false, error: `no version file was changed — is ${version} already the version on ${devBranch}?` };
639
+ }
640
+
641
+ const clAbs = join(dir, component.changelog);
642
+ if (!existsSync(clAbs)) return { ok: false, error: `${component.changelog} does not exist` };
643
+ const spliced = spliceChangelog(readFileCapped(clAbs), version, notes, stamp);
644
+ if (!spliced.ok) return { ok: false, error: spliced.error };
645
+ writeFileSync(clAbs, spliced.content);
646
+ changed.push(component.changelog);
647
+
648
+ // Explicit pathspecs, never `git add -A`. The worktree should contain
649
+ // nothing else, but "should" is not a guarantee worth a release commit.
650
+ const staged = git(['add', '--', ...changed], { cwd: dir });
651
+ if (staged.status !== 0) return { ok: false, error: `git add failed: ${staged.stderr}` };
652
+ const committed = git(['commit', '-m', `chore(${name}): release v${version}`], { cwd: dir });
653
+ if (committed.status !== 0) return { ok: false, error: `git commit failed: ${committed.stderr}` };
654
+
655
+ const diff = git(['show', '--stat', '--format=', 'HEAD'], { cwd: dir });
656
+ return { ok: true, branch, worktree: dir, tag, version, changed, diffstat: diff.stdout };
657
+ } catch (e) {
658
+ return { ok: false, error: String(e?.message ?? e) };
659
+ }
660
+ }
661
+
662
+ // ─── resolving the release target ────────────────────────────────────────────
663
+ // The one place a target version is decided. Before this existed, `cut()`
664
+ // derived it twice, ten lines apart — once preferring dev, once preferring
665
+ // main — and those two derivations could disagree. That disagreement IS #173:
666
+ // the fast path would tag whatever sat on main while the version actually
667
+ // being released sat, unread, on dev. `cut()` now calls this once, before any
668
+ // network call, and uses its result for both the dispatch and the tag it
669
+ // waits for, so there is no longer a code path where those two can differ.
670
+ //
671
+ // Pure function of a `readStatus()` result plus an optional operator-supplied
672
+ // `requestedVersion` (`--version`). `requestedVersion` is a CONFIRMATION, not
673
+ // a bypass: it is only ever accepted when it matches a version already
674
+ // present on `main` or `dev` in this status, so there is no value of it that
675
+ // releases a version which isn't actually on the branch being dispatched.
676
+ export function resolveReleaseTarget(status, requestedVersion = null) {
677
+ const { state, versionOnMain, versionOnDev, devAhead, component } = status;
678
+
679
+ if (state === 'untagged-bump-on-main') {
680
+ if (!devAhead) {
681
+ // The common, unambiguous case: whatever is on main is the only
682
+ // candidate, dev has nothing higher.
683
+ return { ok: true, version: versionOnMain, via: 'dispatch-on-main' };
684
+ }
685
+ if (requestedVersion === versionOnMain) {
686
+ // Confirmed: release exactly what is on main, knowingly leaving dev's
687
+ // higher version for a later, separate release.
688
+ return { ok: true, version: versionOnMain, via: 'dispatch-on-main', confirmed: true };
689
+ }
690
+ if (requestedVersion === versionOnDev) {
691
+ return {
692
+ ok: false,
693
+ error: `${versionOnDev} is on dev but not on main — a dispatch on main cannot cut it. ` +
694
+ `Promote dev → main first, then re-run release-status.`,
695
+ };
696
+ }
697
+ return {
698
+ ok: false,
699
+ error: `${component.name}: main carries ${versionOnMain} but dev carries ${versionOnDev} — ambiguous which one ` +
700
+ `to release, so refusing to guess. Promote dev → main and re-run release-status to release ${versionOnDev}, ` +
701
+ `or pass --version ${versionOnMain} to release exactly what is on main.`,
702
+ };
703
+ }
704
+
705
+ // Every other state (`clean`, `bump-on-dev-unpromoted`, `version-behind-tag`)
706
+ // already has a single unambiguous candidate — dev, when it carries the
707
+ // prepared bump, else main — matching what `cut()` used before this existed.
708
+ return { ok: true, version: versionOnDev ?? versionOnMain, via: 'prepared-branch' };
709
+ }
710
+
711
+ // ─── cut ─────────────────────────────────────────────────────────────────────
712
+ // Resumable and bounded on purpose. The full path (feature PR → checks → merge
713
+ // → promotion → auto-merge → release run → tag) routinely takes longer than a
714
+ // single tool call is allowed to block for, so cut() advances as far as it can
715
+ // within `waitSeconds`, then returns the stage it is parked at. Calling it
716
+ // again picks up from wherever the remote actually is — it derives every stage
717
+ // from live state, never from a local record of what a previous call did, so an
718
+ // interrupted run and a fresh one are the same code path.
719
+ const STAGES = ['push', 'feature-pr', 'feature-merged', 'promotion-open', 'promotion-merged', 'tag'];
720
+
721
+ function prNumberFor(ownerRepo, head, base) {
722
+ const r = ghApiJson(`repos/${ownerRepo}/pulls?head=${encodeURIComponent(head)}&base=${encodeURIComponent(base)}&state=open`);
723
+ if (!r.ok) return null;
724
+ return r.data?.[0]?.number ?? null;
725
+ }
726
+
727
+ function sleepSync(ms) {
728
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
729
+ }
730
+
731
+ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHash = null, skipHashCheck = false, ownerRepo, pollSeconds = 15, version = null } = {}) {
732
+ const component = resolveComponent(repoPath, config, name);
733
+ const mainBranch = config?.branches?.main ?? 'main';
734
+ const devBranch = config?.branches?.dev ?? 'dev';
735
+ const owner = ownerRepo.split('/')[0];
736
+
737
+ const status = readStatus(repoPath, config, name);
738
+ if (!skipHashCheck) {
739
+ if (!expectStatusHash) {
740
+ return { ok: false, error: '--expect-status-hash is required (or the explicit --skip-hash-check escape hatch). Re-run release-status and pass its statusHash.' };
741
+ }
742
+ if (expectStatusHash !== status.statusHash) {
743
+ return { ok: false, error: 'toctou: repo state changed since the status you confirmed — re-run release-status, re-confirm, and pass the new hash', currentStatusHash: status.statusHash };
744
+ }
745
+ }
746
+
747
+ // The ONLY place the release target is decided — see resolveReleaseTarget's
748
+ // own comment for why. Called before any network call, so an ambiguous
749
+ // three-way state (#173: main has an untagged bump AND dev carries
750
+ // something higher) is refused here rather than acted on by the fast path
751
+ // below.
752
+ const target = resolveReleaseTarget(status, version);
753
+ if (!target.ok) return { ok: false, error: target.error };
754
+ const targetVersion = target.version;
755
+ const tag = tagFor(component, targetVersion);
756
+ const branch = releaseBranchName(name, targetVersion);
757
+ const deadline = Date.now() + waitSeconds * 1000;
758
+ const log = [];
759
+ const note = (stage, msg) => log.push({ stage, msg });
760
+
761
+ // Fast path: the bump is already on main and simply was never tagged (a
762
+ // failed or cancelled push run). No PR is needed at all — dispatch and prove.
763
+ if (target.via === 'dispatch-on-main') {
764
+ const already = tagExistsOnRemote(repoPath, tag);
765
+ if (already.ok && already.exists) {
766
+ return { ok: true, done: true, stage: 'tag', tag, targetVersion, note: 'already released' };
767
+ }
768
+ const d = spawnArgs('gh', ['workflow', 'run', component.workflowFile, '--ref', mainBranch, '--repo', ownerRepo]);
769
+ if (d.status !== 0) return { ok: false, error: `workflow dispatch failed: ${d.stderr}` };
770
+ note('dispatch', `dispatched ${component.workflowFile} on ${mainBranch}`);
771
+ const result = waitForTag(repoPath, tag, deadline, pollSeconds, log, ownerRepo, null);
772
+ return { ...result, targetVersion };
773
+ }
774
+
775
+ // 1. push the prepared branch
776
+ if (!revParse(repoPath, branch)) {
777
+ return { ok: false, error: `branch ${branch} does not exist — run release-prepare first` };
778
+ }
779
+ const dir = worktreeDir(name, targetVersion);
780
+ const pushCwd = existsSync(dir) ? dir : repoPath;
781
+ if (!revParse(repoPath, `origin/${branch}`)) {
782
+ const pushed = git(['push', '-u', 'origin', branch], { cwd: pushCwd });
783
+ if (pushed.status !== 0) return { ok: false, error: `git push failed: ${pushed.stderr}` };
784
+ note('push', `pushed ${branch}`);
785
+ }
786
+
787
+ // 2. open the feature → dev PR
788
+ let featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, devBranch);
789
+ if (!featurePr) {
790
+ const devHasIt = readVersionAt(repoPath, component, `origin/${devBranch}`);
791
+ if (devHasIt.ok && cmpSemver(devHasIt.version, targetVersion) >= 0) {
792
+ note('feature-merged', `${targetVersion} is already on ${devBranch}`);
793
+ } else {
794
+ const created = spawnArgs('gh', [
795
+ 'pr', 'create', '--repo', ownerRepo, '--base', devBranch, '--head', branch,
796
+ '--title', `chore(${name}): release v${targetVersion}`,
797
+ '--body', `Release ${tag}.\n\nVersion bump and CHANGELOG entry land together, in this one change — releases here are publish-on-merge, so a follow-up promotion to fix notes is too late.`,
798
+ ]);
799
+ if (created.status !== 0) return { ok: false, error: `gh pr create failed: ${created.stderr}` };
800
+ featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, devBranch);
801
+ note('feature-pr', `opened #${featurePr}`);
802
+ }
803
+ }
804
+
805
+ // 3. wait for its checks, then squash it into dev
806
+ if (featurePr) {
807
+ const gate = waitForChecks(ownerRepo, featurePr, deadline, pollSeconds, log);
808
+ if (!gate.ok) return gate;
809
+ if (!gate.done) return { ok: true, done: false, stage: 'feature-pr', featurePr, tag, targetVersion, log, next: 'call release-cut again — waiting on the feature PR’s checks' };
810
+ const method = config?.mergeMethod?.featureToDevMethod ?? 'squash';
811
+ const merged = spawnArgs('gh', ['pr', 'merge', String(featurePr), '--repo', ownerRepo, `--${method}`, '--delete-branch']);
812
+ if (merged.status !== 0) return { ok: false, error: `gh pr merge failed on the feature PR: ${merged.stderr}` };
813
+ note('feature-merged', `merged #${featurePr} into ${devBranch} (${method})`);
814
+ rmSync(dir, { recursive: true, force: true });
815
+ git(['worktree', 'prune'], { cwd: repoPath });
816
+ }
817
+
818
+ // 4. open (or find) the dev → main promotion. shipflow's rendered auto-merge
819
+ // workflow turns on native auto-merge from here; nothing polls for it.
820
+ git(['fetch', 'origin', '--prune'], { cwd: repoPath });
821
+ let promotion = prNumberFor(ownerRepo, `${owner}:${devBranch}`, mainBranch);
822
+ if (!promotion) {
823
+ const created = spawnArgs('gh', [
824
+ 'pr', 'create', '--repo', ownerRepo, '--base', mainBranch, '--head', devBranch,
825
+ '--title', `release: ${name} v${targetVersion}`,
826
+ '--body', releaseBody(name, targetVersion, status.collateral),
827
+ ]);
828
+ if (created.status !== 0) return { ok: false, error: `gh pr create failed on the promotion: ${created.stderr}` };
829
+ promotion = prNumberFor(ownerRepo, `${owner}:${devBranch}`, mainBranch);
830
+ note('promotion-open', `opened promotion #${promotion}`);
831
+ } else {
832
+ note('promotion-open', `promotion #${promotion} already open`);
833
+ }
834
+
835
+ // 5. wait for the promotion to auto-merge, then for the tag to appear
836
+ const landed = waitForMerge(ownerRepo, promotion, deadline, pollSeconds, log);
837
+ if (!landed.ok) return landed;
838
+ if (!landed.done) {
839
+ return { ok: true, done: false, stage: 'promotion-open', promotion, tag, targetVersion, log, next: 'call release-cut again — waiting on the promotion to auto-merge' };
840
+ }
841
+
842
+ // 6. The promotion landing cuts NOTHING on its own. Every caller's release
843
+ // job is `workflow_dispatch`-only by deliberate design, so that this line
844
+ // is the single point at which a tag is ever created — one named
845
+ // component, released because someone asked for it.
846
+ //
847
+ // This is load-bearing, not ceremony: until 2026-08-02 the release jobs
848
+ // also ran on `push`, and a `dev -> main` merge therefore tagged and npm-
849
+ // published everything bumped on dev, seconds after merging, with no
850
+ // dispatch involved. Removing `push` without adding this dispatch would
851
+ // leave cut() waiting forever for a tag nobody cuts.
852
+ //
853
+ // Safe to re-run: _release.yml no-ops on an existing tag, and its
854
+ // `concurrency: release-<skill>` group serialises a resumed call behind
855
+ // an in-flight one.
856
+ const already = tagExistsOnRemote(repoPath, tag);
857
+ if (!(already.ok && already.exists)) {
858
+ const d = spawnArgs('gh', ['workflow', 'run', component.workflowFile, '--ref', mainBranch, '--repo', ownerRepo]);
859
+ if (d.status !== 0) {
860
+ return { ok: false, error: `the promotion merged but dispatching ${component.workflowFile} failed: ${d.stderr}. Nothing is tagged; re-run release-cut to retry the dispatch.` };
861
+ }
862
+ note('dispatch', `dispatched ${component.workflowFile} on ${mainBranch} — this, not the merge, is what cuts the tag`);
863
+ }
864
+ const result = waitForTag(repoPath, tag, deadline, pollSeconds, log, ownerRepo, promotion);
865
+ return { ...result, targetVersion };
866
+ }
867
+
868
+ function releaseBody(name, version, collateral) {
869
+ const extra = collateral.length
870
+ ? `\n\n**This promotion also moves these bumps to main** (a promotion is atomic and carries all of dev): ` +
871
+ `${collateral.map((c) => `\`${c.tag}\``).join(', ')}. ` +
872
+ `They are **not** released by merging — the release jobs are \`workflow_dispatch\`-only — but each becomes ` +
873
+ `\`untagged-bump-on-main\`, one \`release-cut\` away from a tag.`
874
+ : '';
875
+ return `Promotes \`${name}\` v${version} to main.${extra}`;
876
+ }
877
+
878
+ function waitForChecks(ownerRepo, prNumber, deadline, pollSeconds, log) {
879
+ for (;;) {
880
+ const r = ghApiJson(`repos/${ownerRepo}/pulls/${prNumber}`);
881
+ if (!r.ok) return { ok: false, error: `could not read PR #${prNumber}: ${r.stderr}` };
882
+ const sha = r.data?.head?.sha;
883
+ const cr = ghApiJson(`repos/${ownerRepo}/commits/${sha}/check-runs?per_page=100`);
884
+ if (!cr.ok) return { ok: false, error: `could not read check runs: ${cr.stderr}` };
885
+ const runs = cr.data?.check_runs ?? [];
886
+ const pending = runs.filter((c) => c.status !== 'completed');
887
+ const failed = runs.filter((c) => c.status === 'completed' && !['success', 'neutral', 'skipped'].includes(c.conclusion));
888
+ if (failed.length > 0) {
889
+ return { ok: false, error: `checks failed on PR #${prNumber}: ${failed.map((c) => c.name).join(', ')} — fix them, then call release-cut again` };
890
+ }
891
+ if (runs.length > 0 && pending.length === 0) {
892
+ log.push({ stage: 'feature-pr', msg: `${runs.length} checks green` });
893
+ return { ok: true, done: true };
894
+ }
895
+ if (Date.now() + pollSeconds * 1000 > deadline) {
896
+ log.push({ stage: 'feature-pr', msg: `${pending.length}/${runs.length} checks still running` });
897
+ return { ok: true, done: false };
898
+ }
899
+ sleepSync(pollSeconds * 1000);
900
+ }
901
+ }
902
+
903
+ function waitForMerge(ownerRepo, prNumber, deadline, pollSeconds, log) {
904
+ for (;;) {
905
+ const r = ghApiJson(`repos/${ownerRepo}/pulls/${prNumber}`);
906
+ if (!r.ok) return { ok: false, error: `could not read PR #${prNumber}: ${r.stderr}` };
907
+ if (r.data?.merged === true) {
908
+ log.push({ stage: 'promotion-merged', msg: `#${prNumber} merged` });
909
+ return { ok: true, done: true };
910
+ }
911
+ if (r.data?.state === 'closed') {
912
+ return { ok: false, error: `promotion #${prNumber} was closed without merging` };
913
+ }
914
+ if (Date.now() + pollSeconds * 1000 > deadline) return { ok: true, done: false };
915
+ sleepSync(pollSeconds * 1000);
916
+ }
917
+ }
918
+
919
+ // The one thing that counts. Not the dispatch, not the merge, not a green
920
+ // check — the tag, fetched back from origin.
921
+ function waitForTag(repoPath, tag, deadline, pollSeconds, log, ownerRepo, promotionPr) {
922
+ for (;;) {
923
+ const t = tagExistsOnRemote(repoPath, tag);
924
+ if (t.ok && t.exists) {
925
+ log.push({ stage: 'tag', msg: `${tag} exists on origin` });
926
+ const rel = ghApiJson(`repos/${ownerRepo}/releases/tags/${tag}`);
927
+ // Clearing the label is what stops `shipflow releases` resurfacing a
928
+ // promotion this command already released, forever.
929
+ let labelCleared = null;
930
+ if (promotionPr) {
931
+ const c = ghApiJson(`repos/${ownerRepo}/issues/${promotionPr}/labels/release-pending`, ['-X', 'DELETE']);
932
+ labelCleared = c.ok;
933
+ }
934
+ return {
935
+ ok: true, done: true, stage: 'tag', tag,
936
+ releaseUrl: rel.ok ? rel.data?.html_url ?? null : null,
937
+ releaseNotes: rel.ok ? rel.data?.body ?? null : null,
938
+ labelCleared, log,
939
+ };
940
+ }
941
+ if (Date.now() + pollSeconds * 1000 > deadline) {
942
+ return { ok: true, done: false, stage: 'promotion-merged', tag, log, next: `call release-cut again — ${tag} is not on origin yet` };
943
+ }
944
+ sleepSync(pollSeconds * 1000);
945
+ }
946
+ }
947
+
948
+ export { STAGES };