@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 +320 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/SKILL.md +26 -17
- package/bin/shipflow.js +16 -6
- package/lib/apply.mjs +19 -9
- package/lib/detect.mjs +78 -4
- package/lib/pattern-registry.mjs +79 -0
- package/lib/patterns/dev-main-promotion/index.mjs +49 -0
- package/lib/patterns/gitflow/index.mjs +55 -0
- package/lib/patterns/github-flow/index.mjs +43 -0
- package/lib/plan.mjs +54 -48
- package/lib/render.mjs +42 -2
- package/package.json +4 -2
- package/skill-invariants.json +20 -1
- package/templates/gitflow/hotfix-automerge.yml.tmpl +65 -0
- package/templates/gitflow/hotfix-merge-back.yml.tmpl +54 -0
- package/templates/gitflow/release-automerge.yml.tmpl +65 -0
- package/templates/gitflow/release-merge-back.yml.tmpl +54 -0
- package/templates/github-flow/main-automerge.yml.tmpl +66 -0
- /package/templates/{dev-to-main-automerge.yml.tmpl → dev-main-promotion/dev-to-main-automerge.yml.tmpl} +0 -0
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
|
+
[](https://www.npmjs.com/package/@natjswenson/shipflow)
|
|
4
|
+
[](./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
|
|
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 case — no 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
101
|
+
const templateSources = buildTemplateSources(config);
|
|
92
102
|
let plan;
|
|
93
103
|
try {
|
|
94
|
-
plan = computePlan(repoState, config,
|
|
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
|
|
162
|
+
const templateSources = buildTemplateSources(config);
|
|
153
163
|
let plan;
|
|
154
164
|
try {
|
|
155
|
-
plan = computePlan(repoState, config,
|
|
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
|
|
118
|
-
|
|
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);
|