@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 +120 -0
- package/bin/shipflow.js +290 -0
- package/config.example.json +23 -0
- package/lib/apply.mjs +203 -0
- package/lib/detect.mjs +235 -0
- package/lib/gh.mjs +40 -0
- package/lib/plan.mjs +112 -0
- package/lib/render.mjs +55 -0
- package/package.json +30 -0
- package/skill-invariants.json +61 -0
- package/templates/dev-to-main-automerge.yml.tmpl +52 -0
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.
|
package/bin/shipflow.js
ADDED
|
@@ -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 }}
|