@natjswenson/shipflow 0.4.0 → 0.7.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,93 @@
2
2
 
3
3
  All notable changes to `@natjswenson/shipflow` are documented here.
4
4
 
5
+ ## 0.7.0 (2026-09-17)
6
+
7
+ ### Added
8
+
9
+ - Added guarded main-only policy and GitHub Flow support.
10
+
11
+ ### Fixed
12
+
13
+ - Hardened draft/fork pull request handling and legacy release recovery.
14
+
15
+ ## 0.6.0 (2026-08-03) — the ambiguous fast path is refused, not guessed
16
+
17
+ ### Fixed
18
+
19
+ - **`release-cut`'s fast path could silently tag the OLDER version.** When a
20
+ component's `main` carried an untagged bump *and* `dev` independently carried
21
+ something higher — `lastTag < main < dev` — `readStatus` collapsed both facts
22
+ into the single `untagged-bump-on-main` state, and `cut()`'s fast path acted
23
+ on that state alone, dispatching a release for whatever sat on `main` while
24
+ the version actually being released sat, unread, on `dev`. Hit for real
25
+ during `/release eval` on 2026-08-03: `main` was at 0.2.1, `dev` at 0.3.0 —
26
+ `cut` would have tagged `eval-v0.2.1` and reported success. Caught only by
27
+ reading `release.mjs` before running the irreversible step.
28
+
29
+ `readStatus` now reports a `devAhead` fact (`{ version, aheadOfMain: true }`)
30
+ independently of `state` — including on a component's never-released first
31
+ bump, the sibling case a fix scoped only to the existing `state` branch would
32
+ have missed — plus a `dev-ahead-of-main` blocker, scoped to exactly the state
33
+ where the fast path is armed. A new pure `resolveReleaseTarget(status,
34
+ requestedVersion)` is the single place the release target is now decided;
35
+ `cut()` calls it once, before any network call, and refuses outright when
36
+ the target is ambiguous, naming both versions. There is no longer a code
37
+ path on which the tag `cut` waits for can disagree with the version it
38
+ decided to release.
39
+
40
+ The refusal is escapable, deliberately not inescapable: `release-cut` gains
41
+ `--version <x.y.z>`, a *confirmation* rather than a bypass — it is only ever
42
+ accepted when it names a version already present on `main` or `dev` in that
43
+ status, so there is no value of it that releases a version which isn't
44
+ actually on the branch being dispatched.
45
+
46
+ ## 0.5.0 (2026-08-02) — the merge stops cutting tags; the dispatch is the release
47
+
48
+ ### Changed
49
+
50
+ - **`release-cut` now dispatches the component's release workflow itself, after
51
+ the promotion lands.** Previously it merged the promotion and then *waited*
52
+ for a tag that a `push`-triggered job happened to cut. That made a merge the
53
+ real release trigger, which meant any promotion released everything bumped on
54
+ `dev` — whether or not anyone asked, and irreversibly for skills that publish
55
+ to npm.
56
+
57
+ Paired with every caller's `release` job becoming `workflow_dispatch`-only,
58
+ this makes the dispatch the **single point at which a tag is ever created**.
59
+ A `dev → main` merge now moves a version bump to `main` and stops there; the
60
+ component simply becomes `untagged-bump-on-main` until someone releases it on
61
+ purpose.
62
+
63
+ The two halves are load-bearing together. Removing the `push` gate without
64
+ this dispatch leaves `release-cut` waiting forever for a tag nobody cuts;
65
+ adding the dispatch without removing the gate double-releases.
66
+
67
+ - **`collateral` means something smaller and safer now.** It still lists every
68
+ other component whose bump the same promotion moves to `main` — that is
69
+ unavoidable, a promotion is atomic — but those components are no longer
70
+ *released* by it. The disclosure stays because the user should know what their
71
+ promotion moves, and which components are now one dispatch from a release
72
+ nobody asked for.
73
+
74
+ ### Fixed
75
+
76
+ - **`release-status` returned a wrong commit list in a shallow clone, silently.**
77
+ `git log <tag>..<ref>` excludes everything reachable from `<tag>`, and that
78
+ exclusion needs full ancestry. In a grafted history it under-applies, so the
79
+ range returns commits that shipped long ago — without erroring, and therefore
80
+ with a `suggestedBump` derived from fiction.
81
+
82
+ Observed on this repo the day 0.4.0 shipped: a depth-1 checkout of `main`
83
+ reported **1 unreleased commit** for a component a full clone correctly
84
+ reported as **0**, which would have proposed a patch release for nothing.
85
+ `actions/checkout` is depth-1 by default, so any CI job calling `release-status`
86
+ hit this.
87
+
88
+ A shallow repository is now a **blocker**, not a note — every number derived
89
+ from the commit range is untrustworthy, so the honest answer is to refuse and
90
+ say `git fetch --unshallow`, rather than to report a plausible wrong one.
91
+
5
92
  ## 0.4.0 (2026-08-02) — release one named thing, and prove the tag exists
6
93
 
7
94
  ### Added
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # shipflow
2
2
 
3
- <!-- >>> press:masthead v0.9.0 sha256:2c0f2d9aea4a GENERATED by @natjswenson/press, do not edit -->
4
- **NS** · NATE SWENSON · CLAUDE CODE SKILL · PRESS v0.9.0 · linkedin.com/in/natejswenson
3
+ <!-- >>> press:masthead v0.11.0 sha256:582134ecd7a5 GENERATED by @natjswenson/press, do not edit -->
4
+ **NS** · NATE SWENSON · CLAUDE CODE + CODEX SKILL · PRESS v0.11.0 · linkedin.com/in/natejswenson
5
5
 
6
6
  ---
7
7
  <!-- <<< press:masthead -->
@@ -34,11 +34,32 @@ three against the repo's real shape — it never silently picks one.
34
34
  |---|---|
35
35
  | `skills/shipflow/SKILL.md` | The interactive setup interview, and where it must stop and ask. |
36
36
  | `skills/shipflow/bin/` | The CLI: `detect`, `plan`, `apply`, `releases`, `release-dispatch`. |
37
- | `skills/shipflow/templates/` | The workflow files each pattern renders. |
37
+ | `skills/shipflow/templates/` | GitHub Flow auto-merge for ready same-repository PRs, including `ready_for_review`; drafts and forks skip. |
38
38
  | `skills/shipflow/skill-invariants.json` | The prose guardrails and the baseline eval declaration. |
39
39
 
40
40
  ## Quick start
41
41
 
42
+ Claude Code — run in chat:
43
+
44
+ ```text
45
+ /plugin marketplace add natejswenson/claude-skills
46
+ /plugin install shipflow@claude-skills
47
+ /shipflow
48
+ ```
49
+
50
+ Codex — run in a terminal from the root of this repository checkout:
51
+
52
+ ```bash
53
+ codex plugin marketplace add "$PWD"
54
+ codex plugin add shipflow@claude-skills
55
+ ```
56
+
57
+ Start a new Codex session, then invoke in chat:
58
+
59
+ ```text
60
+ $shipflow
61
+ ```
62
+
42
63
  ```sh
43
64
  npx -y @natjswenson/shipflow@latest detect --repo . --main main --dev dev
44
65
  ```
@@ -60,6 +81,13 @@ npx -y @natjswenson/shipflow@latest detect --repo . --main main --dev dev
60
81
 
61
82
  ## Requirements
62
83
 
84
+ - **Claude Code:** Expose the authenticated `gh` CLI to shell tools.
85
+ - **Codex:** Use the same `gh` authentication and repository permissions; Claude app connections are not imported.
86
+ - **GitHub Flow credentials:** A configured PAT/App repository secret is required; an unavailable token skips cleanly. Maintainers handle fork merges and optional reminders with their own credentials.
87
+ - **Personal data:** Configuration stays in `.github/shipflow.json` in the target repository; no private `~/.claude/shipflow` store is required.
88
+
89
+ See [Codex migration notes](../../docs/codex-migration.md) for host tools and retained data paths.
90
+
63
91
  - Node 18+.
