@natjswenson/shipflow 0.6.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,16 @@
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
+
5
15
  ## 0.6.0 (2026-08-03) — the ambiguous fast path is refused, not guessed
6
16
 
7
17
  ### Fixed
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,11 +211,29 @@ 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.
@@ -198,7 +273,7 @@ at all and `--component` may be omitted.
198
273
  3. **Cut it, and prove it.**
199
274
  ```
200
275
  npx -y @natjswenson/shipflow@latest release-cut --repo <path> --component <name> \
201
- --expect-status-hash <hash-from-step-1> --wait 240
276
+ --version <prepared-or-confirmed-version> --expect-status-hash <hash-from-step-1> --wait 240
202
277
  ```
203
278
  `--expect-status-hash` is mandatory (same TOCTOU discipline as `apply`'s `--expect-state-hash`);
204
279
  `--skip-hash-check` is a named escape hatch, never a default.
@@ -209,7 +284,7 @@ at all and `--component` may be omitted.
209
284
  re-run `release-status` to release what's on dev (the normal recovery), **or** add
210
285
  `--version <x.y.z>` naming exactly the version on main, if you deliberately mean to release
211
286
  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
287
+ bypass — it is only ever accepted when it matches a version already on main, dev, or the verified prepared branch; anything
213
288
  else is refused the same as passing nothing.
214
289
 
215
290
  **`release-cut` is resumable and bounded, and it will usually return `done: false`.** The full
@@ -219,8 +294,8 @@ at all and `--component` may be omitted.
219
294
  derives every stage from live remote state and never from a record of what a previous call did,
220
295
  so a resumed run and a fresh one are the same code path.
221
296
 
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
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
224
299
  is created in this repo, which is why a merge can no longer surprise anyone with a release.
225
300
 
226
301
  4. **Report the tag, and only the tag.** `done: true` carries `tag` and `releaseUrl`, read back
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) {
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,9 +333,19 @@ 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
351
  // Every OTHER component whose version at `dev` carries no tag. A promotion is
@@ -369,20 +380,28 @@ export function collateralComponents(repoPath, config, exceptName, devRef) {
369
380
  return out;
370
381
  }
371
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
+
372
391
  export function readStatus(repoPath, config, name) {
373
392
  const component = resolveComponent(repoPath, config, name);
374
- const mainBranch = config?.branches?.main ?? 'main';
375
- const devBranch = config?.branches?.dev ?? 'dev';
393
+ const policy = branchPolicy(config);
394
+ const { main: mainBranch, dev: devBranch } = policy;
376
395
 
377
396
  // Read from the REMOTE-tracking refs, not the local branches: a local `main`
378
397
  // that has not been fetched in a week would compute a bump against a stale
379
398
  // baseline and silently propose a version that is already tagged.
380
399
  const fetched = git(['fetch', 'origin', '--tags', '--prune'], { cwd: repoPath });
381
400
  const mainRef = revParse(repoPath, `origin/${mainBranch}`) ? `origin/${mainBranch}` : mainBranch;
382
- const devRef = revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch;
401
+ const devRef = devBranch ? (revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch) : null;
383
402
 
384
403
  const onMain = readVersionAt(repoPath, component, mainRef);
385
- const onDev = readVersionAt(repoPath, component, devRef);
404
+ const onDev = devRef ? readVersionAt(repoPath, component, devRef) : { ok: false, version: null };
386
405
  const lastVersion = latestVersionTagged(repoPath, component);
387
406
  const lastTag = lastVersion ? tagFor(component, lastVersion) : null;
388
407
 
@@ -407,7 +426,7 @@ export function readStatus(repoPath, config, name) {
407
426
  notes.push(`could not fetch origin (${fetched?.stderr || 'unknown error'}) — versions and tags below may be stale`);
408
427
  }
409
428
  if (!onMain.ok) blockers.push({ id: 'version-unreadable-on-main', detail: onMain.error });
410
- if (!onDev.ok) blockers.push({ id: 'version-unreadable-on-dev', detail: onDev.error });
429
+ if (devBranch && !onDev.ok) blockers.push({ id: 'version-unreadable-on-dev', detail: onDev.error });
411
430
 
412
431
  const changelogAbs = join(repoPath, component.changelog);
413
432
  if (!existsSync(changelogAbs)) {
@@ -473,17 +492,26 @@ export function readStatus(repoPath, config, name) {
473
492
  const suggestion = suggestBump(since.commits, onMain.version ?? '0.0.0');
474
493
  const nextVersion = suggestion.bump && onMain.ok ? bumpSemver(onMain.version, suggestion.bump) : null;
475
494
 
476
- const collateral = collateralComponents(repoPath, config, name, devRef);
495
+ const collateral = devRef ? collateralComponents(repoPath, config, name, devRef) : [];
496
+ const pendingComponents = collateralComponents(repoPath, config, name, mainRef);
477
497
 
478
498
  // The TOCTOU guard for cut(). Everything that could change the meaning of a
479
499
  // release decision between the moment it is shown to a human and the moment
480
500
  // it is acted on: both branch heads, the versions, the last tag, and who
481
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
+ });
482
508
  const statusHash = sha256(
483
509
  JSON.stringify({
484
510
  component: name,
511
+ preparedBranches,
485
512
  mainSha: revParse(repoPath, mainRef),
486
- devSha: revParse(repoPath, devRef),
513
+ workflowPattern: policy.workflowPattern,
514
+ devSha: devRef ? revParse(repoPath, devRef) : null,
487
515
  versionOnMain: onMain.version,
488
516
  versionOnDev: onDev.version,
489
517
  lastTag,
@@ -500,6 +528,12 @@ export function readStatus(repoPath, config, name) {
500
528
  paths: component.paths,
501
529
  inferredLayout: component.inferredLayout,
502
530
  },
531
+ workflowPattern: policy.workflowPattern,
532
+ releaseBase: policy.base,
533
+ mainBranch,
534
+ devBranch,
535
+ pendingComponents,
536
+ preparedBranches,
503
537
  state,
504
538
  versionOnMain: onMain.version,
505
539
  versionOnDev: onDev.version,
@@ -520,6 +554,13 @@ export function readStatus(repoPath, config, name) {
520
554
 
521
555
  // ─── prepare ─────────────────────────────────────────────────────────────────
522
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
+ }
523
564
  if (relPath.endsWith('.json')) {
524
565
  // Line-targeted rather than JSON.parse → JSON.stringify: reserializing
525
566
  // would reformat the whole file (key order, indentation, trailing
@@ -585,11 +626,12 @@ export function spliceChangelog(existing, version, notes, date) {
585
626
  }
586
627
 
587
628
  export const releaseBranchName = (name, version) => `feature/release-${name}-v${version}`;
588
- 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}`);
589
630
 
590
631
  export function prepare(repoPath, config, name, version, notes, { date, featureBranchPrefix } = {}) {
591
632
  const component = resolveComponent(repoPath, config, name);
592
- const devBranch = config?.branches?.dev ?? 'dev';
633
+ const policy = branchPolicy(config);
634
+ const devBranch = policy.base;
593
635
  const tag = tagFor(component, version);
594
636
  const stamp = date ?? new Date().toISOString().slice(0, 10);
595
637
 
@@ -608,7 +650,7 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
608
650
  if (featureBranchPrefix && !branch.startsWith(featureBranchPrefix)) {
609
651
  return { ok: false, error: `release branch ${branch} does not start with the configured featureBranchPrefix ${featureBranchPrefix}` };
610
652
  }
611
- const dir = worktreeDir(name, version);
653
+ const dir = worktreeDir(repoPath, name, version);
612
654
 
613
655
  // A leftover worktree from an aborted run must not silently become the base
614
656
  // for this one — remove it, then re-create from the CURRENT dev.
@@ -620,7 +662,9 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
620
662
  if (added.status !== 0) return { ok: false, error: `git worktree add failed: ${added.stderr}` };
621
663
 
622
664
  const changed = [];
665
+ const dualHost = component.versionFiles.includes(`skills/${name}/.codex-plugin/plugin.json`);
623
666
  try {
667
+ if (dualHost) checkDualHost(dir, true);
624
668
  for (const relPath of component.versionFiles) {
625
669
  const abs = join(dir, relPath);
626
670
  if (!existsSync(abs)) continue;
@@ -645,6 +689,16 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
645
689
  writeFileSync(clAbs, spliced.content);
646
690
  changed.push(component.changelog);
647
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
+ }
648
702
  // Explicit pathspecs, never `git add -A`. The worktree should contain
649
703
  // nothing else, but "should" is not a guarantee worth a release commit.
650
704
  const staged = git(['add', '--', ...changed], { cwd: dir });
@@ -659,6 +713,21 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
659
713
  }
660
714
  }
661
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
+
662
731
  // ─── resolving the release target ────────────────────────────────────────────
663
732
  // The one place a target version is decided. Before this existed, `cut()`
664
733
  // derived it twice, ten lines apart — once preferring dev, once preferring
@@ -675,9 +744,18 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
675
744
  // releases a version which isn't actually on the branch being dispatched.
676
745
  export function resolveReleaseTarget(status, requestedVersion = null) {
677
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
+
678
755
 
679
756
  if (state === 'untagged-bump-on-main') {
680
757
  if (!devAhead) {
758
+ if (requestedVersion && requestedVersion !== versionOnMain) return { ok: false, error: 'requested version does not match main' };
681
759
  // The common, unambiguous case: whatever is on main is the only
682
760
  // candidate, dev has nothing higher.
683
761
  return { ok: true, version: versionOnMain, via: 'dispatch-on-main' };
@@ -705,7 +783,7 @@ export function resolveReleaseTarget(status, requestedVersion = null) {
705
783
  // Every other state (`clean`, `bump-on-dev-unpromoted`, `version-behind-tag`)
706
784
  // already has a single unambiguous candidate — dev, when it carries the
707
785
  // prepared bump, else main — matching what `cut()` used before this existed.
708
- return { ok: true, version: versionOnDev ?? versionOnMain, via: 'prepared-branch' };
786
+ return { ok: true, version: requestedVersion ?? versionOnDev ?? versionOnMain, via: 'prepared-branch' };
709
787
  }
710
788
 
711
789
  // ─── cut ─────────────────────────────────────────────────────────────────────
@@ -730,8 +808,8 @@ function sleepSync(ms) {
730
808
 
731
809
  export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHash = null, skipHashCheck = false, ownerRepo, pollSeconds = 15, version = null } = {}) {
732
810
  const component = resolveComponent(repoPath, config, name);
733
- const mainBranch = config?.branches?.main ?? 'main';
734
- const devBranch = config?.branches?.dev ?? 'dev';
811
+ const policy = branchPolicy(config);
812
+ const { main: mainBranch, dev: devBranch } = policy;
735
813
  const owner = ownerRepo.split('/')[0];
736
814
 
737
815
  const status = readStatus(repoPath, config, name);
@@ -749,13 +827,28 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
749
827
  // three-way state (#173: main has an untagged bump AND dev carries
750
828
  // something higher) is refused here rather than acted on by the fast path
751
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
+ }
752
835
  const target = resolveReleaseTarget(status, version);
753
836
  if (!target.ok) return { ok: false, error: target.error };
754
837
  const targetVersion = target.version;
755
838
  const tag = tagFor(component, targetVersion);
756
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
+ }
757
845
  const deadline = Date.now() + waitSeconds * 1000;
758
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);
759
852
  const note = (stage, msg) => log.push({ stage, msg });
760
853
 
761
854
  // Fast path: the bump is already on main and simply was never tagged (a
@@ -765,6 +858,8 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
765
858
  if (already.ok && already.exists) {
766
859
  return { ok: true, done: true, stage: 'tag', tag, targetVersion, note: 'already released' };
767
860
  }
861
+ const verified = verifyDispatch(repoPath, component, mainBranch, targetVersion, expectedNotes);
862
+ if (!verified.ok) return verified;
768
863
  const d = spawnArgs('gh', ['workflow', 'run', component.workflowFile, '--ref', mainBranch, '--repo', ownerRepo]);
769
864
  if (d.status !== 0) return { ok: false, error: `workflow dispatch failed: ${d.stderr}` };
770
865
  note('dispatch', `dispatched ${component.workflowFile} on ${mainBranch}`);
@@ -773,44 +868,46 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
773
868
  }
774
869
 
775
870
  // 1. push the prepared branch
776
- if (!revParse(repoPath, branch)) {
871
+ if (!preparedExists && !baseHasVersion) {
777
872
  return { ok: false, error: `branch ${branch} does not exist — run release-prepare first` };
778
873
  }
779
- const dir = worktreeDir(name, targetVersion);
874
+ const dir = worktreeDir(repoPath, name, targetVersion);
780
875
  const pushCwd = existsSync(dir) ? dir : repoPath;
781
- if (!revParse(repoPath, `origin/${branch}`)) {
876
+ if (preparedExists && !baseHasVersion && !revParse(repoPath, `origin/${branch}`)) {
782
877
  const pushed = git(['push', '-u', 'origin', branch], { cwd: pushCwd });
783
878
  if (pushed.status !== 0) return { ok: false, error: `git push failed: ${pushed.stderr}` };
784
879
  note('push', `pushed ${branch}`);
785
880
  }
786
881
 
787
- // 2. open the feature → dev PR
788
- 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);
789
886
  if (!featurePr) {
790
- const devHasIt = readVersionAt(repoPath, component, `origin/${devBranch}`);
887
+ const devHasIt = readVersionAt(repoPath, component, `origin/${featureBase}`);
791
888
  if (devHasIt.ok && cmpSemver(devHasIt.version, targetVersion) >= 0) {
792
889
  note('feature-merged', `${targetVersion} is already on ${devBranch}`);
793
890
  } else {
794
891
  const created = spawnArgs('gh', [
795
- 'pr', 'create', '--repo', ownerRepo, '--base', devBranch, '--head', branch,
892
+ 'pr', 'create', '--repo', ownerRepo, '--base', featureBase, '--head', branch,
796
893
  '--title', `chore(${name}): release v${targetVersion}`,
797
- '--body', `Release ${tag}.\n\nVersion bump and CHANGELOG entry land together, in this one change — releases here are publish-on-merge, so a follow-up promotion to fix notes is too late.`,
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.`,
798
895
  ]);
799
896
  if (created.status !== 0) return { ok: false, error: `gh pr create failed: ${created.stderr}` };
800
- featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, devBranch);
897
+ featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, featureBase);
801
898
  note('feature-pr', `opened #${featurePr}`);
802
899
  }
