@natjswenson/shipflow 0.4.0 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,83 @@
2
2
 
3
3
  All notable changes to `@natjswenson/shipflow` are documented here.
4
4
 
5
+ ## 0.6.0 (2026-08-03) — the ambiguous fast path is refused, not guessed
6
+
7
+ ### Fixed
8
+
9
+ - **`release-cut`'s fast path could silently tag the OLDER version.** When a
10
+ component's `main` carried an untagged bump *and* `dev` independently carried
11
+ something higher — `lastTag < main < dev` — `readStatus` collapsed both facts
12
+ into the single `untagged-bump-on-main` state, and `cut()`'s fast path acted
13
+ on that state alone, dispatching a release for whatever sat on `main` while
14
+ the version actually being released sat, unread, on `dev`. Hit for real
15
+ during `/release eval` on 2026-08-03: `main` was at 0.2.1, `dev` at 0.3.0 —
16
+ `cut` would have tagged `eval-v0.2.1` and reported success. Caught only by
17
+ reading `release.mjs` before running the irreversible step.
18
+
19
+ `readStatus` now reports a `devAhead` fact (`{ version, aheadOfMain: true }`)
20
+ independently of `state` — including on a component's never-released first
21
+ bump, the sibling case a fix scoped only to the existing `state` branch would
22
+ have missed — plus a `dev-ahead-of-main` blocker, scoped to exactly the state
23
+ where the fast path is armed. A new pure `resolveReleaseTarget(status,
24
+ requestedVersion)` is the single place the release target is now decided;
25
+ `cut()` calls it once, before any network call, and refuses outright when
26
+ the target is ambiguous, naming both versions. There is no longer a code
27
+ path on which the tag `cut` waits for can disagree with the version it
28
+ decided to release.
29
+
30
+ The refusal is escapable, deliberately not inescapable: `release-cut` gains
31
+ `--version <x.y.z>`, a *confirmation* rather than a bypass — it is only ever
32
+ accepted when it names a version already present on `main` or `dev` in that
33
+ status, so there is no value of it that releases a version which isn't
34
+ actually on the branch being dispatched.
35
+
36
+ ## 0.5.0 (2026-08-02) — the merge stops cutting tags; the dispatch is the release
37
+
38
+ ### Changed
39
+
40
+ - **`release-cut` now dispatches the component's release workflow itself, after
41
+ the promotion lands.** Previously it merged the promotion and then *waited*
42
+ for a tag that a `push`-triggered job happened to cut. That made a merge the
43
+ real release trigger, which meant any promotion released everything bumped on
44
+ `dev` — whether or not anyone asked, and irreversibly for skills that publish
45
+ to npm.
46
+
47
+ Paired with every caller's `release` job becoming `workflow_dispatch`-only,
48
+ this makes the dispatch the **single point at which a tag is ever created**.
49
+ A `dev → main` merge now moves a version bump to `main` and stops there; the
50
+ component simply becomes `untagged-bump-on-main` until someone releases it on
51
+ purpose.
52
+
53
+ The two halves are load-bearing together. Removing the `push` gate without
54
+ this dispatch leaves `release-cut` waiting forever for a tag nobody cuts;
55
+ adding the dispatch without removing the gate double-releases.
56
+
57
+ - **`collateral` means something smaller and safer now.** It still lists every
58
+ other component whose bump the same promotion moves to `main` — that is
59
+ unavoidable, a promotion is atomic — but those components are no longer
60
+ *released* by it. The disclosure stays because the user should know what their
61
+ promotion moves, and which components are now one dispatch from a release
62
+ nobody asked for.
63
+
64
+ ### Fixed
65
+
66
+ - **`release-status` returned a wrong commit list in a shallow clone, silently.**
67
+ `git log <tag>..<ref>` excludes everything reachable from `<tag>`, and that
68
+ exclusion needs full ancestry. In a grafted history it under-applies, so the
69
+ range returns commits that shipped long ago — without erroring, and therefore
70
+ with a `suggestedBump` derived from fiction.
71
+
72
+ Observed on this repo the day 0.4.0 shipped: a depth-1 checkout of `main`
73
+ reported **1 unreleased commit** for a component a full clone correctly
74
+ reported as **0**, which would have proposed a patch release for nothing.
75
+ `actions/checkout` is depth-1 by default, so any CI job calling `release-status`
76
+ hit this.
77
+
78
+ A shallow repository is now a **blocker**, not a note — every number derived
79
+ from the commit range is untrustworthy, so the honest answer is to refuse and
80
+ say `git fetch --unshallow`, rather than to report a plausible wrong one.
81
+
5
82
  ## 0.4.0 (2026-08-02) — release one named thing, and prove the tag exists