64
92
  - [`gh`](https://cli.github.com/), authenticated with admin rights on the target
65
93
  repo — branch protection cannot be read or written without them.
@@ -72,7 +100,7 @@ npx -y @natjswenson/shipflow@latest detect --repo . --main main --dev dev
72
100
  | Pattern | Shape |
73
101
  |---|---|
74
102
  | `dev-main-promotion` | Long-lived `dev` + `main`; a promotion PR auto-merges `dev` into `main` |
75
- | `github-flow` | Single long-lived `main`; every PR merges (and auto-merges) directly to it |
103
+ | `github-flow` | Single long-lived `main`; ready same-repository PRs can auto-merge directly to it |
76
104
  | `gitflow` | `develop` + `main` + transient `release/*`/`hotfix/*`, for software maintaining multiple released versions concurrently |
77
105
 
78
106
  `detect` scores all three against the repo's branches, tags and workflow files,
@@ -90,10 +118,42 @@ detection is ambiguous or the repo is greenfield.
90
118
  3. **`shipflow apply`** — only after you confirm — renders the resolved pattern's
91
119
  workflow files and makes the confirmed mutations. Nothing happens outside what
92
120
  the plan showed.
93
- 4. Ongoing: promotions auto-merge once required checks pass; a durable
121
+ 4. Ongoing: eligible PRs auto-merge once required checks pass; a durable
94
122
  `release-pending` label survives the async gap until a later
95
123
  `shipflow releases` check asks whether to cut a release.
96
124
 
125
+ ## GitHub Flow contributions
126
+
127
+ Draft PRs skip merge automation. Ready PRs from the same repository can enable
128
+ native auto-merge on `opened`, `reopened`, `synchronize` or `ready_for_review`;
129
+ GitHub's required checks gate the merge. Keep implementation PRs draft until review
130
+ and the authorized merge decision are complete.
131
+
132
+ Repository write access is the trust boundary. Forks and missing head repositories
133
+ skip both jobs. Maintainers review fork PRs and explicitly enable auto-merge or
134
+ merge with their own authorized credentials after required checks. The workflow
135
+ uses `pull_request` and executes no PR code.
136
+
137
+ Configure `release.releaseCredential` as the name of a PAT/App repository secret
138
+ with `contents: write` and `pull-requests: write`. Both commands skip cleanly if that
139
+ named secret is unavailable, without falling back to `GITHUB_TOKEN`; setup must
140
+ still provision it. Omitting the credential name instead retains the renderer’s
141
+ legacy `GITHUB_TOKEN` default, which does not guarantee a skip or the merged-PR
142
+ reminder. Always configure and provision the named PAT/App secret for this flow.
143
+ A merged same-repository PR receives an optional `release-pending` label;
144
+ unmerged closes and fork merges skip it. Maintainers may label fork merges manually,
145
+ and component `release-status` discovers untagged work without labels. No PR event
146
+ creates a tag or release.
147
+
148
+ To migrate an existing generated workflow, run plan/apply with the corrected engine.
149
+ During unreleased development, invoke `node <skill-directory>/bin/shipflow.js` from
150
+ the corrected checkout; after release use `npx -y @natjswenson/shipflow@latest`.
151
+ Review the plan, including any live settings changes, and apply with its state hash.
152
+ An existing workflow matching its old `renderedTemplateHashes` receipt is recognized
153
+ as a generator update. Commit the generated YAML and returned receipt together,
154
+ then replan to confirm no template drift. Genuine hand edits still block apply;
155
+ never edit the hash to hide them.
156
+
97
157
  ## Commands
98
158
 
99
159
  | Command | What it does |
@@ -101,7 +161,7 @@ detection is ambiguous or the repo is greenfield.
101
161
  | `detect --repo <path> [--main <name>] [--dev <name>]` | Inspect live repo state: branch protection, CI checks, release conventions |
102
162
  | `plan --repo <path>` | Diff `.github/shipflow.json` against live state; prints what would change plus a state hash |
103
163
  | `apply --repo <path> --expect-state-hash <hash> [--dry-run] [--force <id> --force-reason <text>]` | Apply a confirmed plan |
104
- | `releases --repo <path>` | List `dev main` promotions still labeled `release-pending` |
164
+ | `releases --repo <path>` | List merged main PR reminders (`mergedPrs` for GitHub flow, `promotions` for legacy consumers) |
105
165
  | `release-dispatch --repo <path> --pr <n> --workflow-file <f>... --ref <ref>` | Dispatch each changed skill's release workflow; clear the label on success |
106
166
  | `rename-default-branch --repo <path> --branch <old> --to <new>` | One-time bootstrap: rename a repo's default branch |
107
167
 
@@ -145,3 +205,10 @@ See [`CHANGELOG.md`](CHANGELOG.md). Releases are cut by a version bump, tagged
145
205
  ## License
146
206
 
147
207
  MIT — see [`LICENSE`](LICENSE).
208
+
209
+ Component releases honor the selected branch pattern. GitHub flow prepares and
210
+ merges its version/changelog PR directly into configured main; two-branch repos
211
+ retain promotion. `release-status` discovers pending components without labels.
212
+ `release-cut --version <x.y.z>` checks required CI, verifies the intended version
213
+ and notes on fetched main, explicitly dispatches one workflow, and proves the
214
+ remote tag. A merge or push alone does not release a component.
package/SKILL.md CHANGED
@@ -2,10 +2,28 @@
2
2
  name: shipflow
3
3
  description: Scaffold a configurable dev/main branching, auto-merge, branch-cleanup, and release-tagging workflow into any repo. Detects existing branch protection, CI checks, and release conventions; always shows a plan and waits for confirmation before mutating anything. Use when the user asks to set up branch protection standards, apply deployment/release standards to a repo, or wants long-lived dev/main branches with auto-merge and branch cleanup.
4
4
  user_invocable: true
5
+ version: 0.7.0
5
6
  ---
6
7
 
8
+ ## Codex runtime
9
+
10
+ When running in Codex, invoke this skill as `$shipflow`. Resolve scripts, assets,
11
+ and references from the directory containing this SKILL.md, regardless of the
12
+ current working directory. Existing `~/.claude/` personal-data paths remain valid
13
+ and are still used by the bundled scripts; they do not require Claude to run.
14
+ Map `Read`/`Write`/`Edit`/`Bash` to the available file and shell tools, and
15
+ `WebSearch`/`WebFetch` to available web tools. For `AskUserQuestion`, use an
16
+ available question tool or a concise chat question; wait for answers that gate
17
+ action. Use Codex's delegation tools for required subagents when available;
18
+ otherwise disclose that independent execution is unavailable. Discover connected
19
+ apps by capability rather than assuming Claude MCP tool names exist.
20
+
7
21
  # /shipflow — branching + release-automation setup
8
22
 
23
+ <!-- press:runtime -->
24
+ In Claude Code, load `/press`; in Codex, load `$press`; then follow the shared PRESS terminal/UI contract from `brand/agent-ui.md`. Do not copy or override that contract here.
25
+ <!-- press:runtime -->
26
+
9
27
  All deterministic work is delegated to the CLI. Invoke it as
10
28
  `npx -y @natjswenson/shipflow@latest <command>` — **always with the explicit
11
29
  `@latest` tag, never bare `@natjswenson/shipflow`.** Without a version/tag,
@@ -48,7 +66,7 @@ user; the CLI is the only thing that *does*.
48
66
  - **Confident:** state what was detected and why (the top entry's `evidence` array) — *"I detected this repo is using **`<pattern-id>`** because: `<evidence bullets>`. I'll set `workflowPattern` to this — confirm before I proceed, or tell me if you'd rather pick a different pattern."* This is still a confirm-before-write checkpoint per this section's mandatory-interview rule — a confident autodetect is not a substitute for the user's explicit confirmation.
49
67
  - **Ambiguous or greenfield:** present all 3 patterns and ask the user to choose. Do not silently pick one:
50
68
  - `dev-main-promotion` — long-lived `dev` + `main`; a promotion PR auto-merges `dev` into `main`.
51
- - `github-flow` — single long-lived `main`; every PR merges (and auto-merges) directly to `main`. Suggest this as the lightweight default for a **greenfield** repo specifically, without auto-picking it.
69
+ - `github-flow` — single long-lived `main`; ready same-repository PRs can auto-merge directly to `main`; forks require maintainer action. Suggest this as the lightweight default for a **greenfield** repo specifically, without auto-picking it.
52
70
  - `gitflow` — `develop` + `main` + transient `release/*`/`hotfix/*` branches, for software that maintains multiple released versions concurrently.
53
71
  - Once resolved, proceed with only the interview fields that pattern's config actually uses — skip asking about a `dev` branch name under `github-flow`, for instance.
54
72
  - If `workflowPattern` is `gitflow`, additionally ask for `releaseBranchPrefix`/`hotfixBranchPrefix` (defaulting to `release/`/`hotfix/` if the user has no preference) — recorded under `patternConfig.gitflow` in the config.
@@ -121,6 +139,45 @@ user; the CLI is the only thing that *does*.
121
139
 
122
140
  Same as steps 1, 8, 9, 10, 11 above, skipping the interview (`workflowPattern`/branch names/checks/protectionOwner/releaseCredential are already recorded in `.github/shipflow.json` — read it, don't re-ask, unless the user explicitly says they want to reconfigure). Step 2's pattern resolution never runs on a re-run — `workflowPattern`'s absence from a config genuinely means "not yet resolved," and its presence means "already resolved," so there's nothing to detect again. If `plan.creates`/`plan.updates` is non-empty, that's drift since the last apply — show it and confirm before applying, exactly as in first-run setup.
123
141
 
142
+ ## GitHub Flow readiness, forks and migration
143
+
144
+ For `workflowPattern: "github-flow"`, the generated `pull_request` workflow handles
145
+ `opened`, `reopened`, `synchronize`, `ready_for_review` and `closed` on configured
146
+ main. Only a non-draft PR whose head repository matches the target repository can
147
+ enable native auto-merge. Drafts skip cleanly; making a reviewed draft ready has
148
+ its own trigger. Native GitHub required checks still gate the merge. Keep an
149
+ implementation PR draft until review and the authorized merge decision are complete.
150
+
151
+ Repository write access is the trust boundary. Forks and missing head repositories
152
+ skip both jobs, regardless of contributor association or labels. Maintainers must
153
+ review fork PRs and explicitly enable native auto-merge or merge with their own
154
+ authorized credentials after required checks. Do not switch to
155
+ `pull_request_target`, check out PR code, or run contributor-controlled scripts to
156
+ make credentials available.
157
+
158
+ Both commands use the configured `release.releaseCredential` PAT/App secret from
159
+ setup step 6. If that named secret is unavailable or unset, the step reports a clean
160
+ skip and invokes no `gh` command; it does not fall back to `GITHUB_TOKEN`. This does
161
+ not provision or validate the credential. Omitting `release.releaseCredential`
162
+ from configuration instead retains the renderer’s legacy `GITHUB_TOKEN` default,
163
+ which does not guarantee a skip or the merged-PR reminder. Always configure and
164
+ provision the named PAT/App secret for this flow.
165
+
166
+ Only a merged same-repository PR receives the optional `release-pending` reminder.
167
+ Unmerged closes and fork merges skip it; a maintainer may label a fork merge
168
+ manually. Component `release-status` discovers untagged work without labels. No
169
+ PR event cuts a tag or release.
170
+
171
+ When upgrading this template, use the corrected checkout's
172
+ `node <skill-directory>/bin/shipflow.js plan --repo <target>` and matching `apply`
173
+ during unreleased development; use the explicit `@latest` invocation after release.
174
+ Follow the reviewed plan and state-hash apply process above, including any live
175
+ settings changes it proposes. If the existing workflow matches its recorded old
176
+ `renderedTemplateHashes` receipt, plan recognizes a template update. Apply generates
177
+ new bytes and returns the new receipt; commit both together and replan to verify
178
+ no template drift. A genuine hand edit still requires the explicit scoped override;
179
+ never manufacture a matching receipt by hand to bypass that refusal.
180
+
124
181
  ## Check pending releases (`manual-gate` ask-flow)
125
182
 
126
183
  This is a **separate, later invocation** from the one that ran the promotion's `apply` — native GitHub auto-merge completes asynchronously, with no live session attached at the moment of the actual merge. A durable `release-pending` label is what survives that gap.
@@ -154,25 +211,51 @@ and release workflow live, with `{name}` as the only substitution token;
154
211
  its root (`package.json`, `CHANGELOG.md`, `v{version}`), so a one-project repo needs no config
155
212
  at all and `--component` may be omitted.
156
213
 
214
+ `workflowPattern: "github-flow"` is authoritative: preparation and feature PRs
215
+ use configured main, status never reads dev, and cut skips promotion. A stale dev
216
+ field does not change that. Absent-pattern and two-branch consumers retain the
217
+ integration branch, promotion and dev-ahead refusal. GitHub-flow `releases`
218
+ returns `mergedPrs`; legacy consumers retain `promotions`. These labels are
219
+ optional reminders: component `release-status` inventories pending versions and
220
+ tags without them, and `release-cut` requires no label.
221
+
222
+ For the declared `skills/{name}/.codex-plugin/plugin.json` layout, preparation
223
+ requires this repository's `tools/sync_codex.py` and `tools/check_compatibility.py`.
224
+ It checks existing metadata, updates both lockfile version fields and the shared
225
+ version files, runs generation and compatibility checks in the worktree, and
226
+ refuses unrelated generated edits. Generic consumers have no Python dependency.
227
+ Before requesting merge, cut reads the full live required-check set; before
228
+ explicit dispatch, it fetches and verifies main's intended version and changelog.
229
+ Keep main PRs draft until their review and authorized merge decision are complete;
230
+ the existing GitHub-flow workflow enables native auto-merge on eligible PRs.
231
+
157
232
  1. **Read the state. Never guess it.**
158
233
  ```
159
234
  npx -y @natjswenson/shipflow@latest release-status --repo <path> --component <name>
160
235
  ```
161
- Returns `state`, the version on main and dev, the last tag, every commit since that tag that
236
+ Returns `state`, `workflowPattern`, `releaseBase`, the version on main (and dev for two-branch repos), the last tag, every commit since that tag that
162
237
  touched this component's paths, a `suggestedBump` with its reason, `blockers`, `notes`, and a
163
238
  `statusHash`. `state` decides the path:
164
239
  - `clean` — the released version is what's on main. A bump is needed: go to step 2.
165
240
  - `untagged-bump-on-main` — the bump is already on main and was never tagged (a cancelled or
166
241
  failed release run). **No PR is needed** — `release-cut` dispatches and verifies. Skip to step 3.
242
+ **`untagged-bump-on-main` is not, by itself, permission to cut.** Check `devAhead` first: if
243
+ it is set, dev already carries a *higher* version than what's on main, and cutting here would
244
+ tag the version on main, not the one on dev — the version you almost certainly mean to
245
+ release. `release-cut` refuses in this shape unless you pass `--version` naming exactly which
246
+ one to release (see step 3); it never guesses.
167
247
  - `bump-on-dev-unpromoted` — the bump is on dev, waiting for a promotion. Skip to step 3.
168
248
  - `version-behind-tag` — main carries a *lower* version than an existing tag. Stop and ask;
169
249
  this means a tag was cut from something other than main, and guessing is how it gets worse.
170
250
 
171
251
  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.
252
+ A `dev → main` promotion is atomic and carries all of dev, so every component listed under
253
+ `collateral` has its bump moved to `main` by the same promotion. It is **not released** by
254
+ that every caller's release job is `workflow_dispatch`-only, so merging tags nothing; each
255
+ becomes `untagged-bump-on-main`, one deliberate `release-cut` away from a tag.
256
+ **Never run `release-cut` without naming that list to the user first.** They should know what
257
+ their promotion moves, and which components are now one dispatch from a release nobody asked
258
+ for.
176
259
 
177
260
  `suggestedBump` is a suggestion. The user decides, and a `suggestedBumpCapped: true` means a
178
261
  breaking change was held at minor because the component is still 0.x — going to 1.0.0 is a
@@ -183,23 +266,37 @@ at all and `--component` may be omitted.
183
266
  ```
184
267
  Local only, no network. It works in a **throwaway git worktree**, so unrelated uncommitted work
185
268
  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.
269
+ and the CHANGELOG entry land in **one commit** the notes are read off `main` at dispatch
270
+ time, so a CHANGELOG that lands in a later promotion than its version is notes the release
271
+ will never carry.
188
272
 
189
273
  3. **Cut it, and prove it.**
190
274
  ```
191
275
  npx -y @natjswenson/shipflow@latest release-cut --repo <path> --component <name> \
192
- --expect-status-hash <hash-from-step-1> --wait 240
276
+ --version <prepared-or-confirmed-version> --expect-status-hash <hash-from-step-1> --wait 240
193
277
  ```
194
278
  `--expect-status-hash` is mandatory (same TOCTOU discipline as `apply`'s `--expect-state-hash`);
195
279
  `--skip-hash-check` is a named escape hatch, never a default.
196
280
 
281
+ If step 1's `devAhead` was set, `release-cut` refuses outright with an error naming both
282
+ versions — this is the ambiguous three-way state (main has an untagged bump, dev already
283
+ carries something higher) where guessing would tag the wrong one. Promote `dev → main` and
284
+ re-run `release-status` to release what's on dev (the normal recovery), **or** add
285
+ `--version <x.y.z>` naming exactly the version on main, if you deliberately mean to release
286
+ that one and leave dev's higher version for later. `--version` is a confirmation, not a
287
+ bypass — it is only ever accepted when it matches a version already on main, dev, or the verified prepared branch; anything
288
+ else is refused the same as passing nothing.
289
+
197
290
  **`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.
291
+ path — feature PR, checks, merge, promotion, auto-merge, **dispatch**, release run, tag — takes
292
+ longer than one call should block for. Each call advances as far as it can, then returns the
293
+ `stage` it is parked at and a `next` line. **Call it again, unchanged, until `done: true`.** It
294
+ derives every stage from live remote state and never from a record of what a previous call did,
295
+ so a resumed run and a fresh one are the same code path.
296
+
297
+ **Merging cuts nothing.** `release-cut` dispatches the component's release
298
+ workflow itself, after the selected version and notes reach main — that dispatch is the single point at which any tag
299
+ is created in this repo, which is why a merge can no longer surprise anyone with a release.
203
300
 
204
301
  4. **Report the tag, and only the tag.** `done: true` carries `tag` and `releaseUrl`, read back
205
302
  from origin. A dispatched workflow, a merged PR and a green check are **not** a release —
package/bin/shipflow.js CHANGED
@@ -211,7 +211,7 @@ function cmdReleases(args) {
211
211
  ...p,
212
212
  ...confirmPromotionMerged(ownerRepo, p.number),
213
213
  }));
214
- printJson({ promotions: withMergeCheck });
214
+ printJson(config.workflowPattern === 'github-flow' ? { mergedPrs: withMergeCheck } : { promotions: withMergeCheck });
215
215
  }
