@natjswenson/shipflow 0.3.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,161 @@
2
2
 
3
3
  All notable changes to `@natjswenson/shipflow` are documented here.
4
4
 
5
+ ## 0.6.0 (2026-08-03) — the ambiguous fast path is refused, not guessed
6
+
7
+ ### Fixed
8
+
9
+ - **`release-cut`'s fast path could silently tag the OLDER version.** When a
10
+ component's `main` carried an untagged bump *and* `dev` independently carried
11
+ something higher — `lastTag < main < dev` — `readStatus` collapsed both facts
12
+ into the single `untagged-bump-on-main` state, and `cut()`'s fast path acted
13
+ on that state alone, dispatching a release for whatever sat on `main` while
14
+ the version actually being released sat, unread, on `dev`. Hit for real
15
+ during `/release eval` on 2026-08-03: `main` was at 0.2.1, `dev` at 0.3.0 —
16
+ `cut` would have tagged `eval-v0.2.1` and reported success. Caught only by
17
+ reading `release.mjs` before running the irreversible step.
18
+
19
+ `readStatus` now reports a `devAhead` fact (`{ version, aheadOfMain: true }`)
20
+ independently of `state` — including on a component's never-released first
21
+ bump, the sibling case a fix scoped only to the existing `state` branch would
22
+ have missed — plus a `dev-ahead-of-main` blocker, scoped to exactly the state
23
+ where the fast path is armed. A new pure `resolveReleaseTarget(status,
24
+ requestedVersion)` is the single place the release target is now decided;
25
+ `cut()` calls it once, before any network call, and refuses outright when
26
+ the target is ambiguous, naming both versions. There is no longer a code
27
+ path on which the tag `cut` waits for can disagree with the version it
28
+ decided to release.
29
+
30
+ The refusal is escapable, deliberately not inescapable: `release-cut` gains
31
+ `--version <x.y.z>`, a *confirmation* rather than a bypass — it is only ever
32
+ accepted when it names a version already present on `main` or `dev` in that
33
+ status, so there is no value of it that releases a version which isn't
34
+ actually on the branch being dispatched.
35
+
36
+ ## 0.5.0 (2026-08-02) — the merge stops cutting tags; the dispatch is the release
37
+
38
+ ### Changed
39
+
40
+ - **`release-cut` now dispatches the component's release workflow itself, after
41
+ the promotion lands.** Previously it merged the promotion and then *waited*
42
+ for a tag that a `push`-triggered job happened to cut. That made a merge the
43
+ real release trigger, which meant any promotion released everything bumped on
44
+ `dev` — whether or not anyone asked, and irreversibly for skills that publish
45
+ to npm.
46
+
47
+ Paired with every caller's `release` job becoming `workflow_dispatch`-only,
48
+ this makes the dispatch the **single point at which a tag is ever created**.
49
+ A `dev → main` merge now moves a version bump to `main` and stops there; the
50
+ component simply becomes `untagged-bump-on-main` until someone releases it on
51
+ purpose.
52
+
53
+ The two halves are load-bearing together. Removing the `push` gate without
54
+ this dispatch leaves `release-cut` waiting forever for a tag nobody cuts;
55
+ adding the dispatch without removing the gate double-releases.
56
+
57
+ - **`collateral` means something smaller and safer now.** It still lists every
58
+ other component whose bump the same promotion moves to `main` — that is
59
+ unavoidable, a promotion is atomic — but those components are no longer
60
+ *released* by it. The disclosure stays because the user should know what their
61
+ promotion moves, and which components are now one dispatch from a release
62
+ nobody asked for.
63
+
64
+ ### Fixed
65
+
66
+ - **`release-status` returned a wrong commit list in a shallow clone, silently.**
67
+ `git log <tag>..<ref>` excludes everything reachable from `<tag>`, and that
68
+ exclusion needs full ancestry. In a grafted history it under-applies, so the
69
+ range returns commits that shipped long ago — without erroring, and therefore
70
+ with a `suggestedBump` derived from fiction.
71
+
72
+ Observed on this repo the day 0.4.0 shipped: a depth-1 checkout of `main`
73
+ reported **1 unreleased commit** for a component a full clone correctly
74
+ reported as **0**, which would have proposed a patch release for nothing.
75
+ `actions/checkout` is depth-1 by default, so any CI job calling `release-status`
76
+ hit this.
77
+
78
+ A shallow repository is now a **blocker**, not a note — every number derived
79
+ from the commit range is untrustworthy, so the honest answer is to refuse and
80
+ say `git fetch --unshallow`, rather than to report a plausible wrong one.
81
+
82
+ ## 0.4.0 (2026-08-02) — release one named thing, and prove the tag exists
83
+
84
+ ### Added
85
+
86
+ - **Component releases: `release-status`, `release-prepare`, `release-cut`.**
87
+ A *component* is one independently-versioned thing in a repo — a skill in a
88
+ monorepo, or the repo itself. `release.componentLayout` says where its
89
+ version files, changelog, tag and release workflow live, with `{name}` as the
90
+ only substitution token, and `release.components` lists the names. A repo
91
+ with neither gets a single component inferred from its root, so a one-project
92
+ repo needs no config at all and `--component` may be omitted.
93
+
94
+ This closes the half of releasing that was never automated. The mechanical
95
+ end already worked — `_release.yml` is version-driven and idempotent, every
96
+ caller has `workflow_dispatch`, `release-dispatch` exists. Everything
97
+ *upstream* of the dispatch was manual, and that is where the friction and the
98
+ mistakes lived.
99
+
100
+ - **`release-status` reads state instead of guessing it.** The version on main
101
+ and on dev (read via `git show`, so the user's working tree is never touched
102
+ or checked out), the last tag, every commit since that tag that touched this
103
+ component's paths, a suggested bump with its reason, and a `statusHash` that
104
+ `release-cut` requires back — the same TOCTOU discipline `apply` already has.
105
+
106
+ - **`collateral`: every other component the same promotion would release.** A
107
+ `dev → main` promotion is atomic and carries all of dev, so cutting a release
108
+ for one component also releases anything else sitting bumped-but-untagged
109
+ there. The SKILL.md rule is that this list is named to the user before the
110
+ irreversible step, never merely present in JSON an agent might skim past.
111
+
112
+ - **`release-cut` is resumable, bounded, and proves the tag.** The full path —
113
+ feature PR, checks, merge, promotion, auto-merge, release run, tag — takes
114
+ longer than one call should block for, so each call advances as far as it can
115
+ and returns the stage it is parked at. Every stage is derived from live remote
116
+ state, never from a record of a previous call, so a resumed run and a fresh
117
+ one are the same code path. It reports `done: true` only after reading the tag
118
+ back from origin: a dispatched workflow, a merged PR and a green check are all
119
+ still "not done."
120
+
121
+ - **Version files can be `package.json`, `SKILL.md` frontmatter, `pyproject.toml`
122
+ or a top-level `project.yml`.** A component model that only reads
123
+ `package.json` is not generic, it is a node model — the first two non-node
124
+ repos this was pointed at were a Python project and an Xcode project. TOML and
125
+ YAML are matched at column zero only: both formats nest, and an indented
126
+ `version` is a dependency pin, not the project's own version. Releasing the
127
+ wrong number is worse than reporting none.
128
+
129
+ ### Fixed
130
+
131
+ - **`spliceChangelog` built a regex out of the version string and escaped only
132
+ the dots**, leaving `\`, `*`, `+`, `(` and `[` live all the way into
133
+ `new RegExp`. `prepare` rejects a non-semver version before reaching it, so
134
+ this was not exploitable through the CLI — but the function is exported and
135
+ independently callable, and a guard that lives in the caller is not a guard.
136
+ It now matches with plain string operations, which is also exactly what
137
+ `_release.yml`'s `awk` does (`/^## / && index($0, ver)`), so the duplicate
138
+ check and the release-time extractor answer the same question the same way.
139
+ Found by CodeQL (`js/incomplete-sanitization`, high) on PR #158.
140
+
141
+ - **`dispatchReleaseWorkflow` ignored the `ownerRepo` it was given.** It shelled
142
+ out to `gh workflow run` with no `--repo`, so `gh` inferred the repository
143
+ from the process's working directory — which is routinely *not* the repo
144
+ `--repo <path>` points at. A dispatch into the wrong repository still exits 0,
145
+ so this failed silently in exactly the setup the flag exists for.
146
+
147
+ ### Notes
148
+
149
+ - A breaking change on a component still in 0.x is capped at a **minor** bump,
150
+ and reported as capped rather than applied silently. Declaring 1.0.0 is an
151
+ API-stability promise, and no commit message is entitled to make it on the
152
+ maintainer's behalf.
153
+ - `release-prepare` does its work in a throwaway `git worktree`, so unrelated
154
+ uncommitted work in the user's tree cannot be swept into a release commit.
155
+ Real trees have parallel work in them; this monorepo's had an entire untracked
156
+ skill in it while this feature was written.
157
+ - No template changed, so no rendered workflow and no `renderedTemplateHashes`
158
+ entry moves in this release.
159
+
5
160
  ## 0.3.3 (2026-08-01) — least-privilege permissions in every rendered workflow
6
161
 
7
162
  - **Every rendered workflow granted its permissions at the workflow level, so
package/README.md CHANGED
@@ -1,47 +1,105 @@
1
1
  # shipflow
2
2
 
3
- [![npm](https://img.shields.io/npm/v/@natjswenson/shipflow?color=blue)](https://www.npmjs.com/package/@natjswenson/shipflow)
4
- [![license](https://img.shields.io/npm/l/@natjswenson/shipflow)](./LICENSE)
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
5
5
 
6
- A Claude Code skill that scaffolds a configurable branching, auto-merge, branch-cleanup, and release-tagging workflow into any repo — across three selectable patterns, not just one.
6
+ ---
7
+ <!-- <<< press:masthead -->
7
8
 
8
- Run it in a target repo and it detects existing branch protection, CI checks, release conventions, and which branching pattern the repo already uses, shows you a plan, and only mutates anything after you confirm. The skill package is identical everywhere — the actual policy (workflow pattern, branch names, required checks, release mode, ...) lives in the target repo's own `.github/shipflow.json`, committed and auditable.
9
+ *Scaffold branching, auto-merge, branch cleanup and release tagging into any repo.*
9
10
 
10
- ## Patterns
11
+ > **Nothing is mutated that the plan did not show, and nothing is mutated before you confirm it.**
11
12
 
12
- | Pattern | Shape |
13
- |---|---|
14
- | `dev-main-promotion` | Long-lived `dev` + `main`; a promotion PR auto-merges `dev` into `main` |
15
- | `github-flow` | Single long-lived `main`; every PR merges (and auto-merges) directly to it |
16
- | `gitflow` | `develop` + `main` + transient `release/*`/`hotfix/*` branches, for software maintaining multiple released versions concurrently |
13
+ [![npm](https://img.shields.io/npm/v/@natjswenson/shipflow?color=blue)](https://www.npmjs.com/package/@natjswenson/shipflow) [![license](https://img.shields.io/npm/l/@natjswenson/shipflow)](./LICENSE)
17
14
 
18
- `shipflow detect` scores all three against the repo's existing shape (branches, tags, workflow files) and either confirms a confident match with you or asks you to pick when detection is ambiguous or the repo is greenfield — it never silently picks one.
15
+ ## Why install this
19
16
 
20
- ## How it works
17
+ Branch protection, auto-merge and release tagging are the settings everybody
18
+ configures once, by hand, in a web UI, and then cannot answer questions about six
19
+ months later. Nothing records what was intended, so drift is undetectable.
21
20
 
22
- 1. **`/shipflow` in Claude Code** runs an interactive setup interview — detects the workflow pattern, branch protection, CI, and the default branch, confirms them (plus `requiredChecks` and `protectionOwner`) with you, and writes `.github/shipflow.json`.
23
- 2. **`shipflow plan`** diffs that config against live repo state and shows exactly what would change, before anything is touched.
24
- 3. **`shipflow apply`** only after you confirm renders the resolved pattern's workflow file(s) and makes the confirmed mutations. Nothing happens outside what the plan showed.
25
- 4. Ongoing: promotions/merges auto-merge once required checks pass; a durable `release-pending` label survives the async gap until a later `shipflow releases` check asks whether to cut a release.
21
+ shipflow makes the policy a committed file. Run it in a target repo and it
22
+ detects existing branch protection, CI checks, release conventions and which
23
+ branching pattern the repo already uses, shows you a plan, and only mutates
24
+ anything after you confirm. The skill package is identical everywhere; the actual
25
+ policy lives in the target repo's own `.github/shipflow.json`, auditable in a
26
+ diff.
26
27
 
27
- ## Quick start
28
+ It supports three patterns rather than imposing one, and `detect` scores all
29
+ three against the repo's real shape — it never silently picks one.
28
30
 
29
- All deterministic work runs through the published CLI:
31
+ ## What you get
32
+
33
+ | Path | What it provides |
34
+ |---|---|
35
+ | `skills/shipflow/SKILL.md` | The interactive setup interview, and where it must stop and ask. |
36
+ | `skills/shipflow/bin/` | The CLI: `detect`, `plan`, `apply`, `releases`, `release-dispatch`. |
37
+ | `skills/shipflow/templates/` | The workflow files each pattern renders. |
38
+ | `skills/shipflow/skill-invariants.json` | The prose guardrails and the baseline eval declaration. |
39
+
40
+ ## Quick start
30
41
 
31
42
  ```sh
32
43
  npx -y @natjswenson/shipflow@latest detect --repo . --main main --dev dev
33
44
  ```
34
45
 
35
- > **Always pin `@latest`.** Without an explicit version/tag, `npx` can silently resolve a stale install already on your `PATH` instead of fetching the current version from the registry — with no warning. If you've ever run `npm install -g @natjswenson/shipflow` for manual testing, remove it: `npm uninstall -g @natjswenson/shipflow`.
46
+ > **Always pin `@latest`.** Without an explicit version or tag, `npx` can
47
+ > silently resolve a stale install already on your `PATH` instead of fetching the
48
+ > current version from the registry, with no warning. This cost this very repo a
49
+ > release: a bare invocation ran a stale global 0.2.0, missing every fix through
50
+ > 0.2.5 including a Critical template-injection fix. If you have ever run
51
+ > `npm install -g @natjswenson/shipflow`, remove it.
52
+
53
+ ## Triggers
54
+
55
+ - Setting up branch protection standards on a repo.
56
+ - Applying deployment or release standards to a repo.
57
+ - Wanting long-lived `dev`/`main` branches with auto-merge and branch cleanup.
58
+ - "why did this PR not auto-merge", or a release that should have been tagged and
59
+ was not.
60
+
61
+ ## Requirements
62
+
63
+ - Node 18+.
64
+ - [`gh`](https://cli.github.com/), authenticated with admin rights on the target
65
+ repo — branch protection cannot be read or written without them.
66
+ - A GitHub repo. Deletion rulesets need GitHub Pro or a public repo; on a private
67
+ free-tier repo that call returns 403 and shipflow reports it rather than
68
+ pretending it applied.
36
69
 
37
- Full interactive setup flow: [`skills/shipflow/SKILL.md`](skills/shipflow/SKILL.md).
70
+ ## Patterns
71
+
72
+ | Pattern | Shape |
73
+ |---|---|
74
+ | `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 |
76
+ | `gitflow` | `develop` + `main` + transient `release/*`/`hotfix/*`, for software maintaining multiple released versions concurrently |
77
+
78
+ `detect` scores all three against the repo's branches, tags and workflow files,
79
+ then either confirms a confident match with you or asks you to pick when
80
+ detection is ambiguous or the repo is greenfield.
81
+
82
+ ## How it works
83
+
84
+ 1. **`/shipflow` in Claude Code** runs an interactive setup interview — detects
85
+ the pattern, branch protection, CI and the default branch, confirms them (plus
86
+ `requiredChecks` and `protectionOwner`) with you, and writes
87
+ `.github/shipflow.json`.
88
+ 2. **`shipflow plan`** diffs that config against live repo state and shows
89
+ exactly what would change, before anything is touched.
90
+ 3. **`shipflow apply`** — only after you confirm — renders the resolved pattern's
91
+ workflow files and makes the confirmed mutations. Nothing happens outside what
92
+ the plan showed.
93
+ 4. Ongoing: promotions auto-merge once required checks pass; a durable
94
+ `release-pending` label survives the async gap until a later
95
+ `shipflow releases` check asks whether to cut a release.
38
96
 
39
97
  ## Commands
40
98
 
41
99
  | Command | What it does |
42
100
  |---|---|
43
101
  | `detect --repo <path> [--main <name>] [--dev <name>]` | Inspect live repo state: branch protection, CI checks, release conventions |
44
- | `plan --repo <path>` | Diff `.github/shipflow.json` against live state; prints what would change + a state hash |
102
+ | `plan --repo <path>` | Diff `.github/shipflow.json` against live state; prints what would change plus a state hash |
45
103
  | `apply --repo <path> --expect-state-hash <hash> [--dry-run] [--force <id> --force-reason <text>]` | Apply a confirmed plan |
46
104
  | `releases --repo <path>` | List `dev → main` promotions still labeled `release-pending` |
47
105
  | `release-dispatch --repo <path> --pr <n> --workflow-file <f>... --ref <ref>` | Dispatch each changed skill's release workflow; clear the label on success |
@@ -51,16 +109,39 @@ Every command prints JSON to stdout.
51
109
 
52
110
  ## Status
53
111
 
54
- **`release.mode: "manual-gate"`** (the only implemented mode) is live-validated end-to-end — dogfooded on this repo (`claude-skills`) and an external repo (`natejswenson/1.00s`). A full Siege security audit found and fixed 9 findings (1 Critical, 1 High, the rest Medium/Low) before wider rollout; zero Critical/High findings remain open. See [`CHANGELOG.md`](./CHANGELOG.md) for the fix-by-fix history.
112
+ **`release.mode: "manual-gate"`** the only implemented mode is live-validated
113
+ end to end, dogfooded on this repo and on an external repo
114
+ (`natejswenson/1.00s`). A full Siege security audit found and fixed 9 findings (1
115
+ Critical, 1 High, the rest Medium/Low) before wider rollout; zero Critical or High
116
+ findings remain open.
55
117
 
56
- **`release.mode: "auto"`** (fully automatic tagging via `release-please`) is accepted in the config schema but not yet implemented — `apply` refuses with a clear error until it ships.
118
+ **`release.mode: "auto"`** (fully automatic tagging via `release-please`) is
119
+ accepted in the config schema but not yet implemented — `apply` refuses with a
120
+ clear error until it ships.
57
121
 
58
122
  ## Design
59
123
 
60
- [`docs/plans/2026-07-14-shipflow-skill-design.md`](../../docs/plans/2026-07-14-shipflow-skill-design.md) — the original single-pattern design (7 rounds of adversarial review, score 12 → 0).
124
+ - [`2026-07-14-shipflow-skill-design.md`](../../docs/plans/2026-07-14-shipflow-skill-design.md)
125
+ — the original single-pattern design (7 rounds of adversarial review, score 12 → 0).
126
+ - [`2026-07-16-shipflow-multi-pattern-design.md`](../../docs/plans/2026-07-16-shipflow-multi-pattern-design.md)
127
+ — the multi-pattern registry design (10 rounds of adversarial review).
128
+
129
+ ## Development
130
+
131
+ ```bash
132
+ cd skills/shipflow/skills/shipflow
133
+ npm test
134
+ ```
135
+
136
+ Node skill. `ci / shipflow` runs the same tests plus the house lints on every
137
+ pull request. The baseline is pinned against this repo's own `shipflow.json` and
138
+ the workflow it renders, byte-exact — the rendered file *is* the contract.
139
+
140
+ ## Changelog
61
141
 
62
- [`docs/plans/2026-07-16-shipflow-multi-pattern-design.md`](../../docs/plans/2026-07-16-shipflow-multi-pattern-design.md) the multi-pattern registry design (10 rounds of adversarial review).
142
+ See [`CHANGELOG.md`](CHANGELOG.md). Releases are cut by a version bump, tagged
143
+ `shipflow-v<version>`, and published to npm.
63
144
 
64
145
  ## License
65
146
 
66
- MIT
147
+ MIT — see [`LICENSE`](LICENSE).
package/SKILL.md CHANGED
@@ -32,6 +32,7 @@ user; the CLI is the only thing that *does*.
32
32
  | `.github/shipflow.json` doesn't exist in the target repo yet | **First-run setup** |
33
33
  | `.github/shipflow.json` exists, user wants to check/repair drift | **Re-run / audit** |
34
34
  | User asks "any releases pending?" / periodic check-in / after a `dev → main` merge | **Check pending releases** |
35
+ | User wants to cut a release for one named thing ("release devlog") | **Cut a component release** |
35
36
 
36
37
  ## First-run setup
37
38
 
@@ -63,7 +64,7 @@ user; the CLI is the only thing that *does*.
63
64
  4. **Confirm branch names and required checks with the user.** Show `workflows.jobNames` from the detect output as candidate `requiredChecks` (this list is already filtered to jobs from workflows that actually trigger on `pull_request` — a job that only runs on `schedule`/`workflow_dispatch` can never satisfy a required check, so it's never offered as a candidate) and let the user confirm/edit the list. **An empty `requiredChecks` list is a fail-open state, not a valid steady state** — `shipflow apply` will hard-refuse to enable auto-merge with zero required checks (see Error handling below). Don't let the user skip this without understanding that consequence.
64
65
 
65
66
  **If the candidate list is empty, a CI workflow has to exist before auto-merge
66
- can be enabled. Hand that job to the `forge` skill** — authoring and *verifying*
67
+ can be enabled. Hand that job to the `ghfactory` skill** — authoring and *verifying*
67
68
  workflow YAML is its whole subject, and it does things shipflow never will:
68
69
  it resolves every action ref against the real API (no linter checks that an
69
70
  action exists), validates each `with:` key against the action's own
@@ -71,10 +72,10 @@ user; the CLI is the only thing that *does*.
71
72
  and zizmor before showing you anything. Two skills answering "scaffold me a CI
72
73
  workflow" differently is worse than either answer.
73
74
 
74
- > Use the forge skill to create a `pull_request`-triggered build+test workflow
75
+ > Use the ghfactory skill to create a `pull_request`-triggered build+test workflow
75
76
  > for this repo, then come back here with the job name.
76
77
 
77
- **If forge is not installed**, draft it here instead: investigate the repo
78
+ **If ghfactory is not installed**, draft it here instead: investigate the repo
78
79
  directly (`package.json`, `Cargo.toml`, `project.yml`/`.xcodeproj`, `go.mod`,
79
80
  `pyproject.toml`, or whatever's actually there) and write a minimal,
80
81
  conservative `pull_request`-triggered build+test workflow.
@@ -140,6 +141,92 @@ This is a **separate, later invocation** from the one that ran the promotion's `
140
141
 
141
142
  4. If no, leave the label as-is — there is no "defer" state in this version; declining is final for that promotion short of a manual dispatch. (Deliberate v1 simplification, not an oversight.)
142
143
 
144
+ ## Cut a component release
145
+
146
+ For the conversational "release devlog" flow, prefer the **`release` skill** — it owns the
147
+ bump judgment, the CHANGELOG prose and the run presentation. This section is the CLI contract
148
+ underneath it, and the fallback when that skill is not installed.
149
+
150
+ A **component** is one independently-versioned thing in a repo: a skill in a monorepo, or the
151
+ repo itself. `release.componentLayout` describes where a component's version, changelog, tag
152
+ and release workflow live, with `{name}` as the only substitution token;
153
+ `release.components` lists the names. A repo with neither gets a single component inferred from
154
+ its root (`package.json`, `CHANGELOG.md`, `v{version}`), so a one-project repo needs no config
155
+ at all and `--component` may be omitted.
156
+
157
+ 1. **Read the state. Never guess it.**
158
+ ```
159
+ npx -y @natjswenson/shipflow@latest release-status --repo <path> --component <name>
160
+ ```
161
+ Returns `state`, the version on main and dev, the last tag, every commit since that tag that
162
+ touched this component's paths, a `suggestedBump` with its reason, `blockers`, `notes`, and a
163
+ `statusHash`. `state` decides the path:
164
+ - `clean` — the released version is what's on main. A bump is needed: go to step 2.
165
+ - `untagged-bump-on-main` — the bump is already on main and was never tagged (a cancelled or
166
+ failed release run). **No PR is needed** — `release-cut` dispatches and verifies. Skip to step 3.
167
+ **`untagged-bump-on-main` is not, by itself, permission to cut.** Check `devAhead` first: if
168
+ it is set, dev already carries a *higher* version than what's on main, and cutting here would
169
+ tag the version on main, not the one on dev — the version you almost certainly mean to
170
+ release. `release-cut` refuses in this shape unless you pass `--version` naming exactly which
171
+ one to release (see step 3); it never guesses.
172
+ - `bump-on-dev-unpromoted` — the bump is on dev, waiting for a promotion. Skip to step 3.
173
+ - `version-behind-tag` — main carries a *lower* version than an existing tag. Stop and ask;
174
+ this means a tag was cut from something other than main, and guessing is how it gets worse.
175
+
176
+ 2. **Show the user `collateral`, `blockers` and the proposed version, and wait.**
177
+ A `dev → main` promotion is atomic and carries all of dev, so every component listed under
178
+ `collateral` has its bump moved to `main` by the same promotion. It is **not released** by
179
+ that — every caller's release job is `workflow_dispatch`-only, so merging tags nothing; each
180
+ becomes `untagged-bump-on-main`, one deliberate `release-cut` away from a tag.
181
+ **Never run `release-cut` without naming that list to the user first.** They should know what
182
+ their promotion moves, and which components are now one dispatch from a release nobody asked
183
+ for.
184
+
185
+ `suggestedBump` is a suggestion. The user decides, and a `suggestedBumpCapped: true` means a
186
+ breaking change was held at minor because the component is still 0.x — going to 1.0.0 is a
187
+ release decision, never a commit message's. Then:
188
+ ```
189
+ npx -y @natjswenson/shipflow@latest release-prepare --repo <path> --component <name> \
190
+ --version <x.y.z> --notes-file <path>
191
+ ```
192
+ Local only, no network. It works in a **throwaway git worktree**, so unrelated uncommitted work
193
+ in the user's tree is untouched and cannot be swept into the release commit. The version bump
194
+ and the CHANGELOG entry land in **one commit** — the notes are read off `main` at dispatch
195
+ time, so a CHANGELOG that lands in a later promotion than its version is notes the release
196
+ will never carry.
197
+
198
+ 3. **Cut it, and prove it.**
199
+ ```
200
+ npx -y @natjswenson/shipflow@latest release-cut --repo <path> --component <name> \
201
+ --expect-status-hash <hash-from-step-1> --wait 240
202
+ ```
203
+ `--expect-status-hash` is mandatory (same TOCTOU discipline as `apply`'s `--expect-state-hash`);
204
+ `--skip-hash-check` is a named escape hatch, never a default.
205
+
206
+ If step 1's `devAhead` was set, `release-cut` refuses outright with an error naming both
207
+ versions — this is the ambiguous three-way state (main has an untagged bump, dev already
208
+ carries something higher) where guessing would tag the wrong one. Promote `dev → main` and
209
+ re-run `release-status` to release what's on dev (the normal recovery), **or** add
210
+ `--version <x.y.z>` naming exactly the version on main, if you deliberately mean to release
211
+ that one and leave dev's higher version for later. `--version` is a confirmation, not a
212
+ bypass — it is only ever accepted when it matches a version already on main or dev; anything
213
+ else is refused the same as passing nothing.
214
+
215
+ **`release-cut` is resumable and bounded, and it will usually return `done: false`.** The full
216
+ path — feature PR, checks, merge, promotion, auto-merge, **dispatch**, release run, tag — takes
217
+ longer than one call should block for. Each call advances as far as it can, then returns the
218
+ `stage` it is parked at and a `next` line. **Call it again, unchanged, until `done: true`.** It
219
+ derives every stage from live remote state and never from a record of what a previous call did,
220
+ so a resumed run and a fresh one are the same code path.
221
+
222
+ **The promotion merging cuts nothing.** `release-cut` dispatches the component's release
223
+ workflow itself, after the promotion lands — that dispatch is the single point at which any tag
224
+ is created in this repo, which is why a merge can no longer surprise anyone with a release.
225
+
226
+ 4. **Report the tag, and only the tag.** `done: true` carries `tag` and `releaseUrl`, read back
227
+ from origin. A dispatched workflow, a merged PR and a green check are **not** a release —
228
+ `release-cut` confirms the tag exists on the remote before it says done, and so must you.
229
+
143
230
  ## Auto mode (not yet implemented)
144
231
 
145
232
  `release.mode: "auto"` is a valid value in the config schema (the full design covers automatic tagging via `release-please`), but `shipflow apply` in this version **refuses to run** against a config with `release.mode: "auto"`, with a clear error rather than silently no-oping. If a user asks for fully automatic tagging, tell them it's designed but not yet shipped (see `CHANGELOG.md`) and that `"manual-gate"` — the deliberate ask-before-tagging flow above — is what's available today.
@@ -153,6 +240,15 @@ This is a **separate, later invocation** from the one that ran the promotion's `
153
240
  - **`release.releaseCredential` left as (or defaulted to) `GITHUB_TOKEN`:** auto-merge and the required-check gate still work, but `label-release-pending` will silently never run — a `GITHUB_TOKEN`-attributed auto-merge's `pull_request: closed` event never triggers it, so no promotion will ever surface via `shipflow releases`. This fails silently, not loudly — there's no error to catch it — so it must be caught at setup time (step 5) rather than discovered later. If a user reports "releases never show up," check this first.
154
241
  - **`--expect-state-hash is required` refusal:** a real apply was attempted with neither `--expect-state-hash` nor `--skip-hash-check`. Go back and get (or re-fetch via `plan`) the hash — don't reach for `--skip-hash-check` just to make the error go away; that flag exists for a deliberate, documented exception, not as a default workaround.
155
242
  - **`--force was passed without --force-reason` refusal:** a `--force` flag was about to be sent with no accompanying justification. Stop and get (or write) an explicit reason tied to what the user actually confirmed before retrying — never pass a placeholder string just to satisfy the flag.
243
+ - **`release-cut` returns `done: false`:** not an error. It is parked at the `stage` it reports,
244
+ waiting on something remote. Call it again with the same arguments. Do not report a release.
245
+ - **`release-status` reports `component-files-dirty`:** this component's own version files or
246
+ CHANGELOG have uncommitted edits, so a bump would collide with them. Unrelated dirt elsewhere in
247
+ the tree is reported under `notes` and is deliberately **not** a blocker — `prepare` runs in an
248
+ isolated worktree specifically so other people's in-flight work is safe.
249
+ - **`release-status` reports `version-unreadable-on-main`:** the component's version files do not
250
+ exist on main, or they disagree with each other. A disagreement is a hard refusal, never a
251
+ "pick the highest" — releasing from a disagreeing set tags one version and ships another.
156
252
  - **A `gh`/`git` call hangs or times out:** every subprocess call has a 30-second timeout (`ETIMEDOUT` surfaces in the error message). A timeout on `detect`/`plan` usually means a real GitHub outage or rate-limit — retry once, and if it persists, tell the user rather than looping silently.
157
253
 
158
254
  ## Security rules
package/bin/shipflow.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from '../lib/apply.mjs';
17
17
  import { readFileCapped } from '../lib/gh.mjs';
18
18
  import { resolvePattern, scoreAll } from '../lib/pattern-registry.mjs';
19
+ import { readStatus, prepare, cut, listComponentNames } from '../lib/release.mjs';
19
20
 
20
21
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
21
22
 
@@ -244,6 +245,119 @@ function cmdReleaseDispatch(args) {
244
245
  printJson({ dispatched: results, labelCleared: cleared.ok, labelClearError: cleared.ok ? null : cleared.error });
245
246
  }
246
247
 
248
+ // --- component releases -----------------------------------------------------
249
+ // Shared resolution for the three release-* commands. `--component` is optional
250
+ // when the repo has exactly one component (the inferred single-component case),
251
+ // because in a repo with one thing to release, naming it is ceremony.
252
+ function resolveReleaseArgs(values, commandName) {
253
+ if (!values.repo) return { error: `${commandName}: --repo is required` };
254
+ const configPath = values.config ?? defaultConfigPath(values.repo);
255
+ let config;
256
+ try {
257
+ config = readConfig(configPath);
258
+ } catch (e) {
259
+ return { error: `${commandName}: could not read config at ${configPath}: ${e.message}` };
260
+ }
261
+ const names = listComponentNames(config, values.repo);
262
+ let name = values.component;
263
+ if (!name) {
264
+ if (names.length !== 1) {
265
+ return { error: `${commandName}: --component is required (this repo declares ${names.length}: ${names.join(', ')})` };
266
+ }
267
+ name = names[0];
268
+ } else if (!names.includes(name)) {
269
+ return { error: `${commandName}: "${name}" is not a declared component. This repo has: ${names.join(', ')}` };
270
+ }
271
+ return { config, name };
272
+ }
273
+
274
+ function cmdReleaseStatus(args) {
275
+ const { values } = parseArgs({
276
+ args,
277
+ options: { repo: { type: 'string' }, config: { type: 'string' }, component: { type: 'string' } },
278
+ });
279
+ const resolved = resolveReleaseArgs(values, 'release-status');
280
+ if (resolved.error) return fail(resolved.error);
281
+ try {
282
+ printJson(readStatus(values.repo, resolved.config, resolved.name));
283
+ } catch (e) {
284
+ return fail(`release-status: ${e.message}`);
285
+ }
286
+ }
287
+
288
+ function cmdReleasePrepare(args) {
289
+ const { values } = parseArgs({
290
+ args,
291
+ options: {
292
+ repo: { type: 'string' },
293
+ config: { type: 'string' },
294
+ component: { type: 'string' },
295
+ version: { type: 'string' },
296
+ 'notes-file': { type: 'string' },
297
+ date: { type: 'string' },
298
+ },
299
+ });
300
+ const resolved = resolveReleaseArgs(values, 'release-prepare');
301
+ if (resolved.error) return fail(resolved.error);
302
+ if (!values.version || !values['notes-file']) {
303
+ return fail('release-prepare: --version and --notes-file are both required');
304
+ }
305
+ let notes;
306
+ try {
307
+ notes = readFileCapped(values['notes-file']);
308
+ } catch (e) {
309
+ return fail(`release-prepare: could not read --notes-file: ${e.message}`);
310
+ }
311
+ if (notes.trim().length === 0) {
312
+ // An empty CHANGELOG entry is how a release ships with notes that say
313
+ // nothing. _release.yml falls back to a bare "<skill> v<version>" title,
314
+ // which looks deliberate and is not.
315
+ return fail('release-prepare: --notes-file is empty — a release with no notes is not a release');
316
+ }
317
+ try {
318
+ const result = prepare(values.repo, resolved.config, resolved.name, values.version, notes, {
319
+ date: values.date,
320
+ featureBranchPrefix: resolved.config.featureBranchPrefix,
321
+ });
322
+ if (!result.ok) return fail(`release-prepare: ${result.error}`);
323
+ printJson(result);
324
+ } catch (e) {
325
+ return fail(`release-prepare: ${e.message}`);
326
+ }
327
+ }
328
+
329
+ function cmdReleaseCut(args) {
330
+ const { values } = parseArgs({
331
+ args,
332
+ options: {
333
+ repo: { type: 'string' },
334
+ config: { type: 'string' },
335
+ component: { type: 'string' },
336
+ 'expect-status-hash': { type: 'string' },
337
+ 'skip-hash-check': { type: 'boolean', default: false },
338
+ wait: { type: 'string' },
339
+ version: { type: 'string' },
340
+ },
341
+ });
342
+ const resolved = resolveReleaseArgs(values, 'release-cut');
343
+ if (resolved.error) return fail(resolved.error);
344
+ const ownerRepo = resolveOwnerRepo(values.repo);
345
+ if (!ownerRepo) return fail('release-cut: could not resolve owner/repo from git remote');
346
+ try {
347
+ const result = cut(values.repo, resolved.config, resolved.name, {
348
+ waitSeconds: values.wait ? Number(values.wait) : 240,
349
+ expectStatusHash: values['expect-status-hash'] ?? null,
350
+ skipHashCheck: values['skip-hash-check'],
351
+ ownerRepo,
352
+ version: values.version ?? null,
353
+ });
354
+ if (!result.ok) return fail(`release-cut: ${result.error}${result.currentStatusHash ? ` (current statusHash: ${result.currentStatusHash})` : ''}`);
355
+ printJson(result);
356
+ } catch (e) {
357
+ return fail(`release-cut: ${e.message}`);
358
+ }
359
+ }
360
+
247
361
  function cmdRenameDefaultBranch(args) {
248
362
  const { values } = parseArgs({
249
363
  args,
@@ -274,6 +388,9 @@ Commands:
274
388
  apply --repo <path> [--config <path>] [--dry-run] [--expect-state-hash <hash> | --skip-hash-check] [--force <id>]... [--force-reason <text>]
275
389
  releases --repo <path> [--config <path>]
276
390
  release-dispatch --repo <path> --pr <number> --workflow-file <file>... --ref <ref>
391
+ release-status --repo <path> [--component <name>]
392
+ release-prepare --repo <path> [--component <name>] --version <x.y.z> --notes-file <path> [--date <YYYY-MM-DD>]
393
+ release-cut --repo <path> [--component <name>] (--expect-status-hash <hash> | --skip-hash-check) [--wait <seconds>] [--version <x.y.z>]
277
394
  rename-default-branch --repo <path> --branch <current-name> --to <new-name>
278
395
 
279
396
  Every command prints JSON to stdout.`);
@@ -314,6 +431,15 @@ if (isMain) {
314
431
  case 'release-dispatch':
315
432
  cmdReleaseDispatch(rest);
316
433
  break;
434
+ case 'release-status':
435
+ cmdReleaseStatus(rest);
436
+ break;
437
+ case 'release-prepare':
438
+ cmdReleasePrepare(rest);
439
+ break;
440
+ case 'release-cut':
441
+ cmdReleaseCut(rest);
442
+ break;
317
443
  case 'rename-default-branch':
318
444
  cmdRenameDefaultBranch(rest);
319
445
  break;
package/lib/apply.mjs CHANGED
@@ -198,8 +198,15 @@ export function clearReleasePendingLabel(ownerRepo, prNumber) {
198
198
  return r.ok ? { ok: true } : { ok: false, error: r.stderr };
199
199
  }
200
200
 
201
+ // `--repo` is not optional here even though `gh` would infer it: the CLI is
202
+ // invoked with `--repo <path>` pointing at an arbitrary target repo, which is
203
+ // routinely NOT the process's cwd. Without it, `gh` resolves the repo from
204
+ // whatever directory the agent happened to be in and dispatches a release in
205
+ // the wrong repository — silently, since a successful dispatch elsewhere still
206
+ // exits 0. ownerRepo was already threaded in for exactly this and was being
207
+ // ignored.
201
208
  export function dispatchReleaseWorkflow(ownerRepo, skillWorkflowFile, ref) {
202
- const r = spawnArgs('gh', ['workflow', 'run', skillWorkflowFile, '--ref', ref]);
209
+ const r = spawnArgs('gh', ['workflow', 'run', skillWorkflowFile, '--ref', ref, '--repo', ownerRepo]);
203
210
  return r.status === 0 ? { ok: true } : { ok: false, error: r.stderr };
204
211
  }
205
212