6
83
 
7
84
  ### Added
package/SKILL.md CHANGED
@@ -164,15 +164,23 @@ at all and `--component` may be omitted.
164
164
  - `clean` — the released version is what's on main. A bump is needed: go to step 2.
165
165
  - `untagged-bump-on-main` — the bump is already on main and was never tagged (a cancelled or
166
166
  failed release run). **No PR is needed** — `release-cut` dispatches and verifies. Skip to step 3.
167
+ **`untagged-bump-on-main` is not, by itself, permission to cut.** Check `devAhead` first: if
168
+ it is set, dev already carries a *higher* version than what's on main, and cutting here would
169
+ tag the version on main, not the one on dev — the version you almost certainly mean to
170
+ release. `release-cut` refuses in this shape unless you pass `--version` naming exactly which
171
+ one to release (see step 3); it never guesses.
167
172
  - `bump-on-dev-unpromoted` — the bump is on dev, waiting for a promotion. Skip to step 3.
168
173
  - `version-behind-tag` — main carries a *lower* version than an existing tag. Stop and ask;
169
174
  this means a tag was cut from something other than main, and guessing is how it gets worse.
170
175
 
171
176
  2. **Show the user `collateral`, `blockers` and the proposed version, and wait.**
172
- **`collateral` is not advisory.** A `dev → main` promotion is atomic and carries all of dev, so
173
- every component listed there is released by the same promotion "release devlog" really does
174
- also release them. **Never run `release-cut` without naming that list to the user first.**
175
- Releasing something the user did not ask for is the worst thing this flow can do.
177
+ A `dev → main` promotion is atomic and carries all of dev, so every component listed under
178
+ `collateral` has its bump moved to `main` by the same promotion. It is **not released** by
179
+ that every caller's release job is `workflow_dispatch`-only, so merging tags nothing; each
180
+ becomes `untagged-bump-on-main`, one deliberate `release-cut` away from a tag.
181
+ **Never run `release-cut` without naming that list to the user first.** They should know what
182
+ their promotion moves, and which components are now one dispatch from a release nobody asked
183
+ for.
176
184
 
177
185
  `suggestedBump` is a suggestion. The user decides, and a `suggestedBumpCapped: true` means a
178
186
  breaking change was held at minor because the component is still 0.x — going to 1.0.0 is a
@@ -183,8 +191,9 @@ at all and `--component` may be omitted.
183
191
  ```
184
192
  Local only, no network. It works in a **throwaway git worktree**, so unrelated uncommitted work
185
193
  in the user's tree is untouched and cannot be swept into the release commit. The version bump
186
- and the CHANGELOG entry land in **one commit**, because releases are publish-on-merge a
187
- follow-up promotion to fix release notes is too late, the tag is already cut.
194
+ and the CHANGELOG entry land in **one commit** the notes are read off `main` at dispatch
195
+ time, so a CHANGELOG that lands in a later promotion than its version is notes the release
196
+ will never carry.
188
197
 
189
198
  3. **Cut it, and prove it.**
190
199
  ```
