@natjswenson/shipflow 0.3.3 → 0.4.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,84 @@
2
2
 
3
3
  All notable changes to `@natjswenson/shipflow` are documented here.
4
4
 
5
+ ## 0.4.0 (2026-08-02) — release one named thing, and prove the tag exists
6
+
7
+ ### Added
8
+
9
+ - **Component releases: `release-status`, `release-prepare`, `release-cut`.**
10
+ A *component* is one independently-versioned thing in a repo — a skill in a
11
+ monorepo, or the repo itself. `release.componentLayout` says where its
12
+ version files, changelog, tag and release workflow live, with `{name}` as the
13
+ only substitution token, and `release.components` lists the names. A repo
14
+ with neither gets a single component inferred from its root, so a one-project
15
+ repo needs no config at all and `--component` may be omitted.
16
+
17
+ This closes the half of releasing that was never automated. The mechanical
18
+ end already worked — `_release.yml` is version-driven and idempotent, every
19
+ caller has `workflow_dispatch`, `release-dispatch` exists. Everything
20
+ *upstream* of the dispatch was manual, and that is where the friction and the
21
+ mistakes lived.
22
+
23
+ - **`release-status` reads state instead of guessing it.** The version on main
24
+ and on dev (read via `git show`, so the user's working tree is never touched
25
+ or checked out), the last tag, every commit since that tag that touched this
26
+ component's paths, a suggested bump with its reason, and a `statusHash` that
27
+ `release-cut` requires back — the same TOCTOU discipline `apply` already has.
28
+
29
+ - **`collateral`: every other component the same promotion would release.** A
30
+ `dev → main` promotion is atomic and carries all of dev, so cutting a release
31
+ for one component also releases anything else sitting bumped-but-untagged
32
+ there. The SKILL.md rule is that this list is named to the user before the
33
+ irreversible step, never merely present in JSON an agent might skim past.
34
+
35
+ - **`release-cut` is resumable, bounded, and proves the tag.** The full path —
36
+ feature PR, checks, merge, promotion, auto-merge, release run, tag — takes
37
+ longer than one call should block for, so each call advances as far as it can
38
+ and returns the stage it is parked at. Every stage is derived from live remote
39
+ state, never from a record of a previous call, so a resumed run and a fresh
40
+ one are the same code path. It reports `done: true` only after reading the tag
41
+ back from origin: a dispatched workflow, a merged PR and a green check are all
42
+ still "not done."
43
+
44
+ - **Version files can be `package.json`, `SKILL.md` frontmatter, `pyproject.toml`
45
+ or a top-level `project.yml`.** A component model that only reads
46
+ `package.json` is not generic, it is a node model — the first two non-node
47
+ repos this was pointed at were a Python project and an Xcode project. TOML and
48
+ YAML are matched at column zero only: both formats nest, and an indented
49
+ `version` is a dependency pin, not the project's own version. Releasing the
50
+ wrong number is worse than reporting none.
51
+
52
+ ### Fixed
53
+
54
+ - **`spliceChangelog` built a regex out of the version string and escaped only
55
+ the dots**, leaving `\`, `*`, `+`, `(` and `[` live all the way into
56
+ `new RegExp`. `prepare` rejects a non-semver version before reaching it, so
57
+ this was not exploitable through the CLI — but the function is exported and
58
+ independently callable, and a guard that lives in the caller is not a guard.
59
+ It now matches with plain string operations, which is also exactly what
60
+ `_release.yml`'s `awk` does (`/^## / && index($0, ver)`), so the duplicate
61
+ check and the release-time extractor answer the same question the same way.
62
+ Found by CodeQL (`js/incomplete-sanitization`, high) on PR #158.
63
+
64
+ - **`dispatchReleaseWorkflow` ignored the `ownerRepo` it was given.** It shelled
65
+ out to `gh workflow run` with no `--repo`, so `gh` inferred the repository
66
+ from the process's working directory — which is routinely *not* the repo
67
+ `--repo <path>` points at. A dispatch into the wrong repository still exits 0,
68
+ so this failed silently in exactly the setup the flag exists for.
69
+
70
+ ### Notes
71
+
72
+ - A breaking change on a component still in 0.x is capped at a **minor** bump,
73
+ and reported as capped rather than applied silently. Declaring 1.0.0 is an
74
+ API-stability promise, and no commit message is entitled to make it on the
75
+ maintainer's behalf.
76
+ - `release-prepare` does its work in a throwaway `git worktree`, so unrelated
77
+ uncommitted work in the user's tree cannot be swept into a release commit.
78
+ Real trees have parallel work in them; this monorepo's had an entire untracked
79
+ skill in it while this feature was written.
80
+ - No template changed, so no rendered workflow and no `renderedTemplateHashes`
81
+ entry moves in this release.
82
+
5
83
  ## 0.3.3 (2026-08-01) — least-privilege permissions in every rendered workflow
6
84
 
7
85
  - **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,70 @@ 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
+ - `bump-on-dev-unpromoted` — the bump is on dev, waiting for a promotion. Skip to step 3.
168
+ - `version-behind-tag` — main carries a *lower* version than an existing tag. Stop and ask;
169
+ this means a tag was cut from something other than main, and guessing is how it gets worse.
170
+
171
+ 2. **Show the user `collateral`, `blockers` and the proposed version, and wait.**
172
+ **`collateral` is not advisory.** A `dev → main` promotion is atomic and carries all of dev, so
173
+ every component listed there is released by the same promotion — "release devlog" really does
174
+ also release them. **Never run `release-cut` without naming that list to the user first.**
175
+ Releasing something the user did not ask for is the worst thing this flow can do.
176
+
177
+ `suggestedBump` is a suggestion. The user decides, and a `suggestedBumpCapped: true` means a
178
+ breaking change was held at minor because the component is still 0.x — going to 1.0.0 is a
179
+ release decision, never a commit message's. Then:
180
+ ```
181
+ npx -y @natjswenson/shipflow@latest release-prepare --repo <path> --component <name> \
182
+ --version <x.y.z> --notes-file <path>
183
+ ```
184
+ Local only, no network. It works in a **throwaway git worktree**, so unrelated uncommitted work
185
+ in the user's tree is untouched and cannot be swept into the release commit. The version bump
186
+ and the CHANGELOG entry land in **one commit**, because releases are publish-on-merge — a
187
+ follow-up promotion to fix release notes is too late, the tag is already cut.
188
+
189
+ 3. **Cut it, and prove it.**
190
+ ```
191
+ npx -y @natjswenson/shipflow@latest release-cut --repo <path> --component <name> \
192
+ --expect-status-hash <hash-from-step-1> --wait 240
193
+ ```
194
+ `--expect-status-hash` is mandatory (same TOCTOU discipline as `apply`'s `--expect-state-hash`);
195
+ `--skip-hash-check` is a named escape hatch, never a default.
196
+
197
+ **`release-cut` is resumable and bounded, and it will usually return `done: false`.** The full
198
+ path — feature PR, checks, merge, promotion, auto-merge, release run, tag — takes longer than
199
+ one call should block for. Each call advances as far as it can, then returns the `stage` it is
200
+ parked at and a `next` line. **Call it again, unchanged, until `done: true`.** It derives every
201
+ stage from live remote state and never from a record of what a previous call did, so a resumed
202
+ run and a fresh one are the same code path.
203
+
204
+ 4. **Report the tag, and only the tag.** `done: true` carries `tag` and `releaseUrl`, read back
205
+ from origin. A dispatched workflow, a merged PR and a green check are **not** a release —
206
+ `release-cut` confirms the tag exists on the remote before it says done, and so must you.
207
+
143
208
  ## Auto mode (not yet implemented)
144
209
 
145
210
  `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 +218,15 @@ This is a **separate, later invocation** from the one that ran the promotion's `
153
218
  - **`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
219
  - **`--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
220
  - **`--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.
221
+ - **`release-cut` returns `done: false`:** not an error. It is parked at the `stage` it reports,
222
+ waiting on something remote. Call it again with the same arguments. Do not report a release.
223
+ - **`release-status` reports `component-files-dirty`:** this component's own version files or
224
+ CHANGELOG have uncommitted edits, so a bump would collide with them. Unrelated dirt elsewhere in
225
+ the tree is reported under `notes` and is deliberately **not** a blocker — `prepare` runs in an
226
+ isolated worktree specifically so other people's in-flight work is safe.
227
+ - **`release-status` reports `version-unreadable-on-main`:** the component's version files do not
228
+ exist on main, or they disagree with each other. A disagreement is a hard refusal, never a
229
+ "pick the highest" — releasing from a disagreeing set tags one version and ships another.
156
230
  - **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
231
 
158
232
  ## 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,117 @@ 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
+ },
340
+ });
341
+ const resolved = resolveReleaseArgs(values, 'release-cut');
342
+ if (resolved.error) return fail(resolved.error);
343
+ const ownerRepo = resolveOwnerRepo(values.repo);
344
+ if (!ownerRepo) return fail('release-cut: could not resolve owner/repo from git remote');
345
+ try {
346
+ const result = cut(values.repo, resolved.config, resolved.name, {
347
+ waitSeconds: values.wait ? Number(values.wait) : 240,
348
+ expectStatusHash: values['expect-status-hash'] ?? null,
349
+ skipHashCheck: values['skip-hash-check'],
350
+ ownerRepo,
351
+ });
352
+ if (!result.ok) return fail(`release-cut: ${result.error}${result.currentStatusHash ? ` (current statusHash: ${result.currentStatusHash})` : ''}`);
353
+ printJson(result);
354
+ } catch (e) {
355
+ return fail(`release-cut: ${e.message}`);
356
+ }
357
+ }
358
+
247
359
  function cmdRenameDefaultBranch(args) {
248
360
  const { values } = parseArgs({
249
361
  args,
@@ -274,6 +386,9 @@ Commands:
274
386
  apply --repo <path> [--config <path>] [--dry-run] [--expect-state-hash <hash> | --skip-hash-check] [--force <id>]... [--force-reason <text>]
275
387
  releases --repo <path> [--config <path>]
276
388
  release-dispatch --repo <path> --pr <number> --workflow-file <file>... --ref <ref>
389
+ release-status --repo <path> [--component <name>]
390
+ release-prepare --repo <path> [--component <name>] --version <x.y.z> --notes-file <path> [--date <YYYY-MM-DD>]
391
+ release-cut --repo <path> [--component <name>] (--expect-status-hash <hash> | --skip-hash-check) [--wait <seconds>]
277
392
  rename-default-branch --repo <path> --branch <current-name> --to <new-name>
278
393
 
279
394
  Every command prints JSON to stdout.`);
@@ -314,6 +429,15 @@ if (isMain) {
314
429
  case 'release-dispatch':
315
430
  cmdReleaseDispatch(rest);
316
431
  break;
432
+ case 'release-status':
433
+ cmdReleaseStatus(rest);
434
+ break;
435
+ case 'release-prepare':
436
+ cmdReleasePrepare(rest);
437
+ break;
438
+ case 'release-cut':
439
+ cmdReleaseCut(rest);
440
+ break;
317
441
  case 'rename-default-branch':
318
442
  cmdRenameDefaultBranch(rest);
319
443
  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