216
216
 
217
217
  function cmdReleaseDispatch(args) {
@@ -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
@@ -12,13 +12,14 @@
12
12
  // prepare() — local writes only, in a THROWAWAY WORKTREE (see below)
13
13
  // cut() — the only irreversible one, gated on a status hash
14
14
  //
15
- // Why a throwaway worktree: `prepare` has to branch off dev and commit, and a
15
+ // Why a throwaway worktree: `prepare` branches from the configured base and commits, and a
16
16
  // real repo's working tree routinely has unrelated in-flight work in it (this
17
17
  // monorepo's own tree did while this was written). Checking out a branch under
18
18
  // that, or staging from it, is how another session's uncommitted work gets
19
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.
20
+ // of that base that cannot see the user's dirt at all, so there is nothing to sweep.
21
21
 
22
+ import { spawnSync } from 'node:child_process';
22
23
  import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
23
24
  import { join, resolve, sep } from 'node:path';
24
25
  import { tmpdir } from 'node:os';
@@ -332,16 +333,34 @@ function revParse(repoPath, ref) {
332
333
  }
333
334
 
334
335
  function dirtyPaths(repoPath, relPaths) {
335
- const r = git(['status', '--porcelain', '--', ...relPaths], { cwd: repoPath });
336
+ const r = spawnSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', ...relPaths], {
337
+ cwd: repoPath, encoding: 'utf8', timeout: 30_000,
338
+ });
336
339
  if (r.status !== 0 || !r.stdout) return [];