@@ -194,12 +203,25 @@ at all and `--component` may be omitted.
194
203
  `--expect-status-hash` is mandatory (same TOCTOU discipline as `apply`'s `--expect-state-hash`);
195
204
  `--skip-hash-check` is a named escape hatch, never a default.
196
205
 
206
+ If step 1's `devAhead` was set, `release-cut` refuses outright with an error naming both
207
+ versions — this is the ambiguous three-way state (main has an untagged bump, dev already
208
+ carries something higher) where guessing would tag the wrong one. Promote `dev → main` and
209
+ re-run `release-status` to release what's on dev (the normal recovery), **or** add
210
+ `--version <x.y.z>` naming exactly the version on main, if you deliberately mean to release
211
+ that one and leave dev's higher version for later. `--version` is a confirmation, not a
212
+ bypass — it is only ever accepted when it matches a version already on main or dev; anything
213
+ else is refused the same as passing nothing.
214
+
197
215
  **`release-cut` is resumable and bounded, and it will usually return `done: false`.** The full
198
- path — feature PR, checks, merge, promotion, auto-merge, release run, tag — takes longer than
199
- one call should block for. Each call advances as far as it can, then returns the `stage` it is
200
- parked at and a `next` line. **Call it again, unchanged, until `done: true`.** It derives every
201
- stage from live remote state and never from a record of what a previous call did, so a resumed
202
- run and a fresh one are the same code path.
216
+ path — feature PR, checks, merge, promotion, auto-merge, **dispatch**, release run, tag — takes
217
+ longer than one call should block for. Each call advances as far as it can, then returns the
218
+ `stage` it is parked at and a `next` line. **Call it again, unchanged, until `done: true`.** It
219
+ derives every stage from live remote state and never from a record of what a previous call did,
220
+ so a resumed run and a fresh one are the same code path.
221
+
222
+ **The promotion merging cuts nothing.** `release-cut` dispatches the component's release
223
+ workflow itself, after the promotion lands — that dispatch is the single point at which any tag
224
+ is created in this repo, which is why a merge can no longer surprise anyone with a release.
203
225
 
204
226
  4. **Report the tag, and only the tag.** `done: true` carries `tag` and `releaseUrl`, read back
205
227
  from origin. A dispatched workflow, a merged PR and a green check are **not** a release —
package/bin/shipflow.js CHANGED
@@ -336,6 +336,7 @@ function cmdReleaseCut(args) {
336
336
  'expect-status-hash': { type: 'string' },
337
337
  'skip-hash-check': { type: 'boolean', default: false },
338
338
  wait: { type: 'string' },
339
+ version: { type: 'string' },
339
340
  },
340
341
  });
341
342
  const resolved = resolveReleaseArgs(values, 'release-cut');
@@ -348,6 +349,7 @@ function cmdReleaseCut(args) {
348
349
  expectStatusHash: values['expect-status-hash'] ?? null,
349
350
  skipHashCheck: values['skip-hash-check'],
350
351
  ownerRepo,
352
+ version: values.version ?? null,
351
353
  });
352
354
  if (!result.ok) return fail(`release-cut: ${result.error}${result.currentStatusHash ? ` (current statusHash: ${result.currentStatusHash})` : ''}`);
353
355
  printJson(result);
@@ -388,7 +390,7 @@ Commands:
388
390
  release-dispatch --repo <path> --pr <number> --workflow-file <file>... --ref <ref>
389
391
  release-status --repo <path> [--component <name>]
390
392
  release-prepare --repo <path> [--component <name>] --version <x.y.z> --notes-file <path> [--date <YYYY-MM-DD>]
391
- release-cut --repo <path> [--component <name>] (--expect-status-hash <hash> | --skip-hash-check) [--wait <seconds>]
393
+ release-cut --repo <path> [--component <name>] (--expect-status-hash <hash> | --skip-hash-check) [--wait <seconds>] [--version <x.y.z>]
392
394
  rename-default-branch --repo <path> --branch <current-name> --to <new-name>
393
395
 