803
900
  }
804
901
 
805
902
  // 3. wait for its checks, then squash it into dev
806
903
  if (featurePr) {
807
- const gate = waitForChecks(ownerRepo, featurePr, deadline, pollSeconds, log);
904
+ const gate = waitForChecks(ownerRepo, featurePr, featureBase, config, deadline, pollSeconds, log);
808
905
  if (!gate.ok) return gate;
809
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' };
810
- const method = config?.mergeMethod?.featureToDevMethod ?? 'squash';
907
+ const method = (devBranch ? config?.mergeMethod?.featureToDevMethod : config?.mergeMethod?.devToMainMethod) ?? 'squash';
811
908
  const merged = spawnArgs('gh', ['pr', 'merge', String(featurePr), '--repo', ownerRepo, `--${method}`, '--delete-branch']);
812
909
  if (merged.status !== 0) return { ok: false, error: `gh pr merge failed on the feature PR: ${merged.stderr}` };
813
- note('feature-merged', `merged #${featurePr} into ${devBranch} (${method})`);
910
+ note('feature-merged', `merged #${featurePr} into ${featureBase} (${method})`);
814
911
  rmSync(dir, { recursive: true, force: true });
815
912
  git(['worktree', 'prune'], { cwd: repoPath });
816
913
  }
