@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 +77 -0
- package/SKILL.md +33 -11
- package/bin/shipflow.js +3 -1
- package/lib/release.mjs +150 -15
- package/package.json +1 -1
- package/skill-invariants.json +13 -0
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
187
|
-
|
|
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
|
|
199
|
-
one call should block for. Each call advances as far as it can, then returns the
|
|
200
|
-
parked at and a `next` line. **Call it again, unchanged, until `done: true`.** It
|
|
201
|
-
stage from live remote state and never from a record of what a previous call did,
|
|
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.
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
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
|
-
|
|
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 (
|
|
657
|
-
const already = tagExistsOnRemote(repoPath,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
package/skill-invariants.json
CHANGED
|
@@ -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
|
}
|