394
396
  Every command prints JSON to stdout.`);
package/lib/release.mjs CHANGED
@@ -337,11 +337,19 @@ function dirtyPaths(repoPath, relPaths) {
337
337
  return r.stdout.split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
338
338
  }
339
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.
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.
345
353
  export function collateralComponents(repoPath, config, exceptName, devRef) {
346
354
  const out = [];
347
355
  for (const name of listComponentNames(config, repoPath)) {
@@ -380,6 +388,21 @@ export function readStatus(repoPath, config, name) {
380
388
 
381
389
  const blockers = [];
382
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
+ }
383
406
  if (!fetched || fetched.status !== 0) {
384
407
  notes.push(`could not fetch origin (${fetched?.stderr || 'unknown error'}) — versions and tags below may be stale`);
385
408
  }
@@ -419,6 +442,33 @@ export function readStatus(repoPath, config, name) {
419
442
  state = 'untagged-bump-on-main'; // never released; whatever is on main is the first release
420
443
  }
421
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
+
422
472
  const since = commitsSince(repoPath, component, lastTag, mainRef);
423
473
  const suggestion = suggestBump(since.commits, onMain.version ?? '0.0.0');
424
474
  const nextVersion = suggestion.bump && onMain.ok ? bumpSemver(onMain.version, suggestion.bump) : null;
@@ -453,6 +503,7 @@ export function readStatus(repoPath, config, name) {
453
503
  state,
454
504
  versionOnMain: onMain.version,
455
505
  versionOnDev: onDev.version,
506
+ devAhead,
456
507
  versionSources: onMain.sources,
457
508
  lastTag,
458
509
  commits: since.commits,
@@ -608,6 +659,55 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
608
659
  }
609
660
  }
610
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
+
611
711
  // ─── cut ─────────────────────────────────────────────────────────────────────
612
712
  // Resumable and bounded on purpose. The full path (feature PR → checks → merge
613
713
  // → promotion → auto-merge → release run → tag) routinely takes longer than a
@@ -628,7 +728,7 @@ function sleepSync(ms) {
628
728
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
629
729
  }
630
730
 
631
- export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHash = null, skipHashCheck = false, ownerRepo, pollSeconds = 15 } = {}) {
731
+ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHash = null, skipHashCheck = false, ownerRepo, pollSeconds = 15, version = null } = {}) {
632
732
  const component = resolveComponent(repoPath, config, name);
633
733
  const mainBranch = config?.branches?.main ?? 'main';
634
734
  const devBranch = config?.branches?.dev ?? 'dev';
@@ -644,7 +744,14 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
644
744
  }
645
745
  }
646
746
 
647
- const targetVersion = status.versionOnDev ?? status.versionOnMain;
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;
648
755
  const tag = tagFor(component, targetVersion);
649
756
  const branch = releaseBranchName(name, targetVersion);
650
757
  const deadline = Date.now() + waitSeconds * 1000;
@@ -653,15 +760,16 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
653
760
 
654
761
  // Fast path: the bump is already on main and simply was never tagged (a
655
762
  // 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));
763
+ if (target.via === 'dispatch-on-main') {
764
+ const already = tagExistsOnRemote(repoPath, tag);
658
765
  if (already.ok && already.exists) {
659
- return { ok: true, done: true, stage: 'tag', tag: tagFor(component, status.versionOnMain), note: 'already released' };
766
+ return { ok: true, done: true, stage: 'tag', tag, targetVersion, note: 'already released' };
660
767
  }
661
768
  const d = spawnArgs('gh', ['workflow', 'run', component.workflowFile, '--ref', mainBranch, '--repo', ownerRepo]);
662
769
  if (d.status !== 0) return { ok: false, error: `workflow dispatch failed: ${d.stderr}` };
663
770
  note('dispatch', `dispatched ${component.workflowFile} on ${mainBranch}`);
664
- return waitForTag(repoPath, tagFor(component, status.versionOnMain), deadline, pollSeconds, log, ownerRepo, null);
771
+ const result = waitForTag(repoPath, tag, deadline, pollSeconds, log, ownerRepo, null);
772
+ return { ...result, targetVersion };
665
773
  }
666
774
 
667
775
  // 1. push the prepared branch
@@ -698,7 +806,7 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
698
806
  if (featurePr) {
699
807
  const gate = waitForChecks(ownerRepo, featurePr, deadline, pollSeconds, log);
700
808
  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' };
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' };
702
810
  const method = config?.mergeMethod?.featureToDevMethod ?? 'squash';
703
811
  const merged = spawnArgs('gh', ['pr', 'merge', String(featurePr), '--repo', ownerRepo, `--${method}`, '--delete-branch']);
704
812
  if (merged.status !== 0) return { ok: false, error: `gh pr merge failed on the feature PR: ${merged.stderr}` };
@@ -728,14 +836,41 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
728
836
  const landed = waitForMerge(ownerRepo, promotion, deadline, pollSeconds, log);
729
837
  if (!landed.ok) return landed;
730
838
  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' };
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`);
732
863
  }
