@natjswenson/shipflow 0.3.3 → 0.4.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,813 @@
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. Those ride along
341
+ // on the same dev → main promotion — a promotion is atomic and carries all of
342
+ // dev, so "release devlog" physically also releases them. Surfacing this list
343
+ // is not advisory: releasing a component the user never named is the worst
344
+ // thing this engine can do, and the only defence is saying so first.
345
+ export function collateralComponents(repoPath, config, exceptName, devRef) {
346
+ const out = [];
347
+ for (const name of listComponentNames(config, repoPath)) {
348
+ if (name === exceptName) continue;
349
+ let component;
350
+ try {
351
+ component = resolveComponent(repoPath, config, name);
352
+ } catch (e) {
353
+ out.push({ name, unresolvable: String(e.message) });
354
+ continue;
355
+ }
356
+ const atDev = readVersionAt(repoPath, component, devRef);
357
+ if (!atDev.ok) continue;
358
+ const tag = tagFor(component, atDev.version);
359
+ if (!tagExistsLocally(repoPath, tag)) out.push({ name, version: atDev.version, tag });
360
+ }
361
+ return out;
362
+ }
363
+
364
+ export function readStatus(repoPath, config, name) {
365
+ const component = resolveComponent(repoPath, config, name);
366
+ const mainBranch = config?.branches?.main ?? 'main';
367
+ const devBranch = config?.branches?.dev ?? 'dev';
368
+
369
+ // Read from the REMOTE-tracking refs, not the local branches: a local `main`
370
+ // that has not been fetched in a week would compute a bump against a stale
371
+ // baseline and silently propose a version that is already tagged.
372
+ const fetched = git(['fetch', 'origin', '--tags', '--prune'], { cwd: repoPath });
373
+ const mainRef = revParse(repoPath, `origin/${mainBranch}`) ? `origin/${mainBranch}` : mainBranch;
374
+ const devRef = revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch;
375
+
376
+ const onMain = readVersionAt(repoPath, component, mainRef);
377
+ const onDev = readVersionAt(repoPath, component, devRef);
378
+ const lastVersion = latestVersionTagged(repoPath, component);
379
+ const lastTag = lastVersion ? tagFor(component, lastVersion) : null;
380
+
381
+ const blockers = [];
382
+ const notes = [];
383
+ if (!fetched || fetched.status !== 0) {
384
+ notes.push(`could not fetch origin (${fetched?.stderr || 'unknown error'}) — versions and tags below may be stale`);
385
+ }
386
+ if (!onMain.ok) blockers.push({ id: 'version-unreadable-on-main', detail: onMain.error });
387
+ if (!onDev.ok) blockers.push({ id: 'version-unreadable-on-dev', detail: onDev.error });
388
+
389
+ const changelogAbs = join(repoPath, component.changelog);
390
+ if (!existsSync(changelogAbs)) {
391
+ blockers.push({ id: 'changelog-missing', detail: `${component.changelog} does not exist — releases here carry notes from it` });
392
+ }
393
+ const workflowAbs = join(repoPath, '.github', 'workflows', component.workflowFile);
394
+ if (!existsSync(workflowAbs)) {
395
+ blockers.push({ id: 'release-workflow-missing', detail: `.github/workflows/${component.workflowFile} does not exist — nothing would cut the tag` });
396
+ }
397
+ // Only THIS component's own files count as blocking dirt. Unrelated
398
+ // uncommitted work is normal and routine (this monorepo's tree had four
399
+ // unrelated modified files and an untracked skill while this was written);
400
+ // blocking on it would make the command unusable, and prepare() works in an
401
+ // isolated worktree precisely so it cannot sweep that work up.
402
+ const ownDirt = dirtyPaths(repoPath, [...component.versionFiles, component.changelog]);
403
+ if (ownDirt.length > 0) {
404
+ blockers.push({ id: 'component-files-dirty', detail: `uncommitted changes in ${ownDirt.join(', ')} — commit or stash them first` });
405
+ }
406
+ const otherDirt = git(['status', '--porcelain'], { cwd: repoPath }).stdout.split('\n').filter(Boolean).length - ownDirt.length;
407
+ if (otherDirt > 0) notes.push(`${otherDirt} unrelated file(s) are dirty in the working tree — left alone; prepare() works in an isolated worktree`);
408
+
409
+ let state = 'unknown';
410
+ if (onMain.ok && lastVersion) {
411
+ const c = cmpSemver(onMain.version, lastVersion);
412
+ if (c > 0) state = 'untagged-bump-on-main';
413
+ else if (c < 0) {
414
+ state = 'version-behind-tag';
415
+ blockers.push({ id: 'version-behind-tag', detail: `${mainBranch} carries ${onMain.version} but ${lastTag} is already tagged` });
416
+ } else if (onDev.ok && cmpSemver(onDev.version, onMain.version) > 0) state = 'bump-on-dev-unpromoted';
417
+ else state = 'clean';
418
+ } else if (onMain.ok && !lastVersion) {
419
+ state = 'untagged-bump-on-main'; // never released; whatever is on main is the first release
420
+ }
421
+
422
+ const since = commitsSince(repoPath, component, lastTag, mainRef);
423
+ const suggestion = suggestBump(since.commits, onMain.version ?? '0.0.0');
424
+ const nextVersion = suggestion.bump && onMain.ok ? bumpSemver(onMain.version, suggestion.bump) : null;
425
+
426
+ const collateral = collateralComponents(repoPath, config, name, devRef);
427
+
428
+ // The TOCTOU guard for cut(). Everything that could change the meaning of a
429
+ // release decision between the moment it is shown to a human and the moment
430
+ // it is acted on: both branch heads, the versions, the last tag, and who
431
+ // else is riding along.
432
+ const statusHash = sha256(
433
+ JSON.stringify({
434
+ component: name,
435
+ mainSha: revParse(repoPath, mainRef),
436
+ devSha: revParse(repoPath, devRef),
437
+ versionOnMain: onMain.version,
438
+ versionOnDev: onDev.version,
439
+ lastTag,
440
+ collateral: collateral.map((c) => `${c.name}@${c.version ?? '?'}`).sort(),
441
+ })
442
+ );
443
+
444
+ return {
445
+ component: {
446
+ name,
447
+ versionFiles: component.versionFiles,
448
+ changelog: component.changelog,
449
+ workflowFile: component.workflowFile,
450
+ paths: component.paths,
451
+ inferredLayout: component.inferredLayout,
452
+ },
453
+ state,
454
+ versionOnMain: onMain.version,
455
+ versionOnDev: onDev.version,
456
+ versionSources: onMain.sources,
457
+ lastTag,
458
+ commits: since.commits,
459
+ suggestedBump: suggestion.bump,
460
+ suggestedBumpReason: suggestion.reason,
461
+ suggestedBumpCapped: suggestion.capped,
462
+ nextVersion,
463
+ collateral,
464
+ blockers,
465
+ notes,
466
+ statusHash,
467
+ };
468
+ }
469
+
470
+ // ─── prepare ─────────────────────────────────────────────────────────────────
471
+ function writeVersionInto(relPath, text, version) {
472
+ if (relPath.endsWith('.json')) {
473
+ // Line-targeted rather than JSON.parse → JSON.stringify: reserializing
474
+ // would reformat the whole file (key order, indentation, trailing
475
+ // newline), turning a one-line version bump into an unreviewable diff and
476
+ // breaking press's byte-exact region checks in files that carry them.
477
+ const re = /^(\s*"version"\s*:\s*")([^"]*)(")/m;
478
+ if (!re.test(text)) return null;
479
+ return text.replace(re, `$1${version}$3`);
480
+ }
481
+ if (relPath.endsWith('.md')) {
482
+ const lines = text.split('\n');
483
+ if (lines[0]?.trim() !== '---') return null;
484
+ for (let i = 1; i < lines.length; i++) {
485
+ if (lines[i].trim() === '---') return null;
486
+ if (/^version:\s*/.test(lines[i])) {
487
+ lines[i] = `version: ${version}`;
488
+ return lines.join('\n');
489
+ }
490
+ }
491
+ return null;
492
+ }
493
+ if (relPath.endsWith('.toml')) {
494
+ return TOML_VERSION_RE.test(text) ? text.replace(TOML_VERSION_RE, (m, v) => m.replace(v, version)) : null;
495
+ }
496
+ if (relPath.endsWith('.yml') || relPath.endsWith('.yaml')) {
497
+ return YAML_VERSION_RE.test(text) ? text.replace(YAML_VERSION_RE, (m, v) => m.replace(v, version)) : null;
498
+ }
499
+ return null;
500
+ }
501
+
502
+ // Keep-a-Changelog shape, matching what _release.yml's awk already extracts:
503
+ // the notes for a release are the lines under the first `## ` heading
504
+ // containing the version, up to the next `## `.
505
+ export function spliceChangelog(existing, version, notes, date) {
506
+ const heading = `## [${version}] - ${date}`;
507
+ // Plain string matching, deliberately not a constructed regex. Two reasons,
508
+ // and the second is why this is not merely a style preference:
509
+ //
510
+ // 1. It is exactly what _release.yml's awk does — `/^## / && index($0, ver)`
511
+ // — so "would this heading be found at release time?" is answered by the
512
+ // same test that will actually answer it.
513
+ // 2. Building `new RegExp` from `version` escaped only dots, leaving every
514
+ // other metacharacter (`\`, `*`, `+`, `(`, `[`) live. `prepare` rejects a
515
+ // non-semver version before reaching here, but this function is exported
516
+ // and independently callable, so it must not depend on a caller's guard.
517
+ // Found by CodeQL (js/incomplete-sanitization, high) on PR #158.
518
+ const alreadyPresent = existing
519
+ .split('\n')
520
+ .some((line) => line.startsWith('## ') && line.includes(version));
521
+ if (alreadyPresent) {
522
+ return { ok: false, error: `CHANGELOG already has a heading for ${version}` };
523
+ }
524
+ const lines = existing.split('\n');
525
+ // Insert above the first existing release heading, so the newest release is
526
+ // at the top and any preamble (title, Keep-a-Changelog blurb) is preserved.
527
+ const firstHeading = lines.findIndex((l) => /^## /.test(l));
528
+ const block = [heading, '', notes.trim(), ''];
529
+ if (firstHeading === -1) {
530
+ return { ok: true, content: `${existing.trimEnd()}\n\n${block.join('\n')}\n` };
531
+ }
532
+ lines.splice(firstHeading, 0, ...block);
533
+ return { ok: true, content: lines.join('\n') };
534
+ }
535
+
536
+ export const releaseBranchName = (name, version) => `feature/release-${name}-v${version}`;
537
+ const worktreeDir = (name, version) => join(tmpdir(), `shipflow-release-${name}-${version}`);
538
+
539
+ export function prepare(repoPath, config, name, version, notes, { date, featureBranchPrefix } = {}) {
540
+ const component = resolveComponent(repoPath, config, name);
541
+ const devBranch = config?.branches?.dev ?? 'dev';
542
+ const tag = tagFor(component, version);
543
+ const stamp = date ?? new Date().toISOString().slice(0, 10);
544
+
545
+ if (!parseSemver(version)) return { ok: false, error: `"${version}" is not a valid semver version` };
546
+ if (tagExistsLocally(repoPath, tag)) return { ok: false, error: `${tag} already exists — pick a higher version` };
547
+
548
+ const onMain = readVersionAt(repoPath, component, revParse(repoPath, `origin/${config?.branches?.main ?? 'main'}`) ? `origin/${config?.branches?.main ?? 'main'}` : (config?.branches?.main ?? 'main'));
549
+ const onDev = readVersionAt(repoPath, component, revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch);
550
+ for (const [where, read] of [['main', onMain], ['dev', onDev]]) {
551
+ if (read.ok && cmpSemver(version, read.version) <= 0) {
552
+ return { ok: false, error: `${version} is not higher than the ${read.version} already on ${where}` };
553
+ }
554
+ }
555
+
556
+ const branch = releaseBranchName(name, version);
557
+ if (featureBranchPrefix && !branch.startsWith(featureBranchPrefix)) {
558
+ return { ok: false, error: `release branch ${branch} does not start with the configured featureBranchPrefix ${featureBranchPrefix}` };
559
+ }
560
+ const dir = worktreeDir(name, version);
561
+
562
+ // A leftover worktree from an aborted run must not silently become the base
563
+ // for this one — remove it, then re-create from the CURRENT dev.
564
+ rmSync(dir, { recursive: true, force: true });
565
+ git(['worktree', 'prune'], { cwd: repoPath });
566
+ git(['branch', '-D', branch], { cwd: repoPath });
567
+ const base = revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch;
568
+ const added = git(['worktree', 'add', '-b', branch, dir, base], { cwd: repoPath });
569
+ if (added.status !== 0) return { ok: false, error: `git worktree add failed: ${added.stderr}` };
570
+
571
+ const changed = [];
572
+ try {
573
+ for (const relPath of component.versionFiles) {
574
+ const abs = join(dir, relPath);
575
+ if (!existsSync(abs)) continue;
576
+ const before = readFileCapped(abs);
577
+ const after = writeVersionInto(relPath, before, version);
578
+ if (after === null) {
579
+ return { ok: false, error: `could not find a version field to rewrite in ${relPath}` };
580
+ }
581
+ if (after !== before) {
582
+ writeFileSync(abs, after);
583
+ changed.push(relPath);
584
+ }
585
+ }
586
+ if (changed.length === 0) {
587
+ return { ok: false, error: `no version file was changed — is ${version} already the version on ${devBranch}?` };
588
+ }
589
+
590
+ const clAbs = join(dir, component.changelog);
591
+ if (!existsSync(clAbs)) return { ok: false, error: `${component.changelog} does not exist` };
592
+ const spliced = spliceChangelog(readFileCapped(clAbs), version, notes, stamp);
593
+ if (!spliced.ok) return { ok: false, error: spliced.error };
594
+ writeFileSync(clAbs, spliced.content);
595
+ changed.push(component.changelog);
596
+
597
+ // Explicit pathspecs, never `git add -A`. The worktree should contain
598
+ // nothing else, but "should" is not a guarantee worth a release commit.
599
+ const staged = git(['add', '--', ...changed], { cwd: dir });
600
+ if (staged.status !== 0) return { ok: false, error: `git add failed: ${staged.stderr}` };
601
+ const committed = git(['commit', '-m', `chore(${name}): release v${version}`], { cwd: dir });
602
+ if (committed.status !== 0) return { ok: false, error: `git commit failed: ${committed.stderr}` };
603
+
604
+ const diff = git(['show', '--stat', '--format=', 'HEAD'], { cwd: dir });
605
+ return { ok: true, branch, worktree: dir, tag, version, changed, diffstat: diff.stdout };
606
+ } catch (e) {
607
+ return { ok: false, error: String(e?.message ?? e) };
608
+ }
609
+ }
610
+
611
+ // ─── cut ─────────────────────────────────────────────────────────────────────
612
+ // Resumable and bounded on purpose. The full path (feature PR → checks → merge
613
+ // → promotion → auto-merge → release run → tag) routinely takes longer than a
614
+ // single tool call is allowed to block for, so cut() advances as far as it can
615
+ // within `waitSeconds`, then returns the stage it is parked at. Calling it
616
+ // again picks up from wherever the remote actually is — it derives every stage
617
+ // from live state, never from a local record of what a previous call did, so an
618
+ // interrupted run and a fresh one are the same code path.
619
+ const STAGES = ['push', 'feature-pr', 'feature-merged', 'promotion-open', 'promotion-merged', 'tag'];
620
+
621
+ function prNumberFor(ownerRepo, head, base) {
622
+ const r = ghApiJson(`repos/${ownerRepo}/pulls?head=${encodeURIComponent(head)}&base=${encodeURIComponent(base)}&state=open`);
623
+ if (!r.ok) return null;
624
+ return r.data?.[0]?.number ?? null;
625
+ }
626
+
627
+ function sleepSync(ms) {
628
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
629
+ }
630
+
631
+ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHash = null, skipHashCheck = false, ownerRepo, pollSeconds = 15 } = {}) {
632
+ const component = resolveComponent(repoPath, config, name);
633
+ const mainBranch = config?.branches?.main ?? 'main';
634
+ const devBranch = config?.branches?.dev ?? 'dev';
635
+ const owner = ownerRepo.split('/')[0];
636
+
637
+ const status = readStatus(repoPath, config, name);
638
+ if (!skipHashCheck) {
639
+ if (!expectStatusHash) {
640
+ 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.' };
641
+ }
642
+ if (expectStatusHash !== status.statusHash) {
643
+ 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 };
644
+ }
645
+ }
646
+
647
+ const targetVersion = status.versionOnDev ?? status.versionOnMain;
648
+ const tag = tagFor(component, targetVersion);
649
+ const branch = releaseBranchName(name, targetVersion);
650
+ const deadline = Date.now() + waitSeconds * 1000;
651
+ const log = [];
652
+ const note = (stage, msg) => log.push({ stage, msg });
653
+
654
+ // Fast path: the bump is already on main and simply was never tagged (a
655
+ // failed or cancelled push run). No PR is needed at all — dispatch and prove.
656
+ if (status.state === 'untagged-bump-on-main') {
657
+ const already = tagExistsOnRemote(repoPath, tagFor(component, status.versionOnMain));
658
+ if (already.ok && already.exists) {
659
+ return { ok: true, done: true, stage: 'tag', tag: tagFor(component, status.versionOnMain), note: 'already released' };
660
+ }
661
+ const d = spawnArgs('gh', ['workflow', 'run', component.workflowFile, '--ref', mainBranch, '--repo', ownerRepo]);
662
+ if (d.status !== 0) return { ok: false, error: `workflow dispatch failed: ${d.stderr}` };
663
+ note('dispatch', `dispatched ${component.workflowFile} on ${mainBranch}`);
664
+ return waitForTag(repoPath, tagFor(component, status.versionOnMain), deadline, pollSeconds, log, ownerRepo, null);
665
+ }
666
+
667
+ // 1. push the prepared branch
668
+ if (!revParse(repoPath, branch)) {
669
+ return { ok: false, error: `branch ${branch} does not exist — run release-prepare first` };
670
+ }
671
+ const dir = worktreeDir(name, targetVersion);
672
+ const pushCwd = existsSync(dir) ? dir : repoPath;
673
+ if (!revParse(repoPath, `origin/${branch}`)) {
674
+ const pushed = git(['push', '-u', 'origin', branch], { cwd: pushCwd });
675
+ if (pushed.status !== 0) return { ok: false, error: `git push failed: ${pushed.stderr}` };
676
+ note('push', `pushed ${branch}`);
677
+ }
678
+
679
+ // 2. open the feature → dev PR
680
+ let featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, devBranch);
681
+ if (!featurePr) {
682
+ const devHasIt = readVersionAt(repoPath, component, `origin/${devBranch}`);
683
+ if (devHasIt.ok && cmpSemver(devHasIt.version, targetVersion) >= 0) {
684
+ note('feature-merged', `${targetVersion} is already on ${devBranch}`);
685
+ } else {
686
+ const created = spawnArgs('gh', [
687
+ 'pr', 'create', '--repo', ownerRepo, '--base', devBranch, '--head', branch,
688
+ '--title', `chore(${name}): release v${targetVersion}`,
689
+ '--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.`,
690
+ ]);
691
+ if (created.status !== 0) return { ok: false, error: `gh pr create failed: ${created.stderr}` };
692
+ featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, devBranch);
693
+ note('feature-pr', `opened #${featurePr}`);
694
+ }
695
+ }
696
+
697
+ // 3. wait for its checks, then squash it into dev
698
+ if (featurePr) {
699
+ const gate = waitForChecks(ownerRepo, featurePr, deadline, pollSeconds, log);
700
+ if (!gate.ok) return gate;
701
+ if (!gate.done) return { ok: true, done: false, stage: 'feature-pr', featurePr, tag, log, next: 'call release-cut again — waiting on the feature PR’s checks' };
702
+ const method = config?.mergeMethod?.featureToDevMethod ?? 'squash';
703
+ const merged = spawnArgs('gh', ['pr', 'merge', String(featurePr), '--repo', ownerRepo, `--${method}`, '--delete-branch']);
704
+ if (merged.status !== 0) return { ok: false, error: `gh pr merge failed on the feature PR: ${merged.stderr}` };
705
+ note('feature-merged', `merged #${featurePr} into ${devBranch} (${method})`);
706
+ rmSync(dir, { recursive: true, force: true });
707
+ git(['worktree', 'prune'], { cwd: repoPath });
708
+ }
709
+
710
+ // 4. open (or find) the dev → main promotion. shipflow's rendered auto-merge
711
+ // workflow turns on native auto-merge from here; nothing polls for it.
712
+ git(['fetch', 'origin', '--prune'], { cwd: repoPath });
713
+ let promotion = prNumberFor(ownerRepo, `${owner}:${devBranch}`, mainBranch);
714
+ if (!promotion) {
715
+ const created = spawnArgs('gh', [
716
+ 'pr', 'create', '--repo', ownerRepo, '--base', mainBranch, '--head', devBranch,
717
+ '--title', `release: ${name} v${targetVersion}`,
718
+ '--body', releaseBody(name, targetVersion, status.collateral),
719
+ ]);
720
+ if (created.status !== 0) return { ok: false, error: `gh pr create failed on the promotion: ${created.stderr}` };
721
+ promotion = prNumberFor(ownerRepo, `${owner}:${devBranch}`, mainBranch);
722
+ note('promotion-open', `opened promotion #${promotion}`);
723
+ } else {
724
+ note('promotion-open', `promotion #${promotion} already open`);
725
+ }
726
+
727
+ // 5. wait for the promotion to auto-merge, then for the tag to appear
728
+ const landed = waitForMerge(ownerRepo, promotion, deadline, pollSeconds, log);
729
+ if (!landed.ok) return landed;
730
+ if (!landed.done) {
731
+ return { ok: true, done: false, stage: 'promotion-open', promotion, tag, log, next: 'call release-cut again — waiting on the promotion to auto-merge' };
732
+ }
733
+ return waitForTag(repoPath, tag, deadline, pollSeconds, log, ownerRepo, promotion);
734
+ }
735
+
736
+ function releaseBody(name, version, collateral) {
737
+ const extra = collateral.length
738
+ ? `\n\n**This promotion also releases:** ${collateral.map((c) => `\`${c.tag}\``).join(', ')} — a promotion is atomic and carries all of dev.`
739
+ : '';
740
+ return `Promotes \`${name}\` v${version} to main.${extra}`;
741
+ }
742
+
743
+ function waitForChecks(ownerRepo, prNumber, deadline, pollSeconds, log) {
744
+ for (;;) {
745
+ const r = ghApiJson(`repos/${ownerRepo}/pulls/${prNumber}`);
746
+ if (!r.ok) return { ok: false, error: `could not read PR #${prNumber}: ${r.stderr}` };
747
+ const sha = r.data?.head?.sha;
748
+ const cr = ghApiJson(`repos/${ownerRepo}/commits/${sha}/check-runs?per_page=100`);
749
+ if (!cr.ok) return { ok: false, error: `could not read check runs: ${cr.stderr}` };
750
+ const runs = cr.data?.check_runs ?? [];
751
+ const pending = runs.filter((c) => c.status !== 'completed');
752
+ const failed = runs.filter((c) => c.status === 'completed' && !['success', 'neutral', 'skipped'].includes(c.conclusion));
753
+ if (failed.length > 0) {
754
+ return { ok: false, error: `checks failed on PR #${prNumber}: ${failed.map((c) => c.name).join(', ')} — fix them, then call release-cut again` };
755
+ }
756
+ if (runs.length > 0 && pending.length === 0) {
757
+ log.push({ stage: 'feature-pr', msg: `${runs.length} checks green` });
758
+ return { ok: true, done: true };
759
+ }
760
+ if (Date.now() + pollSeconds * 1000 > deadline) {
761
+ log.push({ stage: 'feature-pr', msg: `${pending.length}/${runs.length} checks still running` });
762
+ return { ok: true, done: false };
763
+ }
764
+ sleepSync(pollSeconds * 1000);
765
+ }
766
+ }
767
+
768
+ function waitForMerge(ownerRepo, prNumber, deadline, pollSeconds, log) {
769
+ for (;;) {
770
+ const r = ghApiJson(`repos/${ownerRepo}/pulls/${prNumber}`);
771
+ if (!r.ok) return { ok: false, error: `could not read PR #${prNumber}: ${r.stderr}` };
772
+ if (r.data?.merged === true) {
773
+ log.push({ stage: 'promotion-merged', msg: `#${prNumber} merged` });
774
+ return { ok: true, done: true };
775
+ }
776
+ if (r.data?.state === 'closed') {
777
+ return { ok: false, error: `promotion #${prNumber} was closed without merging` };
778
+ }
779
+ if (Date.now() + pollSeconds * 1000 > deadline) return { ok: true, done: false };
780
+ sleepSync(pollSeconds * 1000);
781
+ }
782
+ }
783
+
784
+ // The one thing that counts. Not the dispatch, not the merge, not a green
785
+ // check — the tag, fetched back from origin.
786
+ function waitForTag(repoPath, tag, deadline, pollSeconds, log, ownerRepo, promotionPr) {
787
+ for (;;) {
788
+ const t = tagExistsOnRemote(repoPath, tag);
789
+ if (t.ok && t.exists) {
790
+ log.push({ stage: 'tag', msg: `${tag} exists on origin` });
791
+ const rel = ghApiJson(`repos/${ownerRepo}/releases/tags/${tag}`);
792
+ // Clearing the label is what stops `shipflow releases` resurfacing a
793
+ // promotion this command already released, forever.
794
+ let labelCleared = null;
795
+ if (promotionPr) {
796
+ const c = ghApiJson(`repos/${ownerRepo}/issues/${promotionPr}/labels/release-pending`, ['-X', 'DELETE']);
797
+ labelCleared = c.ok;
798
+ }
799
+ return {
800
+ ok: true, done: true, stage: 'tag', tag,
801
+ releaseUrl: rel.ok ? rel.data?.html_url ?? null : null,
802
+ releaseNotes: rel.ok ? rel.data?.body ?? null : null,
803
+ labelCleared, log,
804
+ };
805
+ }
806
+ if (Date.now() + pollSeconds * 1000 > deadline) {
807
+ return { ok: true, done: false, stage: 'promotion-merged', tag, log, next: `call release-cut again — ${tag} is not on origin yet` };
808
+ }
809
+ sleepSync(pollSeconds * 1000);
810
+ }
811
+ }
812
+
813
+ export { STAGES };