@natjswenson/shipflow 0.2.6 → 0.3.1

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 ADDED
@@ -0,0 +1,320 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@natjswenson/shipflow` are documented here.
4
+
5
+ ## 0.3.1 (2026-07-28) — publish the README, LICENSE and CHANGELOG to npm
6
+
7
+ - **Fixed: the npm package shipped with no README, LICENSE or CHANGELOG.**
8
+ `package.json`'s `files` listed all three, but they live at the plugin root
9
+ (beside `.claude-plugin/`), one level above the package directory — and npm only
10
+ includes files from inside the package directory, so the entries silently matched
11
+ nothing. Every release through 0.3.0 published a tarball without them, and
12
+ `npm view @natjswenson/shipflow readme` returned *"No README data found"*, leaving
13
+ the npm page blank for anyone evaluating the CLI. A `prepack` script now stages the
14
+ three files into the package directory before the tarball is built, and `postpack`
15
+ removes them again so the working tree stays clean; they are gitignored at that
16
+ path so a interrupted pack cannot leave committable strays. Code unchanged.
17
+
18
+ ## 0.3.0 (2026-07-28) — multi-pattern workflow templates; `undefined` param fix
19
+
20
+ Minor, not patch: this release carries the multi-pattern feature work that had been
21
+ sitting unreleased on `main` since 0.2.6, plus one bug fix found by the new baseline
22
+ eval. Existing `dev-main-promotion` repos are unaffected — see the compatibility note
23
+ below.
24
+
25
+ - **Fixed: a missing config field rendered the literal string `"undefined"` into the
26
+ workflow.** `renderTemplate` tested param presence with `key in params`, which is
27
+ true for a key whose value is `undefined`, so `String(undefined)` flowed through as
28
+ a real value and passed the safety regexes. A config with no `branches.main`
29
+ rendered `name: auto-merge dev to undefined` and `branches: [undefined]` — a
30
+ syntactically valid workflow that installs cleanly and can never fire, with no
31
+ error at `apply` time. Present-but-`undefined` and `null` now count as missing and
32
+ raise the same "missing param(s)" error as an absent key. Found by
33
+ `tests/baseline.test.mjs` on its first run.
34
+
35
+ Generalizes shipflow from one hardcoded branching pattern to a registry of three
36
+ selectable patterns, with deterministic autodetection. Backward compatible: a repo's
37
+ existing `.github/shipflow.json` with no `workflowPattern` field keeps resolving to
38
+ `dev-main-promotion` with identical behavior — confirmed against this repo's own live
39
+ config (a `noops`-only plan, byte-identical to before this change).
40
+
41
+ - **Added: `github-flow` pattern** — a single long-lived `main`; every PR merges (and
42
+ auto-merges) directly to it, no separate promotion branch. New
43
+ `main-automerge.yml.tmpl` template.
44
+ - **Added: `gitflow` pattern** — `develop` + `main` + transient `release/*`/`hotfix/*`
45
+ branches, for software maintaining multiple released versions concurrently. New
46
+ `release-automerge.yml.tmpl`/`hotfix-automerge.yml.tmpl` templates (prefix-matched
47
+ `head.ref` guards) and `hotfix-merge-back.yml.tmpl`/`release-merge-back.yml.tmpl`
48
+ (GitFlow's defining dual-merge-back semantic: a hotfix/release merges into both
49
+ `main` and `develop`; a merge-back conflict or push failure opens a PR for manual
50
+ resolution instead of force-pushing or silently dropping the merge).
51
+ - **Added: deterministic autodetection.** `shipflow detect` now returns a
52
+ `rankedPatterns` array (every pattern's score + evidence). Classification is
53
+ `confident` (top score `>= 0.7` and a `> 0.3` gap over second place), `greenfield`
54
+ (top score `< 0.4`), or `ambiguous` (the residual case) — the first-run interview
55
+ confirms a confident detection's evidence with the user rather than silently
56
+ applying it, and presents all 3 patterns for an explicit choice otherwise.
57
+ - **Added: `workflowPattern` + `patternConfig` config fields.** `patternConfig.gitflow`
58
+ holds `releaseBranchPrefix`/`hotfixBranchPrefix` (default `release/`/`hotfix/`).
59
+ `branches.dev` remains the sole source of truth for gitflow's develop-branch name —
60
+ no separate `developBranch` field.
61
+ - **Architecture:** new `lib/pattern-registry.mjs` (`listPatterns`/`resolvePattern`/
62
+ `scoreAll`) and one `lib/patterns/<id>/index.mjs` module per pattern. `detect.mjs`,
63
+ `plan.mjs`, and `apply.mjs` are now thin dispatchers over whatever the registry
64
+ returns, instead of hardcoding `dev-main-promotion`'s logic inline — adding a 4th
65
+ pattern in the future needs no changes to any of the three.
66
+ - Existing single-pattern behavior (branch protection, auto-merge, branch cleanup,
67
+ release tagging) is unchanged for repos already on `dev-main-promotion`.
68
+
69
+ ## 0.2.6 (2026-07-15) — pin `@latest` on every invocation; docs pass
70
+
71
+ Self-discovered during the PAT-wiring dogfood step that immediately followed
72
+ 0.2.5's release — not a Siege audit finding, but adjacent to the same
73
+ class of risk the audit was meant to close.
74
+
75
+ - **Fixed: silent global-install shadowing.** `npx -y @natjswenson/shipflow
76
+ <command>` (no version/tag) can resolve an already-installed copy on
77
+ `PATH` — e.g. a stale `npm install -g @natjswenson/shipflow` left over
78
+ from manual testing — instead of fetching the current version from the
79
+ registry, with **no warning that this happened**. Confirmed concretely on
80
+ `claude-skills` itself: a bare invocation silently ran a stale global
81
+ 0.2.0 install, missing every fix through 0.2.5, including the Critical
82
+ template-injection fix (0.2.3). `npx -y @natjswenson/shipflow@latest
83
+ <command>` correctly resolved 0.2.5. This meant a repo could run
84
+ `shipflow` believing it was getting current, audited behavior while
85
+ silently getting pre-audit, vulnerable behavior instead.
86
+ - **Fix: every invocation in `SKILL.md` now pins `@latest`.** New
87
+ regression tests (`tests/skill_contract.test.mjs`) assert every `npx`
88
+ invocation of shipflow in `SKILL.md` is pinned, and fail if a future edit
89
+ reintroduces a bare invocation. New skill-invariant entry
90
+ (`npx-must-pin-latest`).
91
+ - **Docs pass**, per user request before this release: root `README.md`
92
+ gained a `shipflow` row in the skills table, marketplace/manual-install
93
+ instructions, and a rewritten Branch & release flow section describing
94
+ the actual shipflow-managed automation this repo runs (replacing stale
95
+ prose describing the pre-dogfood bespoke flow); `skills/shipflow/README.md`
96
+ gained current usage instructions (with the `@latest` pin and its
97
+ rationale), an updated Status section reflecting 0.2.5's live validation
98
+ and completed security audit, and a pointer to remove a shadowing global
99
+ install if one exists (`npm uninstall -g @natjswenson/shipflow`).
100
+ - No code changes to `lib/`/`bin/` in this release — SKILL.md, tests,
101
+ README, and version metadata only.
102
+
103
+ ## 0.2.5 (2026-07-15) — mandatory TOCTOU guard, forced-override auditability, subprocess timeouts, YAML-validity CI check
104
+
105
+ The four remaining findings from the same Siege audit as 0.2.3/0.2.4, all
106
+ presented to the user for a fix-vs-accept decision and fixed on explicit
107
+ go-ahead.
108
+
109
+ - **High, fixed (SIEGE-2026-07-15-002):** `--force allow-no-checks` /
110
+ `--force <template-id>` had zero code-level friction beyond the flag
111
+ itself — "get explicit user confirmation before forcing" lived entirely in
112
+ SKILL.md prose, not in the CLI. `apply` now refuses any `--force` unless
113
+ accompanied by `--force-reason "<text>"`; the reason is echoed back on
114
+ each forced entry in the apply result (`{ forced: true, forceReason }`)
115
+ for auditability. This doesn't stop a determined bypass, but it raises
116
+ the bar from a single flag to an explicit, logged justification.
117
+ - **Medium, fixed (SIEGE-2026-07-15-003):** `--expect-state-hash` (the
118
+ documented TOCTOU guard) was optional — omitting it silently proceeded
119
+ with zero drift protection. A real (non-dry-run) `apply` now hard-refuses
120
+ without it, unless the caller explicitly passes the new, named
121
+ `--skip-hash-check` escape hatch.
122
+ - **Medium, fixed (SIEGE-2026-07-15-004):** no subprocess timeout was set
123
+ anywhere in `gh.mjs`'s `spawnSync` calls — a hung/rate-limited `gh api` or
124
+ stuck `git` call could hang the whole process indefinitely. All
125
+ `spawnArgs` calls now default to a 30s timeout, with the resulting
126
+ `ETIMEDOUT` surfaced in the returned `stderr`.
127
+ - **Low, fixed (SIEGE-2026-07-15-005):** no test rendered the template and
128
+ validated the output as syntactically valid YAML — the exact bug class
129
+ that bit 0.2.1/0.2.2 (a silently-broken generated workflow) was caught
130
+ only by live production testing, not the unit suite. New
131
+ `tests/template-validity.test.mjs` parses the rendered workflow with the
132
+ `yaml` package (dev-only dependency, not shipped to consumers) across a
133
+ range of legal inputs.
134
+ - New CLI integration test file (`tests/cli-apply-guards.test.mjs`) spawns
135
+ the real `bin/shipflow.js` to exercise both new refusals end-to-end.
136
+ - This closes out the Siege security audit run before rolling shipflow out
137
+ to repos beyond `claude-skills` — zero Critical/High findings remain open.
138
+
139
+ ## 0.2.4 (2026-07-15) — REST-path encoding, resolveOwnerRepo hardening, file-size cap
140
+
141
+ Three more findings from the same Siege audit as 0.2.3, surfaced by an
142
+ independently-dispatched Boundary Attacker pass that (eventually) returned
143
+ its report and cross-confirmed the 0.2.3 fix while adding new findings:
144
+
145
+ - **Medium, fixed:** `fetchBranchProtection` and `checkSecretPresent`
146
+ interpolated `branch`/`secretName` unencoded into `gh api` REST path
147
+ segments — inconsistent with `checkLabelExists`, which already used
148
+ `encodeURIComponent` for the same class of input. Both now encode.
149
+ - **Low, fixed:** `resolveOwnerRepo`'s regex capture (`[\w.-]+`) admitted
150
+ all-dots segments (`.`, `..`) since `.` is in the character class with no
151
+ further constraint — a crafted remote like `github.com/../claude-skills`
152
+ could yield an `ownerRepo` that normalizes away the intended
153
+ `repos/<owner>/<repo>` prefix once interpolated downstream. Now rejects
154
+ any owner/repo segment matching `^\.+$`.
155
+ - **Medium, fixed:** no file shipflow reads from a target repo
156
+ (`.github/shipflow.json`, candidate settings-as-code artifacts, workflow
157
+ YAML, the rendered template) had a size guard — all of these are
158
+ repo-write-controlled, not admin-only, so a maliciously huge or
159
+ pathologically nested file could exhaust memory on an unbounded
160
+ `readFileSync`/`JSON.parse`. New `readFileCapped` helper in `gh.mjs`
161
+ (1 MB cap) used at every such read site.
162
+ - 8 new regression tests.
163
+
164
+ ## 0.2.3 (2026-07-15) — Critical: unescaped template substitution allowed workflow injection
165
+
166
+ Found by a Siege security audit run before rolling shipflow out to other
167
+ repos, immediately after 0.2.1/0.2.2 landed the previous two fixes.
168
+
169
+ - **Critical, fixed:** `render.mjs`'s `renderTemplate` did pure string
170
+ substitution with zero escaping. `config.branches.dev`/`main` and
171
+ `config.release.releaseCredential` — all sourced from
172
+ `.github/shipflow.json`, a file anyone with repo **write** access can
173
+ edit, not just the admin who ran shipflow's setup — were substituted
174
+ directly into single-quoted YAML string comparisons and a
175
+ `${{ secrets.X }}` GitHub Actions expression with no validation.
176
+ Concretely: a `branches.dev` value of `dev' || 'x'=='x` rendered the
177
+ auto-merge job's `if:` condition to `... == 'dev' || 'x'=='x'` —
178
+ unconditionally true, enabling auto-merge on **any** pull request into
179
+ `main`, not just genuine `dev`-branch promotions. A `releaseCredential`
180
+ value containing a newline could inject arbitrary new YAML keys/steps
181
+ into the committed, then-executed workflow file. Both are a privilege
182
+ escalation: a repo-write-level actor reaching an admin-scoped mutation
183
+ through the credential the rendered workflow runs with.
184
+ - **Fix:** `renderTemplate` now validates each substituted value against a
185
+ per-token safety rule before rendering — `DEV_BRANCH`/`MAIN_BRANCH` reject
186
+ any single quote or newline; `RELEASE_CREDENTIAL_SECRET` must match
187
+ GitHub's own secret-naming rule (`^[A-Za-z_][A-Za-z0-9_]*$`). A rejected
188
+ value throws rather than silently rendering unsafe YAML.
189
+ - **Also fixed:** `bin/shipflow.js`'s `cmdPlan`/`cmdApply` never wrapped
190
+ `computePlan` in a try/catch, so this (and the pre-existing "missing
191
+ param") error would have crashed with a raw stack trace instead of the
192
+ clean `{"error": ...}` JSON contract every other failure mode uses —
193
+ breaking the "every command prints JSON to stdout" guarantee agents rely
194
+ on to parse output.
195
+ - New regression tests assert the exploit renders are rejected, and that
196
+ ordinary branch/secret names still render normally.
197
+
198
+ ## 0.2.2 (2026-07-15) — `label-release-pending` never fires under `GITHUB_TOKEN`
199
+
200
+ Found by the same dogfood run as 0.2.1, one merge later — a second, more
201
+ serious bug than the missing `--repo`: the manual-gate release-ask flow's
202
+ whole premise (a durable label survives the async gap between auto-merge
203
+ enabling and completing) silently didn't work at all.
204
+
205
+ - **Root cause: GitHub's loop-prevention rule.** A PR auto-merged via `gh pr
206
+ merge --auto` run under the default `secrets.GITHUB_TOKEN` completes
207
+ (later, once checks pass) attributed to the `github-actions[bot]`
208
+ identity. A `pull_request: closed` event from that bot-attributed merge
209
+ does **not** trigger this or any other workflow's `on: pull_request`
210
+ handlers. Confirmed empirically, not just from docs: an otherwise-identical
211
+ promotion PR merged by a real, PAT-authenticated actor fired the
212
+ closed-event trigger within 2 seconds; one completed by
213
+ `GITHUB_TOKEN`-enabled auto-merge fired **no run at all**, even after
214
+ 100+ seconds of polling. This means `label-release-pending` never ran for
215
+ any normally-auto-merged promotion — only for a promotion a human merged
216
+ by hand — which is the opposite of the common case the feature exists for.
217
+ - **Fix: both `gh` calls now use `config.release.releaseCredential`** instead
218
+ of a hardcoded `secrets.GITHUB_TOKEN`. Wired a new `RELEASE_CREDENTIAL_SECRET`
219
+ template token through `render.mjs` and `plan.mjs`'s
220
+ `computeTemplatePlanEntry` (previously `releaseCredential` was read by
221
+ `detect.mjs` only to check whether a named secret *existed* — it was never
222
+ actually substituted into the rendered workflow).
223
+ - **First-run setup (SKILL.md) now has an explicit step** requiring the user
224
+ to create a real PAT/App-installation-token secret and record its name in
225
+ `release.releaseCredential` — defaulting to `GITHUB_TOKEN` is called out as
226
+ a silent-failure trap, not a safe default. `config.example.json`'s
227
+ placeholder changed from `"GITHUB_TOKEN"` to `"SHIPFLOW_AUTOMERGE_PAT"` so
228
+ copying the example doesn't propagate the trap.
229
+ - New regression test asserts both `GH_TOKEN` lines use the configured
230
+ secret name and never fall back to a hardcoded `GITHUB_TOKEN`.
231
+
232
+ ## 0.2.1 (2026-07-14) — rendered workflow was missing `--repo`
233
+
234
+ Found by dogfooding shipflow on its own home repo (`claude-skills`) — the
235
+ very first live promotion PR after switching over would have silently
236
+ broken auto-merge and release labeling.
237
+
238
+ - **Fix: both `gh` calls in the rendered `dev-to-main-automerge.yml` now pass
239
+ `--repo "${{ github.repository }}"` explicitly.** Neither the `auto-merge`
240
+ job's `gh pr merge` nor the `label-release-pending` job's `gh pr edit` had
241
+ it, and the workflow has no `actions/checkout` step for `gh` to infer the
242
+ repo from — every run failed with `fatal: not a git repository (or any of
243
+ the parent directories): .git`. This masked itself in the first dogfood
244
+ migration only because the hand-built workflow it was replacing (which did
245
+ pass `--repo`) happened to still be present on `main` and fired on the same
246
+ transitional PR.
247
+ - **New regression tests** (`tests/render.test.mjs`) read the actual
248
+ `.tmpl` file's rendered output and assert `--repo` is present on both `gh`
249
+ invocations — no prior test read the template's real command lines, only a
250
+ synthetic placeholder string, so this shipped with zero coverage of the
251
+ actual `gh` calls.
252
+
253
+ ## 0.2.0 (2026-07-14) — first live-repo fixes
254
+
255
+ Fixes found by running shipflow end-to-end against a real repo
256
+ (`natejswenson/1.00s`) for the first time, beyond the read-only smoke test
257
+ against `claude-skills` itself:
258
+
259
+ - **First-run setup is now an explicit, unskippable interview.** SKILL.md's
260
+ setup steps must present detected branch names, `requiredChecks`, and the
261
+ resolved `protectionOwner` and wait for confirmation before writing
262
+ `.github/shipflow.json` — even when the detected values already look
263
+ correct. Previously nothing stopped an orchestrating agent from silently
264
+ narrating findings and proceeding straight to the config write.
265
+ - **Default-branch mismatch detection.** `detect` now reports the repo's
266
+ actual GitHub default branch (`repoSettings.defaultBranch`). First-run
267
+ setup surfaces a mismatch against the assumed `main` name and asks the
268
+ user to either map shipflow's `main` role onto the existing default branch
269
+ name, or rename the repo's default branch via the new
270
+ `rename-default-branch` command.
271
+ - **New `rename-default-branch` command**, wrapping GitHub's native
272
+ branch-rename endpoint (which retargets the default-branch pointer and
273
+ open PRs automatically when the renamed branch is the current default).
274
+ - **Honest classification of the tier-gated ruleset failure.** Creating the
275
+ deletion-protection ruleset 403s on private repos without GitHub
276
+ Pro/Team/Enterprise (rulesets are free for public repos only). This now
277
+ surfaces as a `skipped` entry with a clear reason instead of an `errors`
278
+ entry — it's an expected environment limitation, not a shipflow bug. No
279
+ fallback to classic branch protection was added (declined — out of scope
280
+ for this fix).
281
+ - **`requiredChecks` candidates are now filtered to actually PR-triggered
282
+ jobs.** `detect`'s `workflows.jobNames` previously listed every job name
283
+ from every workflow file regardless of its `on:` trigger — a
284
+ `schedule`/`workflow_dispatch`-only job (like `1.00s`'s `weekly-archive.yml`)
285
+ could be picked as a required check that would never run on a PR and
286
+ would block every future merge forever. Now only jobs from
287
+ `pull_request`/`pull_request_target`-triggered workflows are offered as
288
+ candidates.
289
+ - **Agent-driven CI scaffolding when no PR check exists.** Rather than
290
+ teaching shipflow's deterministic CLI about every language/build-tool
291
+ ecosystem, first-run setup now has the orchestrating agent investigate the
292
+ repo and draft a starter `pull_request`-triggered build+test workflow when
293
+ the (now-accurate) required-checks candidate list is empty, with the same
294
+ confirm-before-write discipline as every other step — never a silent
295
+ overwrite, always shown to the user first.
296
+
297
+ ## 0.1.0 (2026-07-14) — Phase A: manual-gate core
298
+
299
+ Initial release. Implements the fully-specified, reference-repo-validated slice
300
+ of the [shipflow design](../../docs/plans/2026-07-14-shipflow-skill-design.md):
301
+
302
+ - Long-lived `dev`/`main` branches with configurable names.
303
+ - `dev → main` promotion PRs that auto-merge once configured required checks pass.
304
+ - Automatic branch cleanup (`delete_branch_on_merge` + a deletion ruleset) for
305
+ every branch except `dev`/`main` — zero custom mutation logic, a native
306
+ GitHub setting.
307
+ - `protectionOwner` detection: defers to an existing settings-as-code
308
+ mechanism (e.g. `repo-settings.sh`, Terraform) rather than installing a
309
+ competing ruleset, with an explicit user prompt when protection exists
310
+ with no artifact behind it.
311
+ - `release.mode: "manual-gate"` — the deliberate, ask-before-tagging release
312
+ flow (a durable `release-pending` label survives the async gap between a
313
+ promotion merging and the next interactive `shipflow` run).
314
+
315
+ `release.mode: "auto"` (release-please-driven automatic tagging) is accepted
316
+ in the config schema but **not yet implemented** — `apply.mjs` refuses to run
317
+ against an `"auto"` config with a clear "not yet implemented" error rather
318
+ than silently no-oping. Tracked as Phase B; needs a live GitHub sandbox to
319
+ build and verify the two-hop `RELEASE_PAT` credential wiring and the
320
+ release-please byte-equality pre-flight safely.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nate Swenson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # shipflow
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)
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.
7
+
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
+
10
+ ## Patterns
11
+
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 |
17
+
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.
19
+
20
+ ## How it works
21
+
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.
26
+
27
+ ## Quick start
28
+
29
+ All deterministic work runs through the published CLI:
30
+
31
+ ```sh
32
+ npx -y @natjswenson/shipflow@latest detect --repo . --main main --dev dev
33
+ ```
34
+
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`.
36
+
37
+ Full interactive setup flow: [`skills/shipflow/SKILL.md`](skills/shipflow/SKILL.md).
38
+
39
+ ## Commands
40
+
41
+ | Command | What it does |
42
+ |---|---|
43
+ | `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 |
45
+ | `apply --repo <path> --expect-state-hash <hash> [--dry-run] [--force <id> --force-reason <text>]` | Apply a confirmed plan |
46
+ | `releases --repo <path>` | List `dev → main` promotions still labeled `release-pending` |
47
+ | `release-dispatch --repo <path> --pr <n> --workflow-file <f>... --ref <ref>` | Dispatch each changed skill's release workflow; clear the label on success |
48
+ | `rename-default-branch --repo <path> --branch <old> --to <new>` | One-time bootstrap: rename a repo's default branch |
49
+
50
+ Every command prints JSON to stdout.
51
+
52
+ ## Status
53
+
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.
55
+
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.
57
+
58
+ ## Design
59
+
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).
61
+
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).
63
+
64
+ ## License
65
+
66
+ MIT
package/SKILL.md CHANGED
@@ -41,9 +41,18 @@ user; the CLI is the only thing that *does*.
41
41
  ```
42
42
  npx -y @natjswenson/shipflow@latest detect --repo <path> --main main --dev dev
43
43
  ```
44
- (Use whatever branch names the user has, or `main`/`dev` as a starting guess — you'll confirm them next.) This prints a `RepoState` plus a `protectionOwnerClassification` of `"external"`, `"shipflow"`, or `"ambiguous"`.
45
-
46
- 2. **Resolve a default-branch mismatch, if any.** Compare `repoState.repoSettings.defaultBranch` (the repo's actual GitHub default branch) to the `--main` name used in step 1. If they match, skip to step 3. If they differ (e.g. the repo's default is `master`), ask the user explicitlydo not silently assume either path:
44
+ (Use whatever branch names the user has, or `main`/`dev` as a starting guess — you'll confirm them next.) This prints a `RepoState` plus a `protectionOwnerClassification` of `"external"`, `"shipflow"`, or `"ambiguous"`, and now also a `rankedPatterns` array — every pattern's `{id, score, evidence}`, sorted descending by score.
45
+
46
+ 2. **Resolve `workflowPattern` before anything else** — a `github-flow` repo never asks about a `dev` branch name at all, so this has to happen before step 3 below. Classify `rankedPatterns` per these rules: **confident** if the top score is `>= 0.7` AND the gap over the second-place score is `> 0.3`; **greenfield** if the top score is `< 0.4`; **ambiguous** otherwise (the residual caseno separate condition to satisfy).
47
+ - **Confident:** state what was detected and why (the top entry's `evidence` array) — *"I detected this repo is using **`<pattern-id>`** because: `<evidence bullets>`. I'll set `workflowPattern` to this — confirm before I proceed, or tell me if you'd rather pick a different pattern."* This is still a confirm-before-write checkpoint per this section's mandatory-interview rule — a confident autodetect is not a substitute for the user's explicit confirmation.
48
+ - **Ambiguous or greenfield:** present all 3 patterns and ask the user to choose. Do not silently pick one:
49
+ - `dev-main-promotion` — long-lived `dev` + `main`; a promotion PR auto-merges `dev` into `main`.
50
+ - `github-flow` — single long-lived `main`; every PR merges (and auto-merges) directly to `main`. Suggest this as the lightweight default for a **greenfield** repo specifically, without auto-picking it.
51
+ - `gitflow` — `develop` + `main` + transient `release/*`/`hotfix/*` branches, for software that maintains multiple released versions concurrently.
52
+ - Once resolved, proceed with only the interview fields that pattern's config actually uses — skip asking about a `dev` branch name under `github-flow`, for instance.
53
+ - If `workflowPattern` is `gitflow`, additionally ask for `releaseBranchPrefix`/`hotfixBranchPrefix` (defaulting to `release/`/`hotfix/` if the user has no preference) — recorded under `patternConfig.gitflow` in the config.
54
+
55
+ 3. **Resolve a default-branch mismatch, if any.** Compare `repoState.repoSettings.defaultBranch` (the repo's actual GitHub default branch) to the `--main` name used in step 1. If they match, skip to step 4. If they differ (e.g. the repo's default is `master`), ask the user explicitly — do not silently assume either path:
47
56
  - **Map onto the existing default branch** — set the config's `branches.main` to the detected default branch name and continue with the rest of setup treating that as "main." No mutating calls needed; `branches.main` is fully configurable.
48
57
  - **Switch the repo's default branch to `main`** — flag this as a bigger, more disruptive action than the rest of setup (it affects every collaborator and every open PR), get a distinct explicit confirmation for it specifically, separate from the general setup go-ahead, then run:
49
58
  ```
@@ -51,41 +60,41 @@ user; the CLI is the only thing that *does*.
51
60
  ```
52
61
  GitHub natively retargets the default-branch pointer and open PRs' base ref. On success, tell the user their own local checkout still points at the old name and needs `git fetch origin && git checkout main` to follow, then re-run step 1's `detect` (repo state changed) before continuing.
53
62
 
54
- 3. **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.
63
+ 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.
55
64
 
56
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.
57
66
 
58
- 4. **Resolve `protectionOwner`:**
67
+ 5. **Resolve `protectionOwner`:**
59
68
  - `"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.
60
69
  - `"shipflow"` → tell the user no existing branch protection was found and shipflow will own it going forward.
61
70
  - `"ambiguous"` → **branch protection exists but no settings-as-code artifact was found** (e.g. hand-configured via the GitHub UI). Do NOT silently pick either value — this is exactly the false-positive failure mode a prior design iteration got wrong. Ask explicitly: *"Branch protection exists on this repo but isn't managed as code — should shipflow take ownership of it, or keep managing it externally even though no artifact was found?"* Record whichever the user picks.
62
71
 
63
- 5. **Resolve `release.releaseCredential` — never default it to `GITHUB_TOKEN`.** The rendered `dev-to-main-automerge.yml`'s `GH_TOKEN` comes from this secret name. A PR auto-merged under `secrets.GITHUB_TOKEN` completes (once checks pass) attributed to the `github-actions[bot]` identity, and GitHub's loop-prevention rule means that bot-attributed merge's `pull_request: closed` event **never triggers this or any other workflow** — so `label-release-pending` silently never runs, and the entire manual-gate release-ask flow never has anything to find. This was confirmed empirically, not theoretically: an otherwise-identical PR merged by a real, PAT-authenticated actor fired the closed-event trigger within 2 seconds; one completed by `GITHUB_TOKEN`-enabled auto-merge fired no run at all, even after 100+ seconds. Ask the user to create a fine-grained PAT (or GitHub App installation token) scoped to this repo with `contents: write` + `pull-requests: write`, and to store it as a repo secret themselves (e.g. `gh secret set <NAME> --repo <owner>/<repo>`, run in *their own* shell so the token value never passes through the agent or the transcript). Record only the secret's *name* in `release.releaseCredential` — never its value.
72
+ 6. **Resolve `release.releaseCredential` — never default it to `GITHUB_TOKEN`.** The rendered auto-merge workflow's `GH_TOKEN` comes from this secret name. A PR auto-merged under `secrets.GITHUB_TOKEN` completes (once checks pass) attributed to the `github-actions[bot]` identity, and GitHub's loop-prevention rule means that bot-attributed merge's `pull_request: closed` event **never triggers this or any other workflow** — so `label-release-pending` silently never runs, and the entire manual-gate release-ask flow never has anything to find. This was confirmed empirically, not theoretically: an otherwise-identical PR merged by a real, PAT-authenticated actor fired the closed-event trigger within 2 seconds; one completed by `GITHUB_TOKEN`-enabled auto-merge fired no run at all, even after 100+ seconds. Ask the user to create a fine-grained PAT (or GitHub App installation token) scoped to this repo with `contents: write` + `pull-requests: write`, and to store it as a repo secret themselves (e.g. `gh secret set <NAME> --repo <owner>/<repo>`, run in *their own* shell so the token value never passes through the agent or the transcript). Record only the secret's *name* in `release.releaseCredential` — never its value.
64
73
 
65
- 6. **Present the interview summary and write `.github/shipflow.json`.** Before writing anything, show the user the resolved branch names, `requiredChecks`, `protectionOwner`, and `release.releaseCredential` together in one place and wait for explicit confirmation — this is the checkpoint called out at the top of this section. Then write the config in the target repo (never inside the skill package) using `config.example.json` as the template, with `release.mode: "manual-gate"` (the only implemented mode in this version — see Auto mode, below). Tell the user `.github/shipflow.json` is committed policy and should be `git add`/committed — ideally in the same commit as the rendered auto-merge workflow, once step 10 produces one.
74
+ 7. **Present the interview summary and write `.github/shipflow.json`.** Before writing anything, show the user the resolved `workflowPattern`, branch names, `requiredChecks`, `protectionOwner`, and `release.releaseCredential` together in one place and wait for explicit confirmation — this is the checkpoint called out at the top of this section. Then write the config in the target repo (never inside the skill package) using `config.example.json` as the template, with `release.mode: "manual-gate"` (the only implemented mode in this version — see Auto mode, below). Tell the user `.github/shipflow.json` is committed policy and should be `git add`/committed — ideally in the same commit as the rendered auto-merge workflow(s), once step 11 produces them.
66
75
 
67
- 7. **Show the plan.** Run:
76
+ 8. **Show the plan.** Run:
68
77
  ```
69
78
  npx -y @natjswenson/shipflow@latest plan --repo <path>
70
79
  ```
71
- This prints `{ plan, stateHash }`. Present `plan.creates`/`plan.updates`/`plan.noops` to the user in plain language — what will be created, what will change, what's already correct. **Wait for explicit confirmation before proceeding.** If any entry has `handEditDetected: true`, call it out specifically and ask whether to override (see step 9).
80
+ This prints `{ plan, stateHash }`. Present `plan.creates`/`plan.updates`/`plan.noops` to the user in plain language — what will be created, what will change, what's already correct. **Wait for explicit confirmation before proceeding.** If any entry has `handEditDetected: true`, call it out specifically and ask whether to override (see step 10).
72
81
 
73
- 8. **Dry-run apply** (optional sanity check, same output shape as the real apply but nothing is mutated):
82
+ 9. **Dry-run apply** (optional sanity check, same output shape as the real apply but nothing is mutated):
74
83
  ```
75
84
  npx -y @natjswenson/shipflow@latest apply --repo <path> --dry-run
76
85
  ```
77
86
 
78
- 9. **Apply for real**, passing the `stateHash` from step 7's plan output as `--expect-state-hash` — this is the TOCTOU guard: if repo state drifted between the plan you showed the user and this call, `apply` refuses to mutate anything and tells you to re-plan. **`--expect-state-hash` is mandatory for a real (non-dry-run) apply** — omitting it is a hard CLI refusal, not a silent skip of the check; the only way around it is the explicitly-named `--skip-hash-check` escape hatch, which you should never reach for as a matter of course.
79
- ```
80
- npx -y @natjswenson/shipflow@latest apply --repo <path> --expect-state-hash <hash-from-step-7>
81
- ```
82
- If a `handEditDetected` entry was confirmed for override in step 7, pass `--force <entry-id>` (repeatable — one flag per confirmed entry id, never a blanket override) **and** `--force-reason "<short justification>"` — the CLI refuses any `--force` without an accompanying reason, and that reason is echoed back in the apply result for auditability. Write a real justification tied to the user's actual confirmation (e.g. `--force-reason "user confirmed hand-edit override for the branch-rename migration on 2026-07-15"`), never a placeholder string.
87
+ 10. **Apply for real**, passing the `stateHash` from step 8's plan output as `--expect-state-hash` — this is the TOCTOU guard: if repo state drifted between the plan you showed the user and this call, `apply` refuses to mutate anything and tells you to re-plan. **`--expect-state-hash` is mandatory for a real (non-dry-run) apply** — omitting it is a hard CLI refusal, not a silent skip of the check; the only way around it is the explicitly-named `--skip-hash-check` escape hatch, which you should never reach for as a matter of course.
88
+ ```
89
+ npx -y @natjswenson/shipflow@latest apply --repo <path> --expect-state-hash <hash-from-step-8>
90
+ ```
91
+ If a `handEditDetected` entry was confirmed for override in step 8, pass `--force <entry-id>` (repeatable — one flag per confirmed entry id, never a blanket override) **and** `--force-reason "<short justification>"` — the CLI refuses any `--force` without an accompanying reason, and that reason is echoed back in the apply result for auditability. Write a real justification tied to the user's actual confirmation (e.g. `--force-reason "user confirmed hand-edit override for the branch-rename migration on 2026-07-15"`), never a placeholder string.
83
92
 
84
- 10. **Report the result.** Read `applied`/`skipped`/`errors` from the response. A `skipped` entry can be a deliberate refusal (empty checks, hand-edit) or an environment limitation shipflow can't do anything about (e.g. a deletion-ruleset skipped because the repo is private and not on a paid GitHub tier) — read each `reason` and relay it plainly rather than treating every `skipped` entry the same. If `renderedTemplateHashes` is non-empty, update `.github/shipflow.json`'s `renderedTemplateHashes` field with those values and tell the user to commit the config change *and* the rendered workflow file **together, in the same commit** — a split commit is exactly what causes a false `handEditDetected` on a clean checkout later.
93
+ 11. **Report the result.** Read `applied`/`skipped`/`errors` from the response. A `skipped` entry can be a deliberate refusal (empty checks, hand-edit) or an environment limitation shipflow can't do anything about (e.g. a deletion-ruleset skipped because the repo is private and not on a paid GitHub tier) — read each `reason` and relay it plainly rather than treating every `skipped` entry the same. If `renderedTemplateHashes` is non-empty, update `.github/shipflow.json`'s `renderedTemplateHashes` field with those values and tell the user to commit the config change *and* the rendered workflow file(s) **together, in the same commit** — a split commit is exactly what causes a false `handEditDetected` on a clean checkout later.
85
94
 
86
95
  ## Re-run / audit
87
96
 
88
- Same as steps 1, 7, 8, 9, 10 above, skipping the interview (branch names/checks/protectionOwner/releaseCredential are already recorded in `.github/shipflow.json` — read it, don't re-ask, unless the user explicitly says they want to reconfigure). If `plan.creates`/`plan.updates` is non-empty, that's drift since the last apply — show it and confirm before applying, exactly as in first-run setup.
97
+ Same as steps 1, 8, 9, 10, 11 above, skipping the interview (`workflowPattern`/branch names/checks/protectionOwner/releaseCredential are already recorded in `.github/shipflow.json` — read it, don't re-ask, unless the user explicitly says they want to reconfigure). Step 2's pattern resolution never runs on a re-run — `workflowPattern`'s absence from a config genuinely means "not yet resolved," and its presence means "already resolved," so there's nothing to detect again. If `plan.creates`/`plan.updates` is non-empty, that's drift since the last apply — show it and confirm before applying, exactly as in first-run setup.
89
98
 
90
99
  ## Check pending releases (`manual-gate` ask-flow)
91
100
 
package/bin/shipflow.js CHANGED
@@ -15,9 +15,18 @@ import {
15
15
  renameDefaultBranch,
16
16
  } from '../lib/apply.mjs';
17
17
  import { readFileCapped } from '../lib/gh.mjs';
18
+ import { resolvePattern, scoreAll } from '../lib/pattern-registry.mjs';
18
19
 
19
20
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
- const TEMPLATE_PATH = join(PACKAGE_ROOT, 'templates', 'dev-to-main-automerge.yml.tmpl');
21
+
22
+ function buildTemplateSources(config) {
23
+ const pattern = resolvePattern(config);
24
+ const sources = {};
25
+ for (const entry of pattern.templates(config)) {
26
+ sources[entry.id] = readFileSync(entry.templateSourcePath, 'utf8');
27
+ }
28
+ return sources;
29
+ }
21
30
 
22
31
  function readPackageVersion() {
23
32
  const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'));
@@ -63,7 +72,8 @@ function cmdDetect(args) {
63
72
  releaseCredentialName: values['release-credential'] ?? null,
64
73
  });
65
74
  const protectionOwner = classifyProtectionOwner(repoState);
66
- printJson({ ...repoState, protectionOwnerClassification: protectionOwner });
75
+ const rankedPatterns = scoreAll(repoState);
76
+ printJson({ ...repoState, protectionOwnerClassification: protectionOwner, rankedPatterns });
67
77
  }
68
78
 
69
79
  function cmdPlan(args) {
@@ -88,10 +98,10 @@ function cmdPlan(args) {
88
98
  branches: config.branches,
89
99
  releaseCredentialName: config.release?.releaseCredential ?? null,
90
100
  });
91
- const templateSource = readFileSync(TEMPLATE_PATH, 'utf8');
101
+ const templateSources = buildTemplateSources(config);
92
102
  let plan;
93
103
  try {
94
- plan = computePlan(repoState, config, templateSource);
104
+ plan = computePlan(repoState, config, templateSources);
95
105
  } catch (e) {
96
106
  return fail(`plan: ${e.message}`);
97
107
  }
@@ -149,10 +159,10 @@ function cmdApply(args) {
149
159
  branches: config.branches,
150
160
  releaseCredentialName: config.release?.releaseCredential ?? null,
151
161
  });
152
- const templateSource = readFileSync(TEMPLATE_PATH, 'utf8');
162
+ const templateSources = buildTemplateSources(config);
153
163
  let plan;
154
164
  try {
155
- plan = computePlan(repoState, config, templateSource);
165
+ plan = computePlan(repoState, config, templateSources);
156
166
  } catch (e) {
157
167
  return fail(`apply: ${e.message}`);
158
168
  }
package/lib/apply.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { spawnArgs, ghApiJson } from './gh.mjs';
4
+ import { resolvePattern } from './pattern-registry.mjs';
4
5
 
5
6
  // applyPlan(plan, opts): opts extends the schematic { dryRun, currentStateHash,
6
7
  // force } from the design contract with the execution context (ownerRepo,
@@ -29,6 +30,22 @@ export function classifyRulesetError(stderr) {
29
30
  return { tierGated: false, reason: null };
30
31
  }
31
32
 
33
+ // Pure — no I/O, no gh calls. Exported so it's directly unit-testable without
34
+ // mocking the network layer, matching this file's existing classifyRulesetError
35
+ // pattern. protectedBranchList must come from a FRESH call to the resolved
36
+ // pattern's protectedBranches(config) — never from a stored config field — so
37
+ // this function intentionally takes a plain string array, not a config object,
38
+ // making "read a stale stored value" structurally impossible to do by accident.
39
+ export function buildDeletionRulesetBody(protectedBranchList) {
40
+ return {
41
+ name: 'shipflow-branch-deletion-protection',
42
+ target: 'branch',
43
+ enforcement: 'active',
44
+ conditions: { ref_name: { include: protectedBranchList.map((b) => `refs/heads/${b}`), exclude: [] } },
45
+ rules: [{ type: 'deletion' }],
46
+ };
47
+ }
48
+
32
49
  export function applyPlan(plan, opts) {
33
50
  const { dryRun, currentStateHash, force = [], forceReason = null, ownerRepo, repoPath, config } = opts;
34
51
 
@@ -114,15 +131,8 @@ function applyOne(entry, { ownerRepo, repoPath, config }) {
114
131
  }
115
132
 
116
133
  if (entry.id === 'deletion-ruleset') {
117
- const body = JSON.stringify({
118
- name: 'shipflow-branch-deletion-protection',
119
- target: 'branch',
120
- enforcement: 'active',
121
- conditions: {
122
- ref_name: { include: [`refs/heads/${config.branches.dev}`, `refs/heads/${config.branches.main}`], exclude: [] },
123
- },
124
- rules: [{ type: 'deletion' }],
125
- });
134
+ const protectedBranchList = resolvePattern(config).protectedBranches(config);
135
+ const body = JSON.stringify(buildDeletionRulesetBody(protectedBranchList));
126
136
  const r = spawnArgs('gh', ['api', `repos/${ownerRepo}/rulesets`, '-X', 'POST', '--input', '-'], { input: body });
127
137
  if (r.status === 0) return { ok: true };
128
138
  const { tierGated, reason } = classifyRulesetError(r.stderr);