@@ -818,25 +915,28 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
818
915
  // 4. open (or find) the dev → main promotion. shipflow's rendered auto-merge
819
916
  // workflow turns on native auto-merge from here; nothing polls for it.
820
917
  git(['fetch', 'origin', '--prune'], { cwd: repoPath });
821
- let promotion = prNumberFor(ownerRepo, `${owner}:${devBranch}`, mainBranch);
822
- if (!promotion) {
823
- const created = spawnArgs('gh', [
824
- 'pr', 'create', '--repo', ownerRepo, '--base', mainBranch, '--head', devBranch,
825
- '--title', `release: ${name} v${targetVersion}`,
826
- '--body', releaseBody(name, targetVersion, status.collateral),
827
- ]);
828
- if (created.status !== 0) return { ok: false, error: `gh pr create failed on the promotion: ${created.stderr}` };
918
+ let promotion = null;
919
+ if (devBranch) {
829
920
  promotion = prNumberFor(ownerRepo, `${owner}:${devBranch}`, mainBranch);
830
- note('promotion-open', `opened promotion #${promotion}`);
831
- } else {
832
- note('promotion-open', `promotion #${promotion} already open`);
833
- }
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
+ }
834
933
 
835
- // 5. wait for the promotion to auto-merge, then for the tag to appear
836
- const landed = waitForMerge(ownerRepo, promotion, deadline, pollSeconds, log);
837
- if (!landed.ok) return landed;
838
- if (!landed.done) {
839
- return { ok: true, done: false, stage: 'promotion-open', promotion, tag, targetVersion, log, next: 'call release-cut again — waiting on the promotion to auto-merge' };
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
+ }
840
940
  }