337
- return r.stdout.split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
340
+ const records = r.stdout.split('\0');
341
+ const paths = [];
342
+ for (let i = 0; i < records.length; i++) {
343
+ const record = records[i];
344
+ if (!record) continue;
345
+ paths.push(record.slice(3));
346
+ if (/[RC]/.test(record.slice(0, 2))) i++; // rename/copy source follows destination
347
+ }
348
+ return paths;
338
349
  }
339
350
 
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.
351
+ // Every OTHER component whose version at `dev` carries no tag. A promotion is
352
+ // atomic and carries all of dev, so these components' bumps land on `main`
353
+ // alongside the one being released, whether or not anyone asked.
354
+ //
355
+ // They are NOT released by that. Since the release jobs became
356
+ // workflow_dispatch-only, landing on main tags nothing — each of these simply
357
+ // becomes `untagged-bump-on-main`, releasable later by an explicit `release-cut`.
358
+ // That is a far safer default than the old behaviour, where the same promotion
359
+ // tagged and npm-published every one of them within seconds of merging.
360
+ //
361
+ // It is still worth saying out loud: the user should know what their promotion
362
+ // is moving to main, and which components are now sitting one dispatch away
363
+ // from a release they did not ask for.
345
364
  export function collateralComponents(repoPath, config, exceptName, devRef) {
346
365
  const out = [];
347
366
  for (const name of listComponentNames(config, repoPath)) {
@@ -361,30 +380,53 @@ export function collateralComponents(repoPath, config, exceptName, devRef) {
361
380
  return out;
362
381
  }
363
382
 
383
+ // An explicit pattern is authoritative even during a migration with stale dev fields.
384
+ function branchPolicy(config) {
385
+ const workflowPattern = config?.workflowPattern ?? 'dev-main-promotion';
386
+ const main = config?.branches?.main ?? 'main';
387
+ const dev = workflowPattern === 'github-flow' ? null : (config?.branches?.dev ?? 'dev');
388
+ return { workflowPattern, main, dev, base: dev ?? main };
389
+ }
390
+
364
391
  export function readStatus(repoPath, config, name) {
365
392
  const component = resolveComponent(repoPath, config, name);
366
- const mainBranch = config?.branches?.main ?? 'main';
367
- const devBranch = config?.branches?.dev ?? 'dev';
393
+ const policy = branchPolicy(config);
394
+ const { main: mainBranch, dev: devBranch } = policy;
368
395
 
369
396
  // Read from the REMOTE-tracking refs, not the local branches: a local `main`
370
397
  // that has not been fetched in a week would compute a bump against a stale
371
398
  // baseline and silently propose a version that is already tagged.
372
399
  const fetched = git(['fetch', 'origin', '--tags', '--prune'], { cwd: repoPath });
373
400
  const mainRef = revParse(repoPath, `origin/${mainBranch}`) ? `origin/${mainBranch}` : mainBranch;
374
- const devRef = revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch;
401
+ const devRef = devBranch ? (revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch) : null;
375
402
 
376
403
  const onMain = readVersionAt(repoPath, component, mainRef);
377
- const onDev = readVersionAt(repoPath, component, devRef);
404
+ const onDev = devRef ? readVersionAt(repoPath, component, devRef) : { ok: false, version: null };
378
405
  const lastVersion = latestVersionTagged(repoPath, component);
379
406
  const lastTag = lastVersion ? tagFor(component, lastVersion) : null;
380
407
 
381
408
  const blockers = [];
382
409
  const notes = [];
410
+ // A shallow clone cannot answer "what is unreleased?" — and it does not fail
411
+ // when asked, which is the dangerous part. `git log <tag>..<ref>` excludes
412
+ // everything reachable from <tag>, and that exclusion needs full ancestry;
413
+ // in a grafted history it silently under-applies and the range returns
414
+ // commits that were released long ago. Observed on this repo: a depth-1
415
+ // checkout of main reported 1 unreleased commit for a component that a full
416
+ // clone correctly reported as 0 — which would have proposed a patch release
417
+ // for nothing. A wrong commit list also means a wrong suggestedBump, so this
418
+ // is a blocker rather than a note: every number below it is untrustworthy.
419
+ if (git(['rev-parse', '--is-shallow-repository'], { cwd: repoPath }).stdout.trim() === 'true') {
420
+ blockers.push({
421
+ id: 'shallow-clone',
422
+ detail: 'this is a shallow clone, so commit ranges and the bump derived from them cannot be trusted — run `git fetch --unshallow` first',
423
+ });
424
+ }
383
425
  if (!fetched || fetched.status !== 0) {
384
426
  notes.push(`could not fetch origin (${fetched?.stderr || 'unknown error'}) — versions and tags below may be stale`);
385
427
  }
386
428
  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 });
429
+ if (devBranch && !onDev.ok) blockers.push({ id: 'version-unreadable-on-dev', detail: onDev.error });
388
430
 
389
431
  const changelogAbs = join(repoPath, component.changelog);
390
432
  if (!existsSync(changelogAbs)) {
@@ -419,21 +461,57 @@ export function readStatus(repoPath, config, name) {
419
461
  state = 'untagged-bump-on-main'; // never released; whatever is on main is the first release
420
462
  }
421
463
 
464
+ // A fact, not a state — computed independently of the branch above so it is
465
+ // ALSO set when lastVersion is null (a component's first release). Folding
466
+ // this into `state` is the bug this field exists to fix (#173): "main has an
467
+ // untagged bump" and "dev already carries something higher" are
468
+ // independently true, and a single mutually-exclusive `state` string can
469
+ // only ever report one of them. `cut()`'s fast path acts on `state` alone —
470
+ // without `devAhead`, it would dispatch a release for whatever is on main
471
+ // while the version actually being released sits unread on dev.
472
+ const devAhead = onMain.ok && onDev.ok && cmpSemver(onDev.version, onMain.version) > 0
473
+ ? { version: onDev.version, aheadOfMain: true }
474
+ : null;
475
+
476
+ // The fast path is only armed in `untagged-bump-on-main`, so this blocker is
477
+ // deliberately scoped to that state alone. `bump-on-dev-unpromoted` also has
478
+ // `devAhead` set — that is its normal, expected shape (no fast path is
479
+ // reachable there, nothing can be mis-tagged) — and flagging it too would
480
+ // permanently mark a routine state as blocked, which is how a blocker stops
481
+ // being read.
482
+ if (devAhead && state === 'untagged-bump-on-main') {
483
+ blockers.push({
484
+ id: 'dev-ahead-of-main',
485
+ detail: `${mainBranch} carries ${onMain.version} but ${devBranch} carries ${devAhead.version} — cutting here would tag ` +
486
+ `${tagFor(component, onMain.version)}, not ${tagFor(component, devAhead.version)}. Promote ${devBranch} → ${mainBranch} ` +
487
+ `and re-run status, or pass --version ${onMain.version} to release exactly what is on ${mainBranch}.`,
488
+ });
489
+ }
490
+
422
491
  const since = commitsSince(repoPath, component, lastTag, mainRef);
423
492
  const suggestion = suggestBump(since.commits, onMain.version ?? '0.0.0');
424
493
  const nextVersion = suggestion.bump && onMain.ok ? bumpSemver(onMain.version, suggestion.bump) : null;
425
494
 
426
- const collateral = collateralComponents(repoPath, config, name, devRef);
495
+ const collateral = devRef ? collateralComponents(repoPath, config, name, devRef) : [];
496
+ const pendingComponents = collateralComponents(repoPath, config, name, mainRef);
427
497
 
428
498
  // The TOCTOU guard for cut(). Everything that could change the meaning of a
429
499
  // release decision between the moment it is shown to a human and the moment
430
500
  // it is acted on: both branch heads, the versions, the last tag, and who
431
501
  // else is riding along.
502
+ const preparedBranches = git(['for-each-ref', '--format=%(refname)',
503
+ `refs/heads/feature/release-${name}-v*`, `refs/remotes/origin/feature/release-${name}-v*`], { cwd: repoPath })
504
+ .stdout.split('\n').filter(Boolean).sort().map((ref) => {
505
+ const version = readVersionAt(repoPath, component, ref).version;
506
+ return { ref, sha: revParse(repoPath, ref), version, notes: notesAt(repoPath, component, ref, version) };
507
+ });
432
508
  const statusHash = sha256(
433
509
  JSON.stringify({
434
510
  component: name,
511
+ preparedBranches,
435
512
  mainSha: revParse(repoPath, mainRef),
436
- devSha: revParse(repoPath, devRef),
513
+ workflowPattern: policy.workflowPattern,
514
+ devSha: devRef ? revParse(repoPath, devRef) : null,
437
515
  versionOnMain: onMain.version,
438
516
  versionOnDev: onDev.version,
439
517
  lastTag,
@@ -450,9 +528,16 @@ export function readStatus(repoPath, config, name) {
450
528
  paths: component.paths,
451
529
  inferredLayout: component.inferredLayout,
452
530
  },
531
+ workflowPattern: policy.workflowPattern,
532
+ releaseBase: policy.base,
533
+ mainBranch,
534
+ devBranch,
535
+ pendingComponents,
536
+ preparedBranches,
453
537
  state,
454
538
  versionOnMain: onMain.version,
455
539
  versionOnDev: onDev.version,
540
+ devAhead,
456
541
  versionSources: onMain.sources,
457
542
  lastTag,
458
543
  commits: since.commits,
@@ -469,6 +554,13 @@ export function readStatus(repoPath, config, name) {
469
554
 
470
555
  // ─── prepare ─────────────────────────────────────────────────────────────────
471
556
  function writeVersionInto(relPath, text, version) {
557
+ if (relPath.endsWith('package-lock.json')) {
558
+ const data = JSON.parse(text);
559
+ if (!data.version) return null;
560
+ data.version = version;
561
+ if (data.packages?.['']?.version) data.packages[''].version = version;
562
+ return JSON.stringify(data, null, 2) + '\n';
563
+ }
472
564
  if (relPath.endsWith('.json')) {
473
565
  // Line-targeted rather than JSON.parse → JSON.stringify: reserializing
474
566
  // would reformat the whole file (key order, indentation, trailing
@@ -534,11 +626,12 @@ export function spliceChangelog(existing, version, notes, date) {
534
626
  }
535
627
 
536
628
  export const releaseBranchName = (name, version) => `feature/release-${name}-v${version}`;
537
- const worktreeDir = (name, version) => join(tmpdir(), `shipflow-release-${name}-${version}`);
629
+ const worktreeDir = (repoPath, name, version) => join(tmpdir(), `shipflow-release-${sha256(resolve(repoPath)).slice(0, 16)}-${name}-${version}`);
538
630
 
539
631
  export function prepare(repoPath, config, name, version, notes, { date, featureBranchPrefix } = {}) {
540
632
  const component = resolveComponent(repoPath, config, name);
541
- const devBranch = config?.branches?.dev ?? 'dev';
633
+ const policy = branchPolicy(config);
634
+ const devBranch = policy.base;
542
635
  const tag = tagFor(component, version);
543
636
  const stamp = date ?? new Date().toISOString().slice(0, 10);
544
637
 
@@ -557,7 +650,7 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
557
650
  if (featureBranchPrefix && !branch.startsWith(featureBranchPrefix)) {
558
651
  return { ok: false, error: `release branch ${branch} does not start with the configured featureBranchPrefix ${featureBranchPrefix}` };
559
652
  }
560
- const dir = worktreeDir(name, version);
653
+ const dir = worktreeDir(repoPath, name, version);
561
654
 
562
655
  // A leftover worktree from an aborted run must not silently become the base
563
656
  // for this one — remove it, then re-create from the CURRENT dev.
@@ -569,7 +662,9 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
569
662
  if (added.status !== 0) return { ok: false, error: `git worktree add failed: ${added.stderr}` };
570
663
 
571
664
  const changed = [];
665
+ const dualHost = component.versionFiles.includes(`skills/${name}/.codex-plugin/plugin.json`);
572
666
  try {
667
+ if (dualHost) checkDualHost(dir, true);
573
668
  for (const relPath of component.versionFiles) {
574
669
  const abs = join(dir, relPath);
575
670
  if (!existsSync(abs)) continue;
@@ -594,6 +689,16 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
594
689
  writeFileSync(clAbs, spliced.content);
595
690
  changed.push(component.changelog);
596
691
 
692
+ if (dualHost) {
693
+ const catalog = readFileSync(join(dir, '.agents/plugins/marketplace.json'), 'utf8');
694
+ checkDualHost(dir, false);
695
+ if (readFileSync(join(dir, '.agents/plugins/marketplace.json'), 'utf8') !== catalog) throw new Error('unexpected generated catalog edit during version-only preparation');
696
+ const allowed = new Set([...changed, '.agents/plugins/marketplace.json']);
697
+ const actual = dirtyPaths(dir, ['.']);
698
+ const unexpected = actual.filter((path) => !allowed.has(path));
699
+ if (unexpected.length) throw new Error(`unexpected generated edits: ${unexpected.join(', ')}`);
700
+ changed.push(...actual.filter((path) => !changed.includes(path)));
701
+ }
597
702
  // Explicit pathspecs, never `git add -A`. The worktree should contain
598
703
  // nothing else, but "should" is not a guarantee worth a release commit.
599
704
  const staged = git(['add', '--', ...changed], { cwd: dir });
@@ -608,6 +713,79 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
608
713
  }
609
714
  }
610
715
 
716
+ // Fixed adapter for this repository's declared dual-host layout. Ordinary
717
+ // consumers never execute Python. Refuse pre-existing drift before rewriting.
718
+ function checkDualHost(dir, before) {
719
+ for (const file of ['tools/sync_codex.py', 'tools/check_compatibility.py']) {
720
+ if (!existsSync(join(dir, file))) throw new Error(`dual-host preparation requires ${file}`);
721
+ }
722
+ const commands = before
723
+ ? [['tools/sync_codex.py', '--check'], ['tools/check_compatibility.py']]
724
+ : [['tools/sync_codex.py'], ['tools/sync_codex.py', '--check'], ['tools/check_compatibility.py']];
725
+ for (const args of commands) {
726
+ const result = spawnArgs('python3', args, { cwd: dir, env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
727
+ if (result.status !== 0) throw new Error(`dual-host ${args.join(' ')} failed: ${result.stderr || result.stdout}`);
728
+ }
729
+ }
730
+
731
+ // ─── resolving the release target ────────────────────────────────────────────
732
+ // The one place a target version is decided. Before this existed, `cut()`
733
+ // derived it twice, ten lines apart — once preferring dev, once preferring
734
+ // main — and those two derivations could disagree. That disagreement IS #173:
735
+ // the fast path would tag whatever sat on main while the version actually
736
+ // being released sat, unread, on dev. `cut()` now calls this once, before any
737
+ // network call, and uses its result for both the dispatch and the tag it
738
+ // waits for, so there is no longer a code path where those two can differ.
739
+ //
740
+ // Pure function of a `readStatus()` result plus an optional operator-supplied
741
+ // `requestedVersion` (`--version`). `requestedVersion` is a CONFIRMATION, not
742
+ // a bypass: it is only ever accepted when it matches a version already
743
+ // present on `main` or `dev` in this status, so there is no value of it that
744
+ // releases a version which isn't actually on the branch being dispatched.
745
+ export function resolveReleaseTarget(status, requestedVersion = null) {
746
+ const { state, versionOnMain, versionOnDev, devAhead, component } = status;
747
+ if (requestedVersion && !parseSemver(requestedVersion)) return { ok: false, error: 'requested version is not valid semver' };
748
+ if (status.preparedVersion && requestedVersion === status.preparedVersion) {
749
+ return { ok: true, version: requestedVersion, via: 'prepared-branch' };
750
+ }
751
+ if (requestedVersion && ![versionOnMain, versionOnDev].includes(requestedVersion)) {
752
+ return { ok: false, error: `requested version ${requestedVersion} is not on main, dev, or a verified prepared branch` };
753
+ }
754
+
755
+
756
+ if (state === 'untagged-bump-on-main') {
757
+ if (!devAhead) {
758
+ if (requestedVersion && requestedVersion !== versionOnMain) return { ok: false, error: 'requested version does not match main' };
759
+ // The common, unambiguous case: whatever is on main is the only
760
+ // candidate, dev has nothing higher.
761
+ return { ok: true, version: versionOnMain, via: 'dispatch-on-main' };
762
+ }
763
+ if (requestedVersion === versionOnMain) {
764
+ // Confirmed: release exactly what is on main, knowingly leaving dev's
765
+ // higher version for a later, separate release.
766
+ return { ok: true, version: versionOnMain, via: 'dispatch-on-main', confirmed: true };
767
+ }
768
+ if (requestedVersion === versionOnDev) {
769
+ return {
770
+ ok: false,
771
+ error: `${versionOnDev} is on dev but not on main — a dispatch on main cannot cut it. ` +
772
+ `Promote dev → main first, then re-run release-status.`,
773
+ };
774
+ }
775
+ return {
776
+ ok: false,
777
+ error: `${component.name}: main carries ${versionOnMain} but dev carries ${versionOnDev} — ambiguous which one ` +
778
+ `to release, so refusing to guess. Promote dev → main and re-run release-status to release ${versionOnDev}, ` +
779
+ `or pass --version ${versionOnMain} to release exactly what is on main.`,
780
+ };
781
+ }
782
+
783
+ // Every other state (`clean`, `bump-on-dev-unpromoted`, `version-behind-tag`)
784
+ // already has a single unambiguous candidate — dev, when it carries the
785
+ // prepared bump, else main — matching what `cut()` used before this existed.
786
+ return { ok: true, version: requestedVersion ?? versionOnDev ?? versionOnMain, via: 'prepared-branch' };
787
+ }
788
+
611
789
  // ─── cut ─────────────────────────────────────────────────────────────────────
612
790
  // Resumable and bounded on purpose. The full path (feature PR → checks → merge
613
791
  // → promotion → auto-merge → release run → tag) routinely takes longer than a
@@ -628,10 +806,10 @@ function sleepSync(ms) {
628
806
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
629
807
  }
630
808
 
631
- export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHash = null, skipHashCheck = false, ownerRepo, pollSeconds = 15 } = {}) {
809
+ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHash = null, skipHashCheck = false, ownerRepo, pollSeconds = 15, version = null } = {}) {
632
810
  const component = resolveComponent(repoPath, config, name);
633
- const mainBranch = config?.branches?.main ?? 'main';
634
- const devBranch = config?.branches?.dev ?? 'dev';
811
+ const policy = branchPolicy(config);
812
+ const { main: mainBranch, dev: devBranch } = policy;
635
813
  const owner = ownerRepo.split('/')[0];
636
814
 
637
815
  const status = readStatus(repoPath, config, name);
@@ -644,65 +822,92 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
644
822
  }
645
823
  }
646
824
 
647
- const targetVersion = status.versionOnDev ?? status.versionOnMain;
825
+ // The ONLY place the release target is decided — see resolveReleaseTarget's
826
+ // own comment for why. Called before any network call, so an ambiguous
827
+ // three-way state (#173: main has an untagged bump AND dev carries
828
+ // something higher) is refused here rather than acted on by the fast path
829
+ // below.
830
+ if (version && parseSemver(version)) {
831
+ const preparedRef = releaseBranchName(name, version);
832
+ const prepared = readVersionAt(repoPath, component, preparedRef);
833
+ if (prepared.ok && prepared.version === version && version !== status.versionOnMain) status.preparedVersion = version;
834
+ }
835
+ const target = resolveReleaseTarget(status, version);
836
+ if (!target.ok) return { ok: false, error: target.error };
837
+ const targetVersion = target.version;
648
838
  const tag = tagFor(component, targetVersion);
649
839
  const branch = releaseBranchName(name, targetVersion);
840
+ const released = tagExistsOnRemote(repoPath, tag);
841
+ if (released.ok && released.exists) {
842
+ const rel = ghApiJson(`repos/${ownerRepo}/releases/tags/${tag}`);
843
+ return { ok: true, done: true, stage: 'tag', tag, targetVersion, releaseUrl: rel.ok ? rel.data?.html_url ?? null : null, note: 'already released' };
844
+ }
650
845
  const deadline = Date.now() + waitSeconds * 1000;
651
846
  const log = [];
847
+ const preparedExists = Boolean(revParse(repoPath, branch));
848
+ const onBase = readVersionAt(repoPath, component, `origin/${policy.base}`);
849
+ const baseHasVersion = onBase.ok && onBase.version === targetVersion;
850
+ const sourceRef = target.via === 'prepared-branch' ? (preparedExists ? branch : `origin/${policy.base}`) : `origin/${mainBranch}`;
851
+ const expectedNotes = notesAt(repoPath, component, sourceRef, targetVersion);
652
852
  const note = (stage, msg) => log.push({ stage, msg });
653
853
 
654
854
  // Fast path: the bump is already on main and simply was never tagged (a
655
855
  // 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));
856
+ if (target.via === 'dispatch-on-main') {
857
+ const already = tagExistsOnRemote(repoPath, tag);
658
858
  if (already.ok && already.exists) {
659
- return { ok: true, done: true, stage: 'tag', tag: tagFor(component, status.versionOnMain), note: 'already released' };
859
+ return { ok: true, done: true, stage: 'tag', tag, targetVersion, note: 'already released' };
660
860
  }
861
+ const verified = verifyDispatch(repoPath, component, mainBranch, targetVersion, expectedNotes);
862
+ if (!verified.ok) return verified;
661
863
  const d = spawnArgs('gh', ['workflow', 'run', component.workflowFile, '--ref', mainBranch, '--repo', ownerRepo]);
662
864
  if (d.status !== 0) return { ok: false, error: `workflow dispatch failed: ${d.stderr}` };
663
865
  note('dispatch', `dispatched ${component.workflowFile} on ${mainBranch}`);
664
- return waitForTag(repoPath, tagFor(component, status.versionOnMain), deadline, pollSeconds, log, ownerRepo, null);
866
+ const result = waitForTag(repoPath, tag, deadline, pollSeconds, log, ownerRepo, null);
867
+ return { ...result, targetVersion };
665
868
  }
666
869
 
667
870
  // 1. push the prepared branch
668
- if (!revParse(repoPath, branch)) {
871
+ if (!preparedExists && !baseHasVersion) {
669
872
  return { ok: false, error: `branch ${branch} does not exist — run release-prepare first` };
670
873
  }
671
- const dir = worktreeDir(name, targetVersion);
874
+ const dir = worktreeDir(repoPath, name, targetVersion);
672
875
  const pushCwd = existsSync(dir) ? dir : repoPath;
673
- if (!revParse(repoPath, `origin/${branch}`)) {
876
+ if (preparedExists && !baseHasVersion && !revParse(repoPath, `origin/${branch}`)) {
674
877
  const pushed = git(['push', '-u', 'origin', branch], { cwd: pushCwd });
675
878
  if (pushed.status !== 0) return { ok: false, error: `git push failed: ${pushed.stderr}` };
676
879
  note('push', `pushed ${branch}`);
677
880
  }
678
881
 
679
- // 2. open the feature → dev PR
680
- let featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, devBranch);
882
+ const featureBase = policy.base;
883
+
884
+ // 2. open the feature PR against the configured base
885
+ let featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, featureBase);
681
886
  if (!featurePr) {
682
- const devHasIt = readVersionAt(repoPath, component, `origin/${devBranch}`);
887
+ const devHasIt = readVersionAt(repoPath, component, `origin/${featureBase}`);
683
888
  if (devHasIt.ok && cmpSemver(devHasIt.version, targetVersion) >= 0) {
684
889
  note('feature-merged', `${targetVersion} is already on ${devBranch}`);
685
890
  } else {
686
891
  const created = spawnArgs('gh', [
687
- 'pr', 'create', '--repo', ownerRepo, '--base', devBranch, '--head', branch,
892
+ 'pr', 'create', '--repo', ownerRepo, '--base', featureBase, '--head', branch,
688
893
  '--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.`,
894
+ '--body', `Release ${tag}.\n\nVersion bump and CHANGELOG entry land together, in this one change — an explicit dispatch reads both from main before creating the tag.`,
690
895
  ]);
691
896
  if (created.status !== 0) return { ok: false, error: `gh pr create failed: ${created.stderr}` };
692
- featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, devBranch);
897
+ featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, featureBase);
693
898
  note('feature-pr', `opened #${featurePr}`);
694
899
  }
695
900
  }
696
901
 
697
902
  // 3. wait for its checks, then squash it into dev
698
903
  if (featurePr) {
699
- const gate = waitForChecks(ownerRepo, featurePr, deadline, pollSeconds, log);
904
+ const gate = waitForChecks(ownerRepo, featurePr, featureBase, config, deadline, pollSeconds, log);
700
905
  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';
906
+ 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' };
907
+ const method = (devBranch ? config?.mergeMethod?.featureToDevMethod : config?.mergeMethod?.devToMainMethod) ?? 'squash';
703
908
  const merged = spawnArgs('gh', ['pr', 'merge', String(featurePr), '--repo', ownerRepo, `--${method}`, '--delete-branch']);
704
909
  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})`);
910
+ note('feature-merged', `merged #${featurePr} into ${featureBase} (${method})`);
706
911
  rmSync(dir, { recursive: true, force: true });
707
912
  git(['worktree', 'prune'], { cwd: repoPath });
708
913
  }
@@ -710,55 +915,143 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
710
915
  // 4. open (or find) the dev → main promotion. shipflow's rendered auto-merge
711
916
  // workflow turns on native auto-merge from here; nothing polls for it.
712
917
  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}` };
918
+ let promotion = null;
919
+ if (devBranch) {
721
920
  promotion = prNumberFor(ownerRepo, `${owner}:${devBranch}`, mainBranch);
722
- note('promotion-open', `opened promotion #${promotion}`);
723
- } else {
724
- note('promotion-open', `promotion #${promotion} already open`);
921
+ if (!promotion) {
922
+ const created = spawnArgs('gh', [
923
+ 'pr', 'create', '--repo', ownerRepo, '--base', mainBranch, '--head', devBranch,
924
+ '--title', `release: ${name} v${targetVersion}`,
925
+ '--body', releaseBody(name, targetVersion, status.collateral),
926
+ ]);
927
+ if (created.status !== 0) return { ok: false, error: `gh pr create failed on the promotion: ${created.stderr}` };
928
+ promotion = prNumberFor(ownerRepo, `${owner}:${devBranch}`, mainBranch);
929
+ note('promotion-open', `opened promotion #${promotion}`);
930
+ } else {
931
+ note('promotion-open', `promotion #${promotion} already open`);
932
+ }
933
+
934
+ // 5. wait for the promotion to auto-merge, then for the tag to appear
935
+ const landed = waitForMerge(ownerRepo, promotion, deadline, pollSeconds, log);
936
+ if (!landed.ok) return landed;
937
+ if (!landed.done) {
938
+ return { ok: true, done: false, stage: 'promotion-open', promotion, tag, targetVersion, log, next: 'call release-cut again — waiting on the promotion to auto-merge' };
939
+ }
725
940
  }
726
941
 
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' };
942
+ // 6. The promotion landing cuts NOTHING on its own. Every caller's release
943
+ // job is `workflow_dispatch`-only by deliberate design, so that this line
944
+ // is the single point at which a tag is ever created — one named
945
+ // component, released because someone asked for it.
946
+ //
947
+ // This is load-bearing, not ceremony: until 2026-08-02 the release jobs
948
+ // also ran on `push`, and a `dev -> main` merge therefore tagged and npm-
949
+ // published everything bumped on dev, seconds after merging, with no
950
+ // dispatch involved. Removing `push` without adding this dispatch would
951
+ // leave cut() waiting forever for a tag nobody cuts.
952
+ //
953
+ // Safe to re-run: _release.yml no-ops on an existing tag, and its
954
+ // `concurrency: release-<skill>` group serialises a resumed call behind
955
+ // an in-flight one.
956
+ const already = tagExistsOnRemote(repoPath, tag);
957
+ if (!(already.ok && already.exists)) {
958
+ const verified = verifyDispatch(repoPath, component, mainBranch, targetVersion, expectedNotes);
959
+ if (!verified.ok) return verified;
960
+ const d = spawnArgs('gh', ['workflow', 'run', component.workflowFile, '--ref', mainBranch, '--repo', ownerRepo]);
961
+ if (d.status !== 0) {
962
+ 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.` };
963
+ }
964
+ note('dispatch', `dispatched ${component.workflowFile} on ${mainBranch} — this, not the merge, is what cuts the tag`);
732
965
  }
733
- return waitForTag(repoPath, tag, deadline, pollSeconds, log, ownerRepo, promotion);
966
+ const result = waitForTag(repoPath, tag, deadline, pollSeconds, log, ownerRepo, promotion);
967
+ return { ...result, targetVersion };
968
+ }
969
+
970
+ function notesAt(repoPath, component, ref, version) {
971
+ const result = git(['show', `${ref}:${component.changelog}`], { cwd: repoPath });
972
+ if (result.status !== 0) return null;
973
+ const lines = result.stdout.split('\n');
974
+ const start = lines.findIndex((line) => line.startsWith('## ') && (line.slice(3).split(/\s+/)[0] === version || line.slice(3).split(/\s+/)[0] === `[${version}]`));
975
+ if (start < 0) return null;
976
+ const end = lines.findIndex((line, i) => i > start && line.startsWith('## '));
977
+ return lines.slice(start + 1, end < 0 ? undefined : end).join('\n').trim() || null;
978
+ }
979
+
980
+ function verifyDispatch(repoPath, component, mainBranch, version, expectedNotes) {
981
+ const fetched = git(['fetch', 'origin', '--tags', '--prune'], { cwd: repoPath });
982
+ if (fetched.status !== 0) return { ok: false, error: 'cannot fetch main before dispatch' };
983
+ const current = readVersionAt(repoPath, component, `origin/${mainBranch}`);
984
+ if (!current.ok || current.version !== version) return { ok: false, error: `main version changed before dispatch; expected ${version}` };
985
+ if (!expectedNotes || notesAt(repoPath, component, `origin/${mainBranch}`, version) !== expectedNotes) {
986
+ return { ok: false, error: 'main changelog missing or changed before dispatch' };
987
+ }
988
+ return { ok: true };
734
989
  }
735
990
 
736
991
  function releaseBody(name, version, collateral) {
737
992
  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.`
993
+ ? `\n\n**This promotion also moves these bumps to main** (a promotion is atomic and carries all of dev): ` +
994
+ `${collateral.map((c) => `\`${c.tag}\``).join(', ')}. ` +
995
+ `They are **not** released by merging — the release jobs are \`workflow_dispatch\`-only — but each becomes ` +
996
+ `\`untagged-bump-on-main\`, one \`release-cut\` away from a tag.`
739
997
  : '';
740
998
  return `Promotes \`${name}\` v${version} to main.${extra}`;
741
999
  }