733
- return waitForTag(repoPath, tag, deadline, pollSeconds, log, ownerRepo, promotion);
864
+ const result = waitForTag(repoPath, tag, deadline, pollSeconds, log, ownerRepo, promotion);
865
+ return { ...result, targetVersion };
734
866
  }
735
867
 
736
868
  function releaseBody(name, version, collateral) {
737
869
  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.`
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.`
739
874
  : '';
740
875
  return `Promotes \`${name}\` v${version} to main.${extra}`;
741
876
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natjswenson/shipflow",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Scaffold a configurable dev/main branching, auto-merge, branch-cleanup, and release-tagging workflow into any repo",
5
5
  "license": "MIT",
6
6
  "author": "Nate Swenson",
@@ -105,6 +105,11 @@
105
105
  "id": "ambiguous-pattern-no-silent-pick",
106
106
  "pattern": "present all 3 (templates|patterns).{0,60}ask the user to choose",
107
107
  "rationale": "Ambiguous/greenfield autodetection must never silently pick a workflow pattern — mirrors the existing protectionOwner disambiguation precedent (ambiguous-protection-owner-prompt)."
108
+ },
109
+ {
110
+ "id": "untagged-bump-is-not-permission-to-cut",
111
+ "pattern": "is not, by itself, permission to cut",
112
+ "rationale": "#173: `untagged-bump-on-main` collapsed two independently-true facts (main has an untagged bump; dev already carries something higher) into one state string, and the fast path acted on it without checking which one was true — silently tagging the OLDER version while a human meant the one on dev. This line is what stops an agent (or a human skimming the state table) from treating the state name alone as a green light; `devAhead` and the refusal it drives are the actual gate."
108
113
  }
109
114
  ],
110
115
  "cli_commands_referenced": ["detect", "plan", "apply", "releases", "release-dispatch", "release-status", "release-prepare", "release-cut", "rename-default-branch"],
@@ -120,6 +125,14 @@
120
125
  ],
121
126
  "update_command": "node evals/baseline/update.mjs",
122
127
  "rationale": "This monorepo dogfoods shipflow on itself, so .github/shipflow.json and the workflow rendered from it are a genuine input/output pair from a real `apply` run, and that config's renderedTemplateHashes is the receipt shipflow wrote at the time. The baseline re-runs config -> params -> render and asserts byte equality against the frozen golden, that the golden's sha256 still equals the recorded receipt, and that the frozen golden still equals the repo's live committed workflow (so the fixture cannot quietly go stale). Byte-exactness is correct here and nowhere else in the baseline suite: for a workflow file, one changed character is a behavior change to the repo's merge automation. The paired negative assertions (quote injection rejected, missing param throws, merge method actually reaches the output) stop the golden from passing while the validators rot -- the missing-param one found a real bug on its first run: a present-but-undefined param rendered the literal string 'undefined' into `branches: [...]`, installing a workflow that could never fire."
128
+ },
129
+ {
130
+ "id": "dev-ahead-fast-path-refused",
131
+ "kind": "trap",
132
+ "test": "tests/release.test.mjs",
133
+ "fixtures": [],
134
+ "update_command": "these are regression tests built inline against a real temp git repo (makeThreeWayRepo / makeUnpromotedRepo) — nothing to refresh",
135
+ "rationale": "#173: release-cut's fast path used to act on `state` alone, so when main carried an untagged bump AND dev independently carried something higher, it silently tagged the OLDER version (main's) while reporting success -- hit for real during `/release eval` on 2026-08-03. Pinned against the exact reported shape (lastTag < main < dev) plus its D2 sibling (the same ambiguity on a component's never-released first bump, which a fix scoped only to the `cmpSemver > 0` branch would leave exposed). The known-bad half: cut() must refuse before any dispatch is ever reachable, naming both versions; --version must be a confirmation validated against what is actually on the named branch, never a bare bypass; and the dev-ahead-of-main blocker must stay two-sided -- absent in `clean` and in `bump-on-dev-unpromoted` (where devAhead is normal, expected shape, not an ambiguity), or a blocker that is always on for those states stops being read."
123
136
  }
124
137
  ]
125
138
  }