@natjswenson/shipflow 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md ADDED
@@ -0,0 +1,120 @@
1
+ ---
2
+ name: shipflow
3
+ description: Scaffold a configurable dev/main branching, auto-merge, branch-cleanup, and release-tagging workflow into any repo. Detects existing branch protection, CI checks, and release conventions; always shows a plan and waits for confirmation before mutating anything. Use when the user asks to set up branch protection standards, apply deployment/release standards to a repo, or wants long-lived dev/main branches with auto-merge and branch cleanup.
4
+ user_invocable: true
5
+ ---
6
+
7
+ # /shipflow — branching + release-automation setup
8
+
9
+ All deterministic work is delegated to the CLI. Invoke it as
10
+ `npx -y @natjswenson/shipflow <command>`. Every command prints JSON to
11
+ stdout — parse it, don't try to re-derive what it computed.
12
+
13
+ **This skill never mutates repo state directly.** Every mutating action goes
14
+ through `shipflow apply`, and the computed plan is always shown to the user
15
+ and confirmed before the real (non-dry-run) apply runs. This is the
16
+ deterministic/nondeterministic split: you decide *what* and confirm with the
17
+ user; the CLI is the only thing that *does*.
18
+
19
+ ## Decide which mode you're in
20
+
21
+ | Situation | Mode |
22
+ |---|---|
23
+ | `.github/shipflow.json` doesn't exist in the target repo yet | **First-run setup** |
24
+ | `.github/shipflow.json` exists, user wants to check/repair drift | **Re-run / audit** |
25
+ | User asks "any releases pending?" / periodic check-in / after a `dev → main` merge | **Check pending releases** |
26
+
27
+ ## First-run setup
28
+
29
+ **This whole section is a mandatory interactive interview, not a narrate-and-proceed pass.** Steps 2–4 below must end with the agent presenting a plain-language summary of what was detected and what's about to be written, and waiting for the user's explicit go-ahead — even when detected values already look correct. Never go from step 1's `detect` straight to step 4's config write without that confirmation turn; a value looking right is not the same as the user confirming it.
30
+
31
+ 1. **Detect.** Run:
32
+ ```
33
+ npx -y @natjswenson/shipflow detect --repo <path> --main main --dev dev
34
+ ```
35
+ (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"`.
36
+
37
+ 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 explicitly — do not silently assume either path:
38
+ - **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.
39
+ - **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:
40
+ ```
41
+ npx -y @natjswenson/shipflow rename-default-branch --repo <path> --branch <old-default> --to main
42
+ ```
43
+ 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.
44
+
45
+ 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.
46
+
47
+ **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.
48
+
49
+ 4. **Resolve `protectionOwner`:**
50
+ - `"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.
51
+ - `"shipflow"` → tell the user no existing branch protection was found and shipflow will own it going forward.
52
+ - `"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.
53
+
54
+ 5. **Present the interview summary and write `.github/shipflow.json`.** Before writing anything, show the user the resolved branch names, `requiredChecks`, and `protectionOwner` 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 9 produces one.
55
+
56
+ 6. **Show the plan.** Run:
57
+ ```
58
+ npx -y @natjswenson/shipflow plan --repo <path>
59
+ ```
60
+ 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 8).
61
+
62
+ 7. **Dry-run apply** (optional sanity check, same output shape as the real apply but nothing is mutated):
63
+ ```
64
+ npx -y @natjswenson/shipflow apply --repo <path> --dry-run
65
+ ```
66
+
67
+ 8. **Apply for real**, passing the `stateHash` from step 6'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.
68
+ ```
69
+ npx -y @natjswenson/shipflow apply --repo <path> --expect-state-hash <hash-from-step-6>
70
+ ```
71
+ If a `handEditDetected` entry was confirmed for override in step 6, pass `--force <entry-id>` (repeatable — one flag per confirmed entry id, never a blanket override).
72
+
73
+ 9. **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.
74
+
75
+ ## Re-run / audit
76
+
77
+ Same as steps 1, 6, 7, 8, 9 above, skipping the interview (branch names/checks/protectionOwner 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.
78
+
79
+ ## Check pending releases (`manual-gate` ask-flow)
80
+
81
+ This is a **separate, later invocation** from the one that ran the promotion's `apply` — native GitHub auto-merge completes asynchronously, with no live session attached at the moment of the actual merge. A durable `release-pending` label is what survives that gap.
82
+
83
+ 1. Run:
84
+ ```
85
+ npx -y @natjswenson/shipflow releases --repo <path>
86
+ ```
87
+ This returns every `dev → main` PR still labeled `release-pending`, each with a `merged` flag (confirmed independently, not just inferred from the label).
88
+
89
+ 2. For **each** promotion returned (there can be more than one if several merged before you last checked — handle the whole list, not just the most recent): if `merged` is `false`, skip it for now (native auto-merge hasn't landed yet; don't ask about a promotion that isn't actually on `main`). If `merged` is `true`, ask the user: *"A promotion merged to main — cut a release for [changed skills]?"*
90
+
91
+ 3. If yes, dispatch each changed skill's release workflow and clear the label **only after every dispatch is confirmed successful**:
92
+ ```
93
+ npx -y @natjswenson/shipflow release-dispatch --repo <path> --pr <number> --workflow-file <skill1>.yml --workflow-file <skill2>.yml --ref main
94
+ ```
95
+ If `dispatched` shows a partial failure, the label is deliberately left in place — report this to the user and note the promotion will resurface next time `releases` is checked; a later re-dispatch is safe (each skill's release workflow is idempotent).
96
+
97
+ 4. If no, leave the label as-is — there is no "defer" state in this version; declining is final for that promotion short of a manual dispatch. (Deliberate v1 simplification, not an oversight.)
98
+
99
+ ## Auto mode (not yet implemented)
100
+
101
+ `release.mode: "auto"` is a valid value in the config schema (the full design covers automatic tagging via `release-please`), but `shipflow apply` in this version **refuses to run** against a config with `release.mode: "auto"`, with a clear error rather than silently no-oping. If a user asks for fully automatic tagging, tell them it's designed but not yet shipped (see `CHANGELOG.md`) and that `"manual-gate"` — the deliberate ask-before-tagging flow above — is what's available today.
102
+
103
+ ## Error handling
104
+
105
+ - **Empty `requiredChecks`:** `apply` refuses to wire up auto-merge with zero required checks. Don't work around this by suggesting `--force allow-no-checks` unless the user has explicitly and knowingly accepted an unprotected merge — surface the refusal message plainly first.
106
+ - **`handEditDetected`:** a template file's on-disk content doesn't match what shipflow last rendered *or* what it would freshly render — someone hand-edited it. Never silently pass `--force` for this; always show the user what changed and get explicit confirmation per entry.
107
+ - **TOCTOU abort:** if `apply` returns a `toctou` error, repo state changed between plan and apply — re-run the plan step, don't retry the same `--expect-state-hash`.
108
+ - **`gh auth` failures:** surface these immediately; branch protection and rulesets need repo-admin scope. Don't proceed partway through a plan on missing auth.
109
+
110
+ ## Security rules
111
+
112
+ - All `gh`/`git` invocations in the CLI are argv-style (`spawnSync` with an args array, no shell) — never construct a shell command string from user input when extending this skill.
113
+ - `.github/shipflow.json` is committed policy, not secrets — never write credential *values* into it, only the *name* of a secret (`release.releaseCredential`).
114
+ - Never write shipflow's config anywhere other than `.github/shipflow.json` in the target repo.
115
+
116
+ ## Edge cases
117
+
118
+ - **Greenfield repo, no CI yet:** `requiredChecks` will detect empty. Don't silently proceed — tell the user auto-merge can't be enabled until at least one check exists, and that's a real ordering dependency (CI first, then shipflow setup), not a shipflow bug.
119
+ - **Repo already has `shipflow.json` with `release.mode: "auto"`:** refuse per "Auto mode," above, even on a re-run/audit — don't silently downgrade it to `"manual-gate"` either; surface the refusal and let the user decide.
120
+ - **Private repo on a free GitHub plan:** the deletion-protection ruleset requires GitHub Pro/Team/Enterprise for private repos (rulesets are free for public repos only). `apply` reports this as a `skipped` entry with that reason, not an `errors` entry — it's an expected environment limitation, not a shipflow bug. Cleanup and the release-pending label still apply normally.
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { parseArgs } from 'node:util';
6
+
7
+ import { detectRepoState, classifyProtectionOwner, resolveOwnerRepo } from '../lib/detect.mjs';
8
+ import { computePlan } from '../lib/plan.mjs';
9
+ import {
10
+ applyPlan,
11
+ listPendingReleasePromotions,
12
+ confirmPromotionMerged,
13
+ clearReleasePendingLabel,
14
+ dispatchReleaseWorkflow,
15
+ renameDefaultBranch,
16
+ } from '../lib/apply.mjs';
17
+
18
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
19
+ const TEMPLATE_PATH = join(PACKAGE_ROOT, 'templates', 'dev-to-main-automerge.yml.tmpl');
20
+
21
+ function readPackageVersion() {
22
+ const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'));
23
+ return pkg.version;
24
+ }
25
+
26
+ function readConfig(path) {
27
+ return JSON.parse(readFileSync(path, 'utf8'));
28
+ }
29
+
30
+ function defaultConfigPath(repoPath) {
31
+ return join(repoPath, '.github', 'shipflow.json');
32
+ }
33
+
34
+ function printJson(obj) {
35
+ console.log(JSON.stringify(obj, null, 2));
36
+ }
37
+
38
+ function fail(message) {
39
+ console.error(JSON.stringify({ error: message }));
40
+ process.exit(1);
41
+ }
42
+
43
+ // --- commands ---------------------------------------------------------------
44
+
45
+ function cmdDetect(args) {
46
+ const { values } = parseArgs({
47
+ args,
48
+ options: {
49
+ repo: { type: 'string' },
50
+ main: { type: 'string', default: 'main' },
51
+ dev: { type: 'string', default: 'dev' },
52
+ 'release-credential': { type: 'string' },
53
+ },
54
+ });
55
+ if (!values.repo) return fail('detect: --repo is required');
56
+
57
+ const repoState = detectRepoState(values.repo, {
58
+ branches: { main: values.main, dev: values.dev },
59
+ releaseCredentialName: values['release-credential'] ?? null,
60
+ });
61
+ const protectionOwner = classifyProtectionOwner(repoState);
62
+ printJson({ ...repoState, protectionOwnerClassification: protectionOwner });
63
+ }
64
+
65
+ function cmdPlan(args) {
66
+ const { values } = parseArgs({
67
+ args,
68
+ options: {
69
+ repo: { type: 'string' },
70
+ config: { type: 'string' },
71
+ },
72
+ });
73
+ if (!values.repo) return fail('plan: --repo is required');
74
+
75
+ const configPath = values.config ?? defaultConfigPath(values.repo);
76
+ let config;
77
+ try {
78
+ config = readConfig(configPath);
79
+ } catch (e) {
80
+ return fail(`plan: could not read config at ${configPath}: ${e.message}`);
81
+ }
82
+
83
+ const repoState = detectRepoState(values.repo, {
84
+ branches: config.branches,
85
+ releaseCredentialName: config.release?.releaseCredential ?? null,
86
+ });
87
+ const templateSource = readFileSync(TEMPLATE_PATH, 'utf8');
88
+ const plan = computePlan(repoState, config, templateSource);
89
+ printJson({ plan, stateHash: repoState.stateHash });
90
+ }
91
+
92
+ function cmdApply(args) {
93
+ const { values } = parseArgs({
94
+ args,
95
+ options: {
96
+ repo: { type: 'string' },
97
+ config: { type: 'string' },
98
+ 'dry-run': { type: 'boolean', default: false },
99
+ 'expect-state-hash': { type: 'string' },
100
+ force: { type: 'string', multiple: true, default: [] },
101
+ },
102
+ });
103
+ if (!values.repo) return fail('apply: --repo is required');
104
+
105
+ const configPath = values.config ?? defaultConfigPath(values.repo);
106
+ let config;
107
+ try {
108
+ config = readConfig(configPath);
109
+ } catch (e) {
110
+ return fail(`apply: could not read config at ${configPath}: ${e.message}`);
111
+ }
112
+
113
+ if (config.release?.mode === 'auto') {
114
+ return fail(
115
+ 'apply: release.mode "auto" is accepted in config but not yet implemented in this version of shipflow — see CHANGELOG.md (Phase B, not yet shipped). Set release.mode to "manual-gate" or wait for a future release.'
116
+ );
117
+ }
118
+
119
+ const ownerRepo = resolveOwnerRepo(values.repo);
120
+ const repoState = detectRepoState(values.repo, {
121
+ branches: config.branches,
122
+ releaseCredentialName: config.release?.releaseCredential ?? null,
123
+ });
124
+ const templateSource = readFileSync(TEMPLATE_PATH, 'utf8');
125
+ const plan = computePlan(repoState, config, templateSource);
126
+
127
+ // The CLI-level TOCTOU gate: compare the freshly-detected state against
128
+ // what the user confirmed at plan time (--expect-state-hash, captured
129
+ // from an earlier `shipflow plan` call). applyPlan's own internal check
130
+ // (currentStateHash vs plan.sourceStateHash) is always trivially satisfied
131
+ // here since both are computed from the same fresh detect — this
132
+ // CLI-level comparison against the user-confirmed hash is the meaningful
133
+ // gate against drift between plan-confirmation and apply-start.
134
+ if (!values['dry-run'] && values['expect-state-hash'] && values['expect-state-hash'] !== repoState.stateHash) {
135
+ return printJson({
136
+ applied: [],
137
+ skipped: [],
138
+ errors: [{ id: 'toctou', message: 'repo state changed since the plan was confirmed — re-run to get an updated plan' }],
139
+ renderedTemplateHashes: {},
140
+ });
141
+ }
142
+
143
+ const result = applyPlan(plan, {
144
+ dryRun: values['dry-run'],
145
+ currentStateHash: repoState.stateHash,
146
+ force: values.force,
147
+ ownerRepo,
148
+ repoPath: values.repo,
149
+ config,
150
+ });
151
+ printJson(result);
152
+ }
153
+
154
+ function cmdReleases(args) {
155
+ const { values } = parseArgs({ args, options: { repo: { type: 'string' }, config: { type: 'string' } } });
156
+ if (!values.repo) return fail('releases: --repo is required');
157
+ const configPath = values.config ?? defaultConfigPath(values.repo);
158
+ const config = readConfig(configPath);
159
+ const ownerRepo = resolveOwnerRepo(values.repo);
160
+ if (!ownerRepo) return fail('releases: could not resolve owner/repo from git remote');
161
+
162
+ const result = listPendingReleasePromotions(ownerRepo, config.branches.main);
163
+ if (!result.ok) return fail(`releases: ${result.error}`);
164
+
165
+ const withMergeCheck = result.promotions.map((p) => ({
166
+ ...p,
167
+ ...confirmPromotionMerged(ownerRepo, p.number),
168
+ }));
169
+ printJson({ promotions: withMergeCheck });
170
+ }
171
+
172
+ function cmdReleaseDispatch(args) {
173
+ const { values } = parseArgs({
174
+ args,
175
+ options: {
176
+ repo: { type: 'string' },
177
+ pr: { type: 'string' },
178
+ 'workflow-file': { type: 'string', multiple: true },
179
+ ref: { type: 'string' },
180
+ },
181
+ });
182
+ if (!values.repo || !values.pr || !values['workflow-file'] || !values.ref) {
183
+ return fail('release-dispatch: --repo, --pr, --workflow-file (repeatable), and --ref are required');
184
+ }
185
+ const ownerRepo = resolveOwnerRepo(values.repo);
186
+ if (!ownerRepo) return fail('release-dispatch: could not resolve owner/repo from git remote');
187
+
188
+ const merged = confirmPromotionMerged(ownerRepo, values.pr);
189
+ if (!merged.ok || !merged.merged) {
190
+ return fail(`release-dispatch: PR #${values.pr} is not confirmed MERGED — refusing to dispatch`);
191
+ }
192
+
193
+ const results = values['workflow-file'].map((wf) => ({ workflowFile: wf, ...dispatchReleaseWorkflow(ownerRepo, wf, values.ref) }));
194
+ const allOk = results.every((r) => r.ok);
195
+ if (!allOk) {
196
+ printJson({ dispatched: results, labelCleared: false, note: 'not all dispatches succeeded — label left in place, will resurface next run' });
197
+ return;
198
+ }
199
+ const cleared = clearReleasePendingLabel(ownerRepo, values.pr);
200
+ printJson({ dispatched: results, labelCleared: cleared.ok, labelClearError: cleared.ok ? null : cleared.error });
201
+ }
202
+
203
+ function cmdRenameDefaultBranch(args) {
204
+ const { values } = parseArgs({
205
+ args,
206
+ options: {
207
+ repo: { type: 'string' },
208
+ branch: { type: 'string' },
209
+ to: { type: 'string' },
210
+ },
211
+ });
212
+ if (!values.repo || !values.branch || !values.to) {
213
+ return fail('rename-default-branch: --repo, --branch, and --to are all required');
214
+ }
215
+ const ownerRepo = resolveOwnerRepo(values.repo);
216
+ if (!ownerRepo) return fail('rename-default-branch: could not resolve owner/repo from git remote');
217
+
218
+ const result = renameDefaultBranch(ownerRepo, values.branch, values.to);
219
+ printJson(result);
220
+ }
221
+
222
+ function printHelp() {
223
+ console.log(`shipflow ${readPackageVersion()}
224
+
225
+ Usage: shipflow <command> [options]
226
+
227
+ Commands:
228
+ detect --repo <path> [--main <name>] [--dev <name>] [--release-credential <name>]
229
+ plan --repo <path> [--config <path>]
230
+ apply --repo <path> [--config <path>] [--dry-run] [--expect-state-hash <hash>] [--force <id>]...
231
+ releases --repo <path> [--config <path>]
232
+ release-dispatch --repo <path> --pr <number> --workflow-file <file>... --ref <ref>
233
+ rename-default-branch --repo <path> --branch <current-name> --to <new-name>
234
+
235
+ Every command prints JSON to stdout.`);
236
+ }
237
+
238
+ // ─── dispatch ────────────────────────────────────────────────────────────────
239
+ // Only run the CLI dispatch when this file is executed directly, not when it
240
+ // is imported (e.g. by the test suite). Both sides are realpath'd: under
241
+ // npm/npx, argv[1] is the node_modules/.bin/shipflow SYMLINK while
242
+ // import.meta.url is the resolved file — a naive === never matches and
243
+ // every npx invocation becomes a silent no-op (this exact bug bit devlog —
244
+ // see this repo's CHANGELOG/commit e69b6ba).
245
+ const isMain = (() => {
246
+ if (!process.argv[1]) return false;
247
+ try {
248
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
249
+ } catch {
250
+ return false;
251
+ }
252
+ })();
253
+
254
+ if (isMain) {
255
+ const arg = process.argv[2];
256
+ const rest = process.argv.slice(3);
257
+ switch (arg) {
258
+ case 'detect':
259
+ cmdDetect(rest);
260
+ break;
261
+ case 'plan':
262
+ cmdPlan(rest);
263
+ break;
264
+ case 'apply':
265
+ cmdApply(rest);
266
+ break;
267
+ case 'releases':
268
+ cmdReleases(rest);
269
+ break;
270
+ case 'release-dispatch':
271
+ cmdReleaseDispatch(rest);
272
+ break;
273
+ case 'rename-default-branch':
274
+ cmdRenameDefaultBranch(rest);
275
+ break;
276
+ case '-v':
277
+ case '--version':
278
+ console.log(readPackageVersion());
279
+ break;
280
+ case undefined:
281
+ case '-h':
282
+ case '--help':
283
+ printHelp();
284
+ break;
285
+ default:
286
+ console.error(`Unknown command: ${arg}`);
287
+ printHelp();
288
+ process.exit(1);
289
+ }
290
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "branches": { "main": "main", "dev": "dev" },
3
+ "featureBranchPrefix": "feature/",
4
+ "requiredChecks": [],
5
+ "mergeMethod": {
6
+ "featureToDevMethod": "squash",
7
+ "devToMainMethod": "merge"
8
+ },
9
+ "protectionOwner": "external",
10
+ "release": {
11
+ "enabled": true,
12
+ "mode": "manual-gate",
13
+ "tool": "release-please",
14
+ "layout": "manifest",
15
+ "releaseCredential": "GITHUB_TOKEN"
16
+ },
17
+ "branchCleanup": {
18
+ "deleteOnMerge": true,
19
+ "protectedBranches": ["dev", "main"]
20
+ },
21
+ "enforceAdmins": false,
22
+ "renderedTemplateHashes": {}
23
+ }
package/lib/apply.mjs ADDED
@@ -0,0 +1,203 @@
1
+ import { writeFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { spawnArgs, ghApiJson } from './gh.mjs';
4
+
5
+ // applyPlan(plan, opts): opts extends the schematic { dryRun, currentStateHash,
6
+ // force } from the design contract with the execution context (ownerRepo,
7
+ // repoPath, config) apply.mjs actually needs to perform mutations — the
8
+ // contract captured intent, not a literal final call signature.
9
+ //
10
+ // opts.force: an array of plan-entry ids the caller has explicitly confirmed
11
+ // should proceed despite a handEditDetected flag, OR the literal string
12
+ // "allow-no-checks" to override the empty-required-checks refusal. Never a
13
+ // global boolean — a force scoped to one entry can't accidentally blanket-
14
+ // override every other flagged entry in the same plan.
15
+ // Repository Rulesets are gated behind GitHub Pro/Team/Enterprise for
16
+ // private repos (free only for public repos) — this is GitHub's exact error
17
+ // string for that case, distinct from a generic 403 (bad token, no admin
18
+ // scope, etc.) which should still surface as a real error.
19
+ const RULESET_TIER_GATED_RE = /Upgrade to GitHub (Pro|Team|Enterprise)/i;
20
+
21
+ export function classifyRulesetError(stderr) {
22
+ if (RULESET_TIER_GATED_RE.test(stderr)) {
23
+ return {
24
+ tierGated: true,
25
+ reason:
26
+ 'deletion-protection ruleset requires GitHub Pro/Team/Enterprise for private repos on the free tier — skipped, not a shipflow bug. Make the repo public or upgrade to enable it.',
27
+ };
28
+ }
29
+ return { tierGated: false, reason: null };
30
+ }
31
+
32
+ export function applyPlan(plan, opts) {
33
+ const { dryRun, currentStateHash, force = [], ownerRepo, repoPath, config } = opts;
34
+
35
+ // TOCTOU guard — refuse before making ANY mutating call if live state has
36
+ // drifted since the plan was computed. Idempotency is the primary safety
37
+ // net (re-running is always safe); this is the narrower, single-shot
38
+ // pre-flight that catches drift between plan-confirmation and apply-start.
39
+ if (!dryRun && currentStateHash !== plan.sourceStateHash) {
40
+ return {
41
+ applied: [],
42
+ skipped: [],
43
+ errors: [
44
+ {
45
+ id: 'toctou',
46
+ message: 'repo state changed since the plan was confirmed — re-run to get an updated plan',
47
+ },
48
+ ],
49
+ renderedTemplateHashes: {},
50
+ };
51
+ }
52
+
53
+ const applied = [];
54
+ const skipped = [];
55
+ const errors = [];
56
+ const renderedTemplateHashes = {};
57
+
58
+ const mutating = [...plan.creates, ...plan.updates];
59
+
60
+ // Empty-required-checks hard refusal (fail-open guard). Effective required
61
+ // checks are config.requiredChecks under shipflow-owned protection, or the
62
+ // live-detected union (plan.liveRequiredChecks) under external ownership
63
+ // — see the design's protectionOwner discussion for why these differ.
64
+ const effectiveChecks = config.protectionOwner === 'shipflow' ? config.requiredChecks ?? [] : plan.liveRequiredChecks ?? [];
65
+ const templateEntries = mutating.filter((e) => e.id.startsWith('template:'));
66
+ if (effectiveChecks.length === 0 && templateEntries.length > 0 && !force.includes('allow-no-checks')) {
67
+ for (const entry of templateEntries) {
68
+ skipped.push({
69
+ id: entry.id,
70
+ reason:
71
+ 'refusing to enable auto-merge with zero required checks — set requiredChecks or pass force: ["allow-no-checks"] to override',
72
+ });
73
+ }
74
+ }
75
+
76
+ for (const entry of mutating) {
77
+ if (skipped.some((s) => s.id === entry.id)) continue; // already skipped above (empty-checks refusal)
78
+
79
+ if (entry.handEditDetected && !force.includes(entry.id)) {
80
+ skipped.push({ id: entry.id, reason: `hand-edit detected — pass force: ["${entry.id}"] to override` });
81
+ continue;
82
+ }
83
+
84
+ if (dryRun) {
85
+ applied.push({ id: entry.id, description: entry.description, dryRun: true });
86
+ continue;
87
+ }
88
+
89
+ const result = applyOne(entry, { ownerRepo, repoPath, config });
90
+ if (result.ok) {
91
+ applied.push({ id: entry.id, description: entry.description });
92
+ if (entry.id.startsWith('template:') && entry.renderedHash) {
93
+ renderedTemplateHashes[entry.path] = entry.renderedHash;
94
+ }
95
+ } else if (result.tierGated) {
96
+ skipped.push({ id: entry.id, reason: result.error });
97
+ } else {
98
+ errors.push({ id: entry.id, message: result.error });
99
+ }
100
+ }
101
+
102
+ return { applied, skipped, errors, renderedTemplateHashes };
103
+ }
104
+
105
+ function applyOne(entry, { ownerRepo, repoPath, config }) {
106
+ if (entry.id === 'delete-branch-on-merge') {
107
+ const r = ghApiJson(`repos/${ownerRepo}`, ['-X', 'PATCH', '-f', `delete_branch_on_merge=${entry.desired}`]);
108
+ return r.ok ? { ok: true } : { ok: false, error: r.stderr };
109
+ }
110
+
111
+ if (entry.id === 'deletion-ruleset') {
112
+ const body = JSON.stringify({
113
+ name: 'shipflow-branch-deletion-protection',
114
+ target: 'branch',
115
+ enforcement: 'active',
116
+ conditions: {
117
+ ref_name: { include: [`refs/heads/${config.branches.dev}`, `refs/heads/${config.branches.main}`], exclude: [] },
118
+ },
119
+ rules: [{ type: 'deletion' }],
120
+ });
121
+ const r = spawnArgs('gh', ['api', `repos/${ownerRepo}/rulesets`, '-X', 'POST', '--input', '-'], { input: body });
122
+ if (r.status === 0) return { ok: true };
123
+ const { tierGated, reason } = classifyRulesetError(r.stderr);
124
+ return tierGated ? { ok: false, tierGated: true, error: reason } : { ok: false, error: r.stderr };
125
+ }
126
+
127
+ if (entry.id === 'release-pending-label') {
128
+ const r = ghApiJson(`repos/${ownerRepo}/labels`, [
129
+ '-f', 'name=release-pending',
130
+ '-f', 'color=0E8A16',
131
+ '-f', 'description=shipflow: this dev-to-main promotion is awaiting a release decision',
132
+ ]);
133
+ return r.ok ? { ok: true } : { ok: false, error: r.stderr };
134
+ }
135
+
136
+ if (entry.id.startsWith('template:')) {
137
+ const full = join(repoPath, entry.path);
138
+ try {
139
+ mkdirSync(dirname(full), { recursive: true });
140
+ writeFileSync(full, entry.content, 'utf8');
141
+ return { ok: true };
142
+ } catch (e) {
143
+ return { ok: false, error: String(e.message || e) };
144
+ }
145
+ }
146
+
147
+ return { ok: false, error: `unknown plan entry id: ${entry.id}` };
148
+ }
149
+
150
+ // --- Manual-gate ask-flow helpers -----------------------------------------
151
+ //
152
+ // These are invoked by a SEPARATE, later shipflow invocation than the one
153
+ // that ran applyPlan for the promotion — the design established (round 3)
154
+ // that the ask cannot happen at promotion time, since native async
155
+ // auto-merge completes with no live session attached. A subsequent
156
+ // interactive run enumerates every promotion PR still labeled
157
+ // release-pending, confirms each is actually MERGED (not just labeled —
158
+ // belt and suspenders against a labeling-job race), and only clears a
159
+ // label after its own release dispatch is confirmed successful.
160
+
161
+ export function listPendingReleasePromotions(ownerRepo, mainBranch) {
162
+ const r = ghApiJson(
163
+ `search/issues?q=${encodeURIComponent(`repo:${ownerRepo} is:pr is:merged base:${mainBranch} label:release-pending`)}`
164
+ );
165
+ if (!r.ok) return { ok: false, error: r.stderr, promotions: [] };
166
+ const items = r.data?.items ?? [];
167
+ return { ok: true, promotions: items.map((i) => ({ number: i.number, title: i.title, mergedAt: i.pull_request?.merged_at ?? null })) };
168
+ }
169
+
170
+ export function confirmPromotionMerged(ownerRepo, prNumber) {
171
+ const r = ghApiJson(`repos/${ownerRepo}/pulls/${prNumber}`);
172
+ if (!r.ok) return { ok: false, merged: false, error: r.stderr };
173
+ return { ok: true, merged: r.data?.merged === true, mergeCommitSha: r.data?.merge_commit_sha ?? null };
174
+ }
175
+
176
+ // Clears the release-pending label — MUST only be called after every
177
+ // changed skill's release dispatch for this promotion has been confirmed
178
+ // successful. A partial failure must leave the label in place so the whole
179
+ // set resurfaces on the next run (already-released skills re-dispatch as
180
+ // idempotent no-ops, per the design's Testing strategy).
181
+ export function clearReleasePendingLabel(ownerRepo, prNumber) {
182
+ const r = ghApiJson(`repos/${ownerRepo}/issues/${prNumber}/labels/release-pending`, ['-X', 'DELETE']);
183
+ return r.ok ? { ok: true } : { ok: false, error: r.stderr };
184
+ }
185
+
186
+ export function dispatchReleaseWorkflow(ownerRepo, skillWorkflowFile, ref) {
187
+ const r = spawnArgs('gh', ['workflow', 'run', skillWorkflowFile, '--ref', ref]);
188
+ return r.status === 0 ? { ok: true } : { ok: false, error: r.stderr };
189
+ }
190
+
191
+ // A one-time bootstrap action, not part of the steady-state plan/apply diff
192
+ // model — renaming a repo's default branch affects every collaborator and
193
+ // open PR, so it's a distinct, explicitly-confirmed CLI command rather than
194
+ // a plan entry. GitHub's rename endpoint natively moves the default-branch
195
+ // pointer and retargets open PRs when the renamed branch is the current
196
+ // default; no extra shipflow-side logic is needed for that part.
197
+ export function renameDefaultBranch(ownerRepo, fromBranch, toBranch) {
198
+ const r = spawnArgs('gh', [
199
+ 'api', '-X', 'POST', `repos/${ownerRepo}/branches/${fromBranch}/rename`,
200
+ '-f', `new_name=${toBranch}`,
201
+ ]);
202
+ return r.status === 0 ? { ok: true } : { ok: false, error: r.stderr };
203
+ }
package/lib/detect.mjs ADDED
@@ -0,0 +1,235 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { spawnArgs, ghApiJson, git, sha256 } from './gh.mjs';
4
+
5
+ const TEMPLATE_RELATIVE_PATH = '.github/workflows/dev-to-main-automerge.yml';
6
+ const CONFIG_RELATIVE_PATH = '.github/shipflow.json';
7
+
8
+ // Files/content patterns that indicate branch protection is already managed
9
+ // as code elsewhere in the repo — see the design's "Rulesets vs. an existing
10
+ // settings-as-code source of truth" discussion. Matching one of these is
11
+ // what makes protectionOwner classify as "external" instead of falling
12
+ // through to the ambiguous (protection-exists-but-no-artifact) prompt case.
13
+ const SETTINGS_AS_CODE_NAME_RE = /repo-settings\.(sh|js|mjs|py)$/i;
14
+ const SETTINGS_AS_CODE_CONTENT_RE =
15
+ /github_branch_protection|github_repository_ruleset|branches\/[\w-]+\/protection/;
16
+
17
+ export function resolveOwnerRepo(repoPath) {
18
+ const r = git(['remote', 'get-url', 'origin'], { cwd: repoPath });
19
+ if (r.status !== 0) return null;
20
+ const m = r.stdout.match(/github\.com[:/]([\w.-]+)\/([\w.-]+?)(\.git)?$/);
21
+ return m ? `${m[1]}/${m[2]}` : null;
22
+ }
23
+
24
+ export function listTrackedFiles(repoPath) {
25
+ const r = git(['ls-files'], { cwd: repoPath });
26
+ return r.status === 0 ? r.stdout.split('\n').filter(Boolean) : [];
27
+ }
28
+
29
+ // Finds a settings-as-code artifact by name pattern first (cheap), then by
30
+ // content pattern for anything under .github/ or a common IaC directory
31
+ // (avoids grepping the whole tree — bounded to files a settings-as-code
32
+ // script/config plausibly lives in).
33
+ export function findSettingsAsCodeArtifact(repoPath, trackedFiles) {
34
+ const byName = trackedFiles.find((f) => SETTINGS_AS_CODE_NAME_RE.test(f));
35
+ if (byName) return byName;
36
+
37
+ const candidates = trackedFiles.filter(
38
+ (f) => f.startsWith('.github/') || /\.(tf|tfvars)$/i.test(f) || /pulumi/i.test(f)
39
+ );
40
+ for (const f of candidates) {
41
+ const full = join(repoPath, f);
42
+ if (!existsSync(full)) continue;
43
+ let content;
44
+ try {
45
+ content = readFileSync(full, 'utf8');
46
+ } catch {
47
+ continue;
48
+ }
49
+ if (SETTINGS_AS_CODE_CONTENT_RE.test(content)) return f;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ // A job that never runs on a pull_request can never satisfy a required
55
+ // status check — picking one as requiredChecks would block every future
56
+ // merge forever. This is a text-based heuristic (matching the rest of this
57
+ // file's no-YAML-parser style), not a full YAML parse: good enough to catch
58
+ // the common schedule/workflow_dispatch-only case.
59
+ const PULL_REQUEST_TRIGGER_RE = /pull_request(_target)?\b/;
60
+
61
+ export function listWorkflowJobNames(repoPath, trackedFiles) {
62
+ const names = new Set();
63
+ const jobNameRe = /^\s{2}([\w.-]+):\s*$/;
64
+ for (const f of trackedFiles.filter((f) => /^\.github\/workflows\/.*\.ya?ml$/.test(f))) {
65
+ const full = join(repoPath, f);
66
+ if (!existsSync(full)) continue;
67
+ let content;
68
+ try {
69
+ content = readFileSync(full, 'utf8');
70
+ } catch {
71
+ continue;
72
+ }
73
+ const lines = content.split('\n');
74
+ const jobsLineIdx = lines.findIndex((l) => l.trim() === 'jobs:');
75
+ if (jobsLineIdx === -1) continue;
76
+ const triggerSection = lines.slice(0, jobsLineIdx).join('\n');
77
+ if (!PULL_REQUEST_TRIGGER_RE.test(triggerSection)) continue; // never PR-triggered
78
+ for (const line of lines.slice(jobsLineIdx + 1)) {
79
+ if (line.trim() !== '' && /^\S/.test(line)) break; // dedented out of the jobs: block
80
+ const m = line.match(jobNameRe);
81
+ if (m) names.add(m[1]);
82
+ }
83
+ }
84
+ return [...names].sort();
85
+ }
86
+
87
+ export function readTemplateFileHash(repoPath) {
88
+ const full = join(repoPath, TEMPLATE_RELATIVE_PATH);
89
+ if (!existsSync(full)) return { exists: false, sha256: null };
90
+ const content = readFileSync(full, 'utf8');
91
+ return { exists: true, sha256: sha256(content) };
92
+ }
93
+
94
+ export function readExistingConfig(repoPath) {
95
+ const full = join(repoPath, CONFIG_RELATIVE_PATH);
96
+ if (!existsSync(full)) return null;
97
+ try {
98
+ return JSON.parse(readFileSync(full, 'utf8'));
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ export function fetchBranchProtection(ownerRepo, branch) {
105
+ const r = ghApiJson(`repos/${ownerRepo}/branches/${branch}/protection`);
106
+ if (!r.ok) return null;
107
+ const checks = r.data?.required_status_checks?.contexts ?? [];
108
+ return { requiredChecks: checks, raw: r.data };
109
+ }
110
+
111
+ export function fetchRulesets(ownerRepo) {
112
+ const r = ghApiJson(`repos/${ownerRepo}/rulesets`);
113
+ if (!r.ok) return [];
114
+ const list = Array.isArray(r.data) ? r.data : [];
115
+ return list.map((rs) => ({
116
+ id: rs.id,
117
+ name: rs.name,
118
+ target: rs.target,
119
+ enforcement: rs.enforcement,
120
+ }));
121
+ }
122
+
123
+ // Rulesets' required_status_checks live under a rule of type
124
+ // "required_status_checks", not on the list-rulesets summary response — a
125
+ // second call per ruleset is required to get the parameter detail.
126
+ export function fetchRulesetRequiredChecks(ownerRepo, rulesetId) {
127
+ const r = ghApiJson(`repos/${ownerRepo}/rulesets/${rulesetId}`);
128
+ if (!r.ok) return [];
129
+ const rules = r.data?.rules ?? [];
130
+ const rule = rules.find((x) => x.type === 'required_status_checks');
131
+ const checks = rule?.parameters?.required_status_checks ?? [];
132
+ return checks.map((c) => c.context).filter(Boolean);
133
+ }
134
+
135
+ export function checkSecretPresent(ownerRepo, secretName) {
136
+ const r = ghApiJson(`repos/${ownerRepo}/actions/secrets/${secretName}`);
137
+ return r.ok;
138
+ }
139
+
140
+ export function fetchRepoSettings(ownerRepo) {
141
+ const r = ghApiJson(`repos/${ownerRepo}`);
142
+ if (!r.ok) return { deleteBranchOnMerge: null, defaultBranch: null };
143
+ return {
144
+ deleteBranchOnMerge: r.data?.delete_branch_on_merge ?? false,
145
+ defaultBranch: r.data?.default_branch ?? null,
146
+ };
147
+ }
148
+
149
+ export function checkLabelExists(ownerRepo, labelName) {
150
+ const r = ghApiJson(`repos/${ownerRepo}/labels/${encodeURIComponent(labelName)}`);
151
+ return r.ok;
152
+ }
153
+
154
+ // branches: { main: string, dev: string } — the candidate names to inspect
155
+ // protection for. Caller (SKILL.md's setup wizard, or a re-run reading
156
+ // existingConfig) supplies these; detect.mjs does not guess branch names on
157
+ // its own, keeping it a pure "read what I'm told to look at" function.
158
+ export function detectRepoState(repoPath, { branches = { main: 'main', dev: 'dev' }, releaseCredentialName = null } = {}) {
159
+ const ownerRepo = resolveOwnerRepo(repoPath);
160
+ const trackedFiles = listTrackedFiles(repoPath);
161
+
162
+ const branchList = git(['branch', '-a', '--format=%(refname:short)'], { cwd: repoPath });
163
+ const localBranches = branchList.status === 0 ? branchList.stdout.split('\n').filter(Boolean) : [];
164
+
165
+ const workflowJobNames = listWorkflowJobNames(repoPath, trackedFiles);
166
+ const templateFiles = { [TEMPLATE_RELATIVE_PATH]: readTemplateFileHash(repoPath) };
167
+ const settingsAsCodeArtifact = findSettingsAsCodeArtifact(repoPath, trackedFiles);
168
+
169
+ const protection = ownerRepo
170
+ ? {
171
+ [branches.main]: fetchBranchProtection(ownerRepo, branches.main),
172
+ [branches.dev]: fetchBranchProtection(ownerRepo, branches.dev),
173
+ }
174
+ : {};
175
+
176
+ const rulesetsRaw = ownerRepo ? fetchRulesets(ownerRepo) : [];
177
+ const rulesets = rulesetsRaw.map((rs) => ({
178
+ ...rs,
179
+ requiredChecks: ownerRepo ? fetchRulesetRequiredChecks(ownerRepo, rs.id) : [],
180
+ }));
181
+
182
+ const existingConfig = readExistingConfig(repoPath);
183
+ const releaseCredentialPresent =
184
+ ownerRepo && releaseCredentialName ? checkSecretPresent(ownerRepo, releaseCredentialName) : null;
185
+ const repoSettings = ownerRepo ? fetchRepoSettings(ownerRepo) : { deleteBranchOnMerge: null, defaultBranch: null };
186
+ const releasePendingLabelExists = ownerRepo ? checkLabelExists(ownerRepo, 'release-pending') : null;
187
+
188
+ const stateHash = sha256(
189
+ JSON.stringify({
190
+ branches: localBranches.sort(),
191
+ protectionMain: protection[branches.main]?.requiredChecks ?? null,
192
+ protectionDev: protection[branches.dev]?.requiredChecks ?? null,
193
+ rulesetIds: rulesets.map((r) => r.id).sort(),
194
+ workflowJobNames,
195
+ deleteBranchOnMerge: repoSettings.deleteBranchOnMerge,
196
+ releasePendingLabelExists,
197
+ })
198
+ );
199
+
200
+ return {
201
+ ownerRepo,
202
+ branches: { configured: branches, local: localBranches },
203
+ workflows: { jobNames: workflowJobNames },
204
+ templateFiles,
205
+ protection,
206
+ rulesets,
207
+ settingsAsCodeArtifact,
208
+ existingConfig,
209
+ releaseCredentialPresent,
210
+ repoSettings,
211
+ releasePendingLabelExists,
212
+ stateHash,
213
+ };
214
+ }
215
+
216
+ // Three-way protectionOwner classification (see design, "Rulesets vs. an
217
+ // existing settings-as-code source of truth"). Returns:
218
+ // "external" — a settings-as-code artifact was found; that mechanism
219
+ // owns protection, shipflow must not install a competing one
220
+ // "shipflow" — no protection exists at all; shipflow becomes the owner
221
+ // "ambiguous" — protection exists but no artifact was found (e.g.
222
+ // hand-configured via the UI) — caller MUST prompt the user
223
+ // rather than silently pick either value (this is the
224
+ // round-2 false-positive fix: silently defaulting here left
225
+ // protection un-audited AND un-managed by anyone)
226
+ export function classifyProtectionOwner(repoState) {
227
+ const { settingsAsCodeArtifact, protection } = repoState;
228
+ if (settingsAsCodeArtifact) return 'external';
229
+
230
+ const hasProtection = Object.values(protection).some(
231
+ (p) => p && (p.requiredChecks?.length > 0 || p.raw)
232
+ );
233
+ if (!hasProtection) return 'shipflow';
234
+ return 'ambiguous';
235
+ }
package/lib/gh.mjs ADDED
@@ -0,0 +1,40 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+
4
+ // argv-style invocation; no shell, so user-supplied args cannot inject.
5
+ // Returns { status, stdout, stderr } so callers can distinguish failure modes
6
+ // (e.g. a 404 from gh vs. a network error) instead of collapsing to a boolean.
7
+ export function spawnArgs(cmd, args, opts = {}) {
8
+ try {
9
+ const r = spawnSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', ...opts });
10
+ return {
11
+ status: r.status ?? 1,
12
+ stdout: (r.stdout || '').trim(),
13
+ stderr: (r.stderr || '').trim(),
14
+ };
15
+ } catch (e) {
16
+ return { status: 1, stdout: '', stderr: String((e && e.message) || e) };
17
+ }
18
+ }
19
+
20
+ export function ghApi(path, args = []) {
21
+ return spawnArgs('gh', ['api', path, ...args]);
22
+ }
23
+
24
+ export function ghApiJson(path, args = []) {
25
+ const r = ghApi(path, args);
26
+ if (r.status !== 0) return { ok: false, status: r.status, stderr: r.stderr };
27
+ try {
28
+ return { ok: true, data: JSON.parse(r.stdout) };
29
+ } catch (e) {
30
+ return { ok: false, status: r.status, stderr: `unparseable JSON: ${e.message}` };
31
+ }
32
+ }
33
+
34
+ export function git(args, opts = {}) {
35
+ return spawnArgs('git', args, opts);
36
+ }
37
+
38
+ export function sha256(content) {
39
+ return createHash('sha256').update(content, 'utf8').digest('hex');
40
+ }
package/lib/plan.mjs ADDED
@@ -0,0 +1,112 @@
1
+ import { renderTemplate, mergeMethodToFlag } from './render.mjs';
2
+ import { sha256 } from './gh.mjs';
3
+
4
+ const TEMPLATE_PATH = '.github/workflows/dev-to-main-automerge.yml';
5
+
6
+ // Pure function — no I/O, no gh/git calls. Diffs repoState (what detect.mjs
7
+ // observed) against config (what the user wants) into a Plan the caller
8
+ // shows to the user before any mutation happens.
9
+ export function computePlan(repoState, config, templateSource) {
10
+ const creates = [];
11
+ const updates = [];
12
+ const noops = [];
13
+
14
+ // 1. delete_branch_on_merge repo setting
15
+ const wantDeleteOnMerge = config.branchCleanup?.deleteOnMerge ?? true;
16
+ const haveDeleteOnMerge = repoState.repoSettings?.deleteBranchOnMerge;
17
+ if (haveDeleteOnMerge === wantDeleteOnMerge) {
18
+ noops.push({ id: 'delete-branch-on-merge', description: 'delete_branch_on_merge already set correctly' });
19
+ } else {
20
+ updates.push({
21
+ id: 'delete-branch-on-merge',
22
+ description: `set delete_branch_on_merge to ${wantDeleteOnMerge}`,
23
+ desired: wantDeleteOnMerge,
24
+ });
25
+ }
26
+
27
+ // 2. deletion-protecting ruleset — only shipflow's job when it owns protection.
28
+ // Coarse check (v1 scope): whether *any* ruleset exists at all, not whether
29
+ // it specifically restricts deletion on the configured branches — apply.mjs
30
+ // re-checks live state immediately before creating (idempotency guarantee),
31
+ // so an imprecise plan preview here cannot cause a wrong mutation, only a
32
+ // possibly-stale preview label.
33
+ if (config.protectionOwner === 'shipflow') {
34
+ if ((repoState.rulesets ?? []).length > 0) {
35
+ noops.push({ id: 'deletion-ruleset', description: 'a ruleset already exists (coarse check — see plan.mjs comment)' });
36
+ } else {
37
+ creates.push({ id: 'deletion-ruleset', description: `create a ruleset protecting ${config.branches.dev}/${config.branches.main} from deletion` });
38
+ }
39
+ } else {
40
+ noops.push({ id: 'deletion-ruleset', description: `protectionOwner is "${config.protectionOwner}" — deferring to existing mechanism, shipflow installs nothing` });
41
+ }
42
+
43
+ // 3. dev-to-main-automerge.yml — content-hash diff against a fresh render,
44
+ // with hand-edit detection against the last hash shipflow itself recorded.
45
+ const templateEntry = computeTemplatePlanEntry(repoState, config, templateSource);
46
+ if (templateEntry.kind === 'noop') noops.push(templateEntry);
47
+ else if (templateEntry.kind === 'create') creates.push(templateEntry);
48
+ else updates.push(templateEntry);
49
+
50
+ // 4. release-pending label — unconditional across every release.mode
51
+ // (round-6 fix: the labeling job in the template above ships regardless
52
+ // of mode, so the label must exist regardless of mode too).
53
+ if (repoState.releasePendingLabelExists) {
54
+ noops.push({ id: 'release-pending-label', description: 'release-pending label already exists' });
55
+ } else {
56
+ creates.push({ id: 'release-pending-label', description: 'create the release-pending label' });
57
+ }
58
+
59
+ // liveRequiredChecks: union of classic branch-protection required checks
60
+ // (on the configured main branch) and every fetched ruleset's required
61
+ // checks. v1 scope note: rulesets are unioned without filtering by which
62
+ // branch they target (no ref-pattern matching implemented yet) — a
63
+ // documented simplification, not a silent gap.
64
+ const classicChecks = repoState.protection?.[config.branches.main]?.requiredChecks ?? [];
65
+ const rulesetChecks = (repoState.rulesets ?? []).flatMap((rs) => rs.requiredChecks ?? []);
66
+ const liveRequiredChecks = [...new Set([...classicChecks, ...rulesetChecks])].sort();
67
+
68
+ return {
69
+ creates,
70
+ updates,
71
+ noops,
72
+ sourceStateHash: repoState.stateHash,
73
+ liveRequiredChecks,
74
+ };
75
+ }
76
+
77
+ function computeTemplatePlanEntry(repoState, config, templateSource) {
78
+ const params = {
79
+ devBranch: config.branches.dev,
80
+ mainBranch: config.branches.main,
81
+ mergeFlag: mergeMethodToFlag(config.mergeMethod?.devToMainMethod),
82
+ };
83
+ const renderedContent = renderTemplate(templateSource, params);
84
+ const freshHash = sha256(renderedContent);
85
+ const onDisk = repoState.templateFiles?.[TEMPLATE_PATH];
86
+ const lastRenderedHash = config.renderedTemplateHashes?.[TEMPLATE_PATH] ?? null;
87
+
88
+ if (!onDisk || !onDisk.exists) {
89
+ return { id: 'template:' + TEMPLATE_PATH, kind: 'create', path: TEMPLATE_PATH, description: `write ${TEMPLATE_PATH}`, renderedHash: freshHash, content: renderedContent };
90
+ }
91
+ if (onDisk.sha256 === freshHash) {
92
+ return { id: 'template:' + TEMPLATE_PATH, kind: 'noop', path: TEMPLATE_PATH, description: `${TEMPLATE_PATH} already matches config` };
93
+ }
94
+ if (onDisk.sha256 === lastRenderedHash) {
95
+ // On-disk content matches what shipflow itself last rendered, but the
96
+ // config has changed since — a legitimate re-render, not a hand-edit.
97
+ return { id: 'template:' + TEMPLATE_PATH, kind: 'update', path: TEMPLATE_PATH, description: `re-render ${TEMPLATE_PATH} (config changed)`, renderedHash: freshHash, content: renderedContent, handEditDetected: false };
98
+ }
99
+ // On-disk content matches neither the fresh render nor our last recorded
100
+ // render — someone hand-edited it (or it was never rendered by shipflow).
101
+ // Flagged, not silently overwritten; apply.mjs blocks this entry unless
102
+ // the caller passes an explicit force override naming this entry's id.
103
+ return {
104
+ id: 'template:' + TEMPLATE_PATH,
105
+ kind: 'update',
106
+ path: TEMPLATE_PATH,
107
+ description: `${TEMPLATE_PATH} was hand-edited — blocked pending --force`,
108
+ renderedHash: freshHash,
109
+ content: renderedContent,
110
+ handEditDetected: true,
111
+ };
112
+ }
package/lib/render.mjs ADDED
@@ -0,0 +1,55 @@
1
+ // Pure template-substitution. No I/O, no gh/git calls — used by both plan.mjs
2
+ // (to compute a content-hash for the "would render" side of the hand-edit
3
+ // diff) and apply.mjs (to actually write the file). Keeping it here, not
4
+ // duplicated between the two, is what makes plan.mjs's hash comparison and
5
+ // apply.mjs's write guaranteed to agree.
6
+ //
7
+ // Required-check names are deliberately NOT a substitution parameter here.
8
+ // Native `gh pr merge --auto` takes no check-name input — GitHub gates the
9
+ // eventual async merge on whatever live branch protection/rulesets mark
10
+ // required, not on anything baked into this workflow file. Baking check
11
+ // names in would be a dead parameter at best and an invitation to
12
+ // reconstruct the bespoke-polling mechanism (rejected — see the design's
13
+ // Fatal #1 discussion) at worst.
14
+
15
+ const TOKEN_RE = /\{\{(\w+)\}\}/g;
16
+
17
+ // params: { devBranch, mainBranch, mergeFlag }
18
+ // mergeFlag is one of "--merge" | "--squash" | "--rebase", derived from
19
+ // config.mergeMethod.devToMainMethod by the caller (not this function —
20
+ // mapping method name -> gh flag is a config-schema concern, kept out of
21
+ // the pure-substitution layer so this function has zero knowledge of the
22
+ // config shape, only of the template's token names).
23
+ export function renderTemplate(templateSource, params) {
24
+ const missing = [];
25
+ const rendered = templateSource.replace(TOKEN_RE, (_, name) => {
26
+ const key = TOKEN_TO_PARAM[name];
27
+ if (!key || !(key in params)) {
28
+ missing.push(name);
29
+ return `{{${name}}}`;
30
+ }
31
+ return String(params[key]);
32
+ });
33
+ if (missing.length > 0) {
34
+ throw new Error(`renderTemplate: missing param(s) for token(s): ${missing.join(', ')}`);
35
+ }
36
+ return rendered;
37
+ }
38
+
39
+ const TOKEN_TO_PARAM = Object.freeze({
40
+ DEV_BRANCH: 'devBranch',
41
+ MAIN_BRANCH: 'mainBranch',
42
+ MERGE_FLAG: 'mergeFlag',
43
+ });
44
+
45
+ export function mergeMethodToFlag(devToMainMethod) {
46
+ switch (devToMainMethod) {
47
+ case 'squash':
48
+ return '--squash';
49
+ case 'rebase':
50
+ return '--rebase';
51
+ case 'merge':
52
+ default:
53
+ return '--merge';
54
+ }
55
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@natjswenson/shipflow",
3
+ "version": "0.2.0",
4
+ "description": "Scaffold a configurable dev/main branching, auto-merge, branch-cleanup, and release-tagging workflow into any repo",
5
+ "license": "MIT",
6
+ "author": "Nate Swenson",
7
+ "homepage": "https://github.com/natejswenson/shipflow",
8
+ "repository": { "type": "git", "url": "git+https://github.com/natejswenson/shipflow.git" },
9
+ "bugs": { "url": "https://github.com/natejswenson/shipflow/issues" },
10
+ "keywords": ["shipflow", "claude-code", "claude-skill", "git", "branching", "release-automation", "ci-cd"],
11
+ "type": "module",
12
+ "bin": { "shipflow": "bin/shipflow.js" },
13
+ "files": [
14
+ "bin/",
15
+ "lib/",
16
+ "templates/",
17
+ "skill-invariants.json",
18
+ "SKILL.md",
19
+ "CHANGELOG.md",
20
+ "config.example.json",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "engines": { "node": ">=18" },
25
+ "scripts": {
26
+ "test": "node --test \"tests/**/*.test.mjs\"",
27
+ "audit": "npm audit --audit-level=moderate"
28
+ },
29
+ "dependencies": {}
30
+ }
@@ -0,0 +1,61 @@
1
+ {
2
+ "comment": "Prose guardrails in SKILL.md that must survive edits. Each pattern is a case-insensitive regex tested against the full SKILL.md text by tests/skill_contract.test.mjs. If you intentionally change one, update it here in the same commit and say why in the PR.",
3
+ "prose": [
4
+ {
5
+ "id": "never-mutates-directly",
6
+ "pattern": "never mutates repo state directly",
7
+ "rationale": "The deterministic/nondeterministic split is load-bearing: the agent decides, the CLI does. Losing this line reopens ad-hoc direct gh/git calls from the agent."
8
+ },
9
+ {
10
+ "id": "always-confirm-before-apply",
11
+ "pattern": "Wait for explicit confirmation before proceeding",
12
+ "rationale": "A plan must be shown and confirmed before any real apply — this is the safety property the whole design rests on."
13
+ },
14
+ {
15
+ "id": "ambiguous-protection-owner-prompt",
16
+ "pattern": "Do NOT silently pick either value",
17
+ "rationale": "The round-2 false-positive fix: ambiguous branch protection must be explicitly disambiguated with the user, never silently defaulted, or protection ends up un-audited and un-managed by anyone."
18
+ },
19
+ {
20
+ "id": "empty-required-checks-refusal",
21
+ "pattern": "refuses to wire up auto-merge with zero required checks",
22
+ "rationale": "The fail-open guard: an empty requiredChecks list must never silently enable an unprotected auto-merge."
23
+ },
24
+ {
25
+ "id": "hand-edit-never-silent-force",
26
+ "pattern": "[Nn]ever silently pass `--force`",
27
+ "rationale": "A hand-edited template file must always be surfaced and explicitly confirmed per entry, never auto-overridden."
28
+ },
29
+ {
30
+ "id": "toctou-reredetect",
31
+ "pattern": "re-run the plan step, don't retry the same `--expect-state-hash`",
32
+ "rationale": "A TOCTOU abort means state drifted — retrying the same stale hash would just abort again or, worse, apply against stale assumptions if the guard were bypassed."
33
+ },
34
+ {
35
+ "id": "auto-mode-refuses",
36
+ "pattern": "refuses to run.{0,40}against a config with `release\\.mode: \"auto\"`",
37
+ "rationale": "Auto mode is unimplemented in this version; it must fail loudly, never silently no-op, so a user's config request isn't silently ignored."
38
+ },
39
+ {
40
+ "id": "argv-style-no-shell",
41
+ "pattern": "argv-style.{0,60}no shell",
42
+ "rationale": "Command-injection defense: every gh/git invocation must stay argv-style, never a shell string built from user input."
43
+ },
44
+ {
45
+ "id": "config-path-fixed",
46
+ "pattern": "[Nn]ever write shipflow's config anywhere other than `\\.github/shipflow\\.json`",
47
+ "rationale": "A committed, repo-specific policy file must live in the target repo's own tree at a fixed, predictable path — never inside the shipped skill package, never at an arbitrary location."
48
+ },
49
+ {
50
+ "id": "first-run-interview-mandatory",
51
+ "pattern": "even when detected values already look correct",
52
+ "rationale": "First live end-to-end run (2026-07-14, against natejswenson/1.00s) showed an orchestrating agent can silently skip the confirmation turn when detected values look right. This line makes the interview checkpoint explicit and unskippable, not just implied by 'confirm with the user.'"
53
+ },
54
+ {
55
+ "id": "ci-scaffold-never-overwrites",
56
+ "pattern": "[Nn]ever silently overwrite an existing workflow file",
57
+ "rationale": "Agent-driven CI scaffolding (added after 1.00s had no pull_request-triggered CI at all) must never clobber a workflow file that's already there — same confirm-before-write discipline as every other mutation in this skill, applied to a step the CLI itself never touches."
58
+ }
59
+ ],
60
+ "cli_commands_referenced": ["detect", "plan", "apply", "releases", "release-dispatch", "rename-default-branch"]
61
+ }
@@ -0,0 +1,52 @@
1
+ name: auto-merge {{DEV_BRANCH}} to {{MAIN_BRANCH}}
2
+
3
+ # Rendered by shipflow's apply.mjs from this template. Do not hand-edit
4
+ # without also updating .github/shipflow.json's renderedTemplateHashes entry
5
+ # for this file, or shipflow's next apply will refuse to overwrite it
6
+ # (handEditDetected) until an explicit --force is passed. Commit both files
7
+ # together in the same commit.
8
+
9
+ on:
10
+ pull_request:
11
+ types: [opened, reopened, synchronize, closed]
12
+ branches: [{{MAIN_BRANCH}}]
13
+
14
+ permissions:
15
+ contents: write
16
+ pull-requests: write
17
+
18
+ jobs:
19
+ # Enables native GitHub auto-merge on open/reopen/synchronize — this job
20
+ # does NOT wait for checks itself; it turns on auto-merge and exits. The
21
+ # actual merge happens asynchronously, later, whenever GitHub's own
22
+ # required-checks gate is satisfied (see the design's discussion of why a
23
+ # bespoke polling/blocking job was rejected).
24
+ auto-merge:
25
+ if: >-
26
+ github.event.action != 'closed' &&
27
+ github.event.pull_request.head.ref == '{{DEV_BRANCH}}'
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - name: Enable auto-merge
31
+ run: gh pr merge --auto {{MERGE_FLAG}} "${{ github.event.pull_request.number }}"
32
+ env:
33
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34
+
35
+ # Fires once, when the promotion PR actually merges (a separate event from
36
+ # the job above, which only *enables* auto-merge). Applies a durable
37
+ # release-pending label so a later, disconnected shipflow invocation can
38
+ # find this promotion and ask about a release — this repo's actual merge
39
+ # completion has no live Claude session attached to react to it directly.
40
+ label-release-pending:
41
+ if: >-
42
+ github.event.action == 'closed' &&
43
+ github.event.pull_request.merged == true &&
44
+ github.event.pull_request.head.ref == '{{DEV_BRANCH}}'
45
+ runs-on: ubuntu-latest
46
+ permissions:
47
+ pull-requests: write
48
+ steps:
49
+ - name: Apply release-pending label
50
+ run: gh pr edit "${{ github.event.pull_request.number }}" --add-label release-pending
51
+ env:
52
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}