841
941
 
842
942
  // 6. The promotion landing cuts NOTHING on its own. Every caller's release
@@ -855,6 +955,8 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
855
955
  // an in-flight one.
856
956
  const already = tagExistsOnRemote(repoPath, tag);
857
957
  if (!(already.ok && already.exists)) {
958
+ const verified = verifyDispatch(repoPath, component, mainBranch, targetVersion, expectedNotes);
959
+ if (!verified.ok) return verified;
858
960
  const d = spawnArgs('gh', ['workflow', 'run', component.workflowFile, '--ref', mainBranch, '--repo', ownerRepo]);
859
961
  if (d.status !== 0) {
860
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.` };
@@ -865,6 +967,27 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
865
967
  return { ...result, targetVersion };
866
968
  }
867
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 };
989
+ }
990
+
868
991
  function releaseBody(name, version, collateral) {
869
992
  const extra = collateral.length
870
993
  ? `\n\n**This promotion also moves these bumps to main** (a promotion is atomic and carries all of dev): ` +
@@ -875,25 +998,60 @@ function releaseBody(name, version, collateral) {
875
998
  return `Promotes \`${name}\` v${version} to main.${extra}`;
876
999
  }
877
1000
 
878
- 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);
879
1021
  for (;;) {
880
1022
  const r = ghApiJson(`repos/${ownerRepo}/pulls/${prNumber}`);
881
1023
  if (!r.ok) return { ok: false, error: `could not read PR #${prNumber}: ${r.stderr}` };
882
1024
  const sha = r.data?.head?.sha;
883
- const cr = ghApiJson(`repos/${ownerRepo}/commits/${sha}/check-runs?per_page=100`);
884
- if (!cr.ok) return { ok: false, error: `could not read check runs: ${cr.stderr}` };
885
- const runs = cr.data?.check_runs ?? [];
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(', ')}` };
886
1044
  const pending = runs.filter((c) => c.status !== 'completed');
887
1045
  const failed = runs.filter((c) => c.status === 'completed' && !['success', 'neutral', 'skipped'].includes(c.conclusion));
888
1046
  if (failed.length > 0) {
889
1047
  return { ok: false, error: `checks failed on PR #${prNumber}: ${failed.map((c) => c.name).join(', ')} — fix them, then call release-cut again` };
890
1048
  }
891
- if (runs.length > 0 && pending.length === 0) {
1049
+ if ((runs.length > 0 || contexts.length > 0) && pending.length === 0 && missing.length === 0) {
892
1050
  log.push({ stage: 'feature-pr', msg: `${runs.length} checks green` });
893
1051
  return { ok: true, done: true };
894
1052
  }
895
1053
  if (Date.now() + pollSeconds * 1000 > deadline) {
896
- 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(', ')}` });
897
1055
  return { ok: true, done: false };
898
1056
  }
899
1057
  sleepSync(pollSeconds * 1000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natjswenson/shipflow",
3
- "version": "0.6.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",
@@ -109,7 +109,7 @@
109
109
  {
110
110
  "id": "untagged-bump-is-not-permission-to-cut",
111
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."
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."
113
113
  }
114
114
  ],
115
115
  "cli_commands_referenced": ["detect", "plan", "apply", "releases", "release-dispatch", "release-status", "release-prepare", "release-cut", "rename-default-branch"],
@@ -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}} }}