@natjswenson/shipflow 0.3.2 → 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,21 +2,137 @@
2
2
 
3
3
  All notable changes to `@natjswenson/shipflow` are documented here.
4
4
 
5
- ## 0.3.2 (2026-07-28) — actually render the README on npmjs.com
6
-
7
- - **Fixed: the npm page still showed "No README data found" after 0.3.1.** That
8
- release put README.md, LICENSE and CHANGELOG.md into the tarball (verified by
9
- downloading it), which fixed what `npm install` delivers — but not the website.
10
- npm reads README.md to populate the registry manifest's `readme` field when it
11
- builds the publish manifest, which happens BEFORE the package's own `prepack`
12
- hook runs. So the tarball had the file and the registry record did not.
13
- Worse than merely empty: the stored value was the literal string
14
- `ERROR: No README data found!`, and npmjs.com falls back to reading the
15
- tarball only when that field is *absent* (compare `zod`, which renders fine
16
- with no packument readme at all), so the error string kept winning.
17
- The release workflow now stages the three files into the package directory
18
- before `npm publish` is invoked at all, rather than relying on `prepack`.
19
- Code unchanged.
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
+
83
+ ## 0.3.3 (2026-08-01) — least-privilege permissions in every rendered workflow
84
+
85
+ - **Every rendered workflow granted its permissions at the workflow level, so
86
+ every job in the file got them whether it needed them or not.** zizmor flags
87
+ this as `excessive-permissions` (High), and it was failing this repo's own
88
+ `security / workflows` gate on `dev-to-main-automerge.yml`. All six templates
89
+ now deny by default (`permissions: {}`) and grant per job.
90
+
91
+ A workflow-level grant is not just untidy — it silently extends to jobs added
92
+ later that were never reviewed for it. Scoping is the fix; waiving the rule
93
+ would have kept the finding *and* the exposure.
94
+
95
+ - **`contents: write` is dropped from the auto-merge workflows entirely, not
96
+ moved.** `gh pr merge --auto` only *enables* native auto-merge, which is a
97
+ pull-requests operation; GitHub performs the merge itself afterwards under its
98
+ own automation rather than this token. Those workflows now grant
99
+ `pull-requests: write` to the two jobs that call `gh`, and nothing else.
100
+
101
+ Where `release.releaseCredential` names a real PAT (the supported setup), the
102
+ workflow token's permissions never applied to those steps in the first place.
103
+ These grants are what matters for a repo that left the credential defaulted to
104
+ `GITHUB_TOKEN`.
105
+
106
+ ### Fixed
107
+
108
+ - **The gitflow merge-back workflows could not open their own fallback PR.**
109
+ `release-merge-back` and `hotfix-merge-back` granted only `contents: write`,
110
+ but their "on any failure, open a PR for manual resolution" step calls
111
+ `gh pr create` — which needs `pull-requests: write`. Under a defaulted
112
+ `GITHUB_TOKEN` that step would have failed exactly when it was needed most:
113
+ after a merge conflict, with nothing else left to surface it. Found while
114
+ scoping the permissions above, not by a report.
115
+
116
+ ## 0.3.2 (2026-07-28) — attempted npm-page fix; did NOT work
117
+
118
+ - **Attempted and failed: making npmjs.com render the README.** 0.3.1 put
119
+ README.md, LICENSE and CHANGELOG.md into the tarball (verified by downloading
120
+ the published artifact), which fixed what `npm install` delivers. This release
121
+ additionally staged those files into the package directory before
122
+ `npm publish` was invoked, on the theory that npm populates the registry
123
+ manifest's `readme` field from disk at that point. **It did not work.** After
124
+ publishing, `npm view <pkg> readme` still returns the literal string
125
+ `ERROR: No README data found!` for both packages.
126
+ That field appears to be sticky: it was set once by an early release that had
127
+ no README, and republishing with one present does not overwrite it. Since
128
+ npmjs.com only falls back to reading the tarball when the field is *absent*
129
+ (compare `zod`, which renders fine with none at all), the stale error string
130
+ keeps winning.
131
+ The ineffective staging step was reverted in the release workflow; the
132
+ `prepack` hook stays, because the tarball fix is real and verified. **The npm
133
+ web page for both packages is still blank** — an open issue, likely needing
134
+ npm support to clear the cached field.
135
+ Code unchanged in both skills.
20
136
 