742
1000
 
743
- function waitForChecks(ownerRepo, prNumber, deadline, pollSeconds, log) {
1001
+ function waitForChecks(ownerRepo, prNumber, base, config, deadline, pollSeconds, log) {
1002
+ const protection = ghApiJson(`repos/${ownerRepo}/branches/${encodeURIComponent(base)}/protection`);
1003
+ if (!protection.ok && !/404/.test(protection.stderr)) return { ok: false, error: 'could not read required branch checks' };
1004
+ const rules = ghApiJson(`repos/${ownerRepo}/rules/branches/${encodeURIComponent(base)}`);
1005
+ if (!rules.ok) return { ok: false, error: 'could not read branch rules' };
1006
+ const checks = protection.data?.required_status_checks?.checks ?? [];
1007
+ const required = [
1008
+ ...checks.map((c) => ({ context: c.context, appId: c.app_id })),
1009
+ ...(protection.data?.required_status_checks?.contexts ?? [])
1010
+ .filter((context) => !checks.some((c) => c.context === context))
1011
+ .map((context) => ({ context })),
1012
+ ...(rules.data ?? []).filter((r) => r.type === 'required_status_checks')
1013
+ .flatMap((r) => (r.parameters?.required_status_checks ?? []).map((c) => ({ context: c.context, appId: c.integration_id }))),
1014
+ ...(base === branchPolicy(config).main && config?.protectionOwner !== 'external' ? config?.requiredChecks ?? [] : [])
1015
+ .map((context) => ({ context })),
1016
+ ];
1017
+ const matchesRun = (required, run) => run.name === required.context &&
1018
+ (required.appId == null || required.appId === -1 || run.app?.id === required.appId);
1019
+ const matchesStatus = (required, status) => status.context === required.context &&
1020
+ (required.appId == null || required.appId === -1);
744
1021
  for (;;) {
745
1022
  const r = ghApiJson(`repos/${ownerRepo}/pulls/${prNumber}`);
746
1023
  if (!r.ok) return { ok: false, error: `could not read PR #${prNumber}: ${r.stderr}` };
747
1024
  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 ?? [];
1025
+ const runs = [];
1026
+ const contexts = [];
1027
+ for (let page = 1; ; page++) {
1028
+ const cr = ghApiJson(`repos/${ownerRepo}/commits/${sha}/check-runs?per_page=100&page=${page}`);
1029
+ if (!cr.ok) return { ok: false, error: `could not read check runs: ${cr.stderr}` };
1030
+ const batch = cr.data?.check_runs ?? [];
1031
+ runs.push(...batch);
1032
+ if (batch.length < 100) break;
1033
+ }
1034
+ for (let page = 1; ; page++) {
1035
+ const cs = ghApiJson(`repos/${ownerRepo}/commits/${sha}/status?per_page=100&page=${page}`);
1036
+ if (!cs.ok) return { ok: false, error: 'could not read commit statuses' };
1037
+ const batch = cs.data?.statuses ?? [];
1038
+ contexts.push(...batch);
1039
+ if (batch.length < 100) break;
1040
+ }
1041
+ const missing = required.filter((r) => !runs.some((c) => matchesRun(r, c) && c.status === 'completed' && ['success', 'neutral', 'skipped'].includes(c.conclusion)) && !contexts.some((c) => matchesStatus(r, c) && c.state === 'success'));
1042
+ const failedStatuses = contexts.filter((c) => required.some((r) => matchesStatus(r, c)) && ['failure', 'error'].includes(c.state));
1043
+ if (failedStatuses.length) return { ok: false, error: `required statuses failed: ${failedStatuses.map((c) => c.context).join(', ')}` };
751
1044
  const pending = runs.filter((c) => c.status !== 'completed');
752
1045
  const failed = runs.filter((c) => c.status === 'completed' && !['success', 'neutral', 'skipped'].includes(c.conclusion));
753
1046
  if (failed.length > 0) {
754
1047
  return { ok: false, error: `checks failed on PR #${prNumber}: ${failed.map((c) => c.name).join(', ')} — fix them, then call release-cut again` };
755
1048
  }
756
- if (runs.length > 0 && pending.length === 0) {
1049
+ if ((runs.length > 0 || contexts.length > 0) && pending.length === 0 && missing.length === 0) {
757
1050
  log.push({ stage: 'feature-pr', msg: `${runs.length} checks green` });
758
1051
  return { ok: true, done: true };
759
1052
  }
760
1053
  if (Date.now() + pollSeconds * 1000 > deadline) {
761
- log.push({ stage: 'feature-pr', msg: `${pending.length}/${runs.length} checks still running` });
1054
+ log.push({ stage: 'feature-pr', msg: `${pending.length}/${runs.length} checks still running; missing required checks: ${missing.map((r) => r.context).join(', ')}` });
762
1055
  return { ok: true, done: false };
763
1056
  }
764
1057
  sleepSync(pollSeconds * 1000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natjswenson/shipflow",
3
- "version": "0.4.0",
3
+ "version": "0.7.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": "For legacy two-branch consumers only (GitHub flow never reads dev): #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
  }
@@ -6,9 +6,13 @@ name: auto-merge to {{MAIN_BRANCH}}
6
6
  # (handEditDetected) until an explicit --force is passed. Commit both files
7
7
  # together in the same commit.
8
8
  #
9
- # GitHub Flow has no separate "promotion" branch every PR into {{MAIN_BRANCH}}
10
- # is eligible for auto-merge and every merge is release-worthy, so unlike
11
- # dev-main-promotion's template, neither job here restricts on head.ref.
9
+ # GitHub Flow has no separate promotion branch. Only ready PRs from this
10
+ # repository are eligible for auto-merge; repository write access is the trust
11
+ # boundary. Forks (and missing head repositories) skip both jobs because ordinary
12
+ # pull_request events cannot supply repository secrets to forks. Maintainers
13
+ # review fork PRs and enable auto-merge or merge with their own credentials after
14
+ # required checks, and may add release-pending manually. No PR code is executed.
15
+ # Same-repository merged PRs receive an optional reminder, never a tag or release.
12
16
  #
13
17
  # GH_TOKEN uses config.release.releaseCredential, NOT a hardcoded
14
18
  # secrets.GITHUB_TOKEN, because of GitHub's loop-prevention rule: a PR
@@ -25,7 +29,7 @@ name: auto-merge to {{MAIN_BRANCH}}
25
29
 
26
30
  on:
27
31
  pull_request:
28
- types: [opened, reopened, synchronize, closed]
32
+ types: [opened, reopened, synchronize, ready_for_review, closed]
29
33
  branches: [{{MAIN_BRANCH}}]
30
34
 
31
35
  # Deny by default at the workflow level, grant per job. A workflow-level grant
@@ -39,20 +43,30 @@ on:
39
43
  permissions: {}
40
44
 
41
45
  jobs:
42
- # Enables native GitHub auto-merge on open/reopen/synchronize — this job
46
+ # Enables native GitHub auto-merge on open/reopen/synchronize/ready — this job
43
47
  # does NOT wait for checks itself; it turns on auto-merge and exits. The
44
48
  # actual merge happens asynchronously, later, whenever GitHub's own
45
49
  # required-checks gate is satisfied (see the design's discussion of why a
46
50
  # bespoke polling/blocking job was rejected).
47
51
  auto-merge:
48
- if: github.event.action != 'closed'
52
+ if: >-
53
+ github.event.action != 'closed' &&
54
+ github.event.pull_request.draft == false &&
55
+ github.event.pull_request.head.repo.full_name == github.repository
49
56
  runs-on: ubuntu-latest
50
57
  permissions:
51
58
  pull-requests: write
52
59
  steps:
53
60
  - name: Enable auto-merge
54
- run: gh pr merge --auto {{MERGE_FLAG}} "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}"
61
+ run: |
62
+ if [ -z "$GH_TOKEN" ]; then
63
+ echo "Skipping auto-merge: GH_TOKEN is unavailable or not configured."
64
+ exit 0
65
+ fi
66
+ gh pr merge --auto {{MERGE_FLAG}} "$PR_NUMBER" --repo "$PR_REPO"
55
67
  env:
68
+ PR_NUMBER: ${{ github.event.pull_request.number }}
69
+ PR_REPO: ${{ github.repository }}
56
70
  GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
57
71
 
58
72
  # Fires once, when a PR actually merges (a separate event from the job
@@ -63,12 +77,20 @@ jobs:
63
77
  label-release-pending:
64
78
  if: >-
65
79
  github.event.action == 'closed' &&
66
- github.event.pull_request.merged == true
80
+ github.event.pull_request.merged == true &&
81
+ github.event.pull_request.head.repo.full_name == github.repository
67
82
  runs-on: ubuntu-latest
68
83
  permissions:
69
84
  pull-requests: write
70
85
  steps:
71
86
  - name: Apply release-pending label
72
- run: gh pr edit "${{ github.event.pull_request.number }}" --add-label release-pending --repo "${{ github.repository }}"
87
+ run: |
88
+ if [ -z "$GH_TOKEN" ]; then
89
+ echo "Skipping release reminder: GH_TOKEN is unavailable or not configured."
90
+ exit 0
91
+ fi
92
+ gh pr edit "$PR_NUMBER" --add-label release-pending --repo "$PR_REPO"
73
93
  env:
94
+ PR_NUMBER: ${{ github.event.pull_request.number }}
95
+ PR_REPO: ${{ github.repository }}
74
96
  GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}