21
137
  ## 0.3.1 (2026-07-28) — publish the README, LICENSE and CHANGELOG to npm
22
138
 
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
 
@@ -62,7 +63,31 @@ user; the CLI is the only thing that *does*.
62
63
 
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
- **If the candidate list is empty, offer to scaffold a starter CI workflow yourself** — this is a judgment call for the agent, not something shipflow's CLI does (the CLI stays free of per-language/build-tool logic). Investigate the repo directly (`package.json`, `Cargo.toml`, `project.yml`/`.xcodeproj`, `go.mod`, `pyproject.toml`, or whatever's actually there) and draft a minimal, conservative `pull_request`-triggered build+test workflow. **Never silently overwrite an existing workflow file.** Present the drafted YAML to the user and wait for explicit confirmation before writing it — the same confirm-before-write pattern as everything else in this skill. Say plainly that this is a best-effort starting point inferred from repo structure, not a guarantee it's green on the first run — a required check that never passes blocks every future merge, so the user should watch it actually run successfully before relying on it as a required check. Once it exists, re-run step 1's `detect` (repo state changed) and continue this step with the new job name as a real candidate.
66
+ **If the candidate list is empty, a CI workflow has to exist before auto-merge
67
+ can be enabled. Hand that job to the `ghfactory` skill** — authoring and *verifying*
68
+ workflow YAML is its whole subject, and it does things shipflow never will:
69
+ it resolves every action ref against the real API (no linter checks that an
70
+ action exists), validates each `with:` key against the action's own
71
+ `action.yml`, reports how many majors behind each pin is, and runs actionlint
72
+ and zizmor before showing you anything. Two skills answering "scaffold me a CI
73
+ workflow" differently is worse than either answer.
74
+
75
+ > Use the ghfactory skill to create a `pull_request`-triggered build+test workflow
76
+ > for this repo, then come back here with the job name.
77
+
78
+ **If ghfactory is not installed**, draft it here instead: investigate the repo
79
+ directly (`package.json`, `Cargo.toml`, `project.yml`/`.xcodeproj`, `go.mod`,
80
+ `pyproject.toml`, or whatever's actually there) and write a minimal,
81
+ conservative `pull_request`-triggered build+test workflow.
82
+ **Never silently overwrite an existing workflow file.** Present it and wait for
83
+ explicit confirmation before writing it — the same confirm-before-write pattern
84
+ as everything else in this skill.
85
+
86
+ Either way, say plainly that a fresh workflow is a starting point, not a
87
+ guarantee it's green on the first run — **a required check that never passes
88
+ blocks every future merge**, so the user should watch it run successfully
89
+ before relying on it as one. Once it exists, re-run step 1's `detect` (repo
90
+ state changed) and continue this step with the new job name as a real candidate.
66
91
 
67
92
  5. **Resolve `protectionOwner`:**
68
93
  - `"external"` → tell the user which settings-as-code artifact was found (`settingsAsCodeArtifact` in the detect output) and that shipflow will defer to it, managing only cleanup/automerge/release, not installing a competing ruleset.
@@ -116,6 +141,70 @@ This is a **separate, later invocation** from the one that ran the promotion's `
116
141
 
117
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.)
118
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
+
119
208
  ## Auto mode (not yet implemented)
120
209
 
121
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.
@@ -129,6 +218,15 @@ This is a **separate, later invocation** from the one that ran the promotion's `
129
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.
130
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.
131
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.
132
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.
133
231
 
134
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