@natjswenson/shipflow 0.2.5 → 0.3.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 CHANGED
@@ -7,8 +7,17 @@ user_invocable: true
7
7
  # /shipflow — branching + release-automation setup
8
8
 
9
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.
10
+ `npx -y @natjswenson/shipflow@latest <command>` **always with the explicit
11
+ `@latest` tag, never bare `@natjswenson/shipflow`.** Without a version/tag,
12
+ `npx` prefers an already-resolvable install on `PATH` (e.g. a stale global
13
+ `npm install -g @natjswenson/shipflow` from a prior manual test) over
14
+ fetching the current version from the registry, and does so silently with
15
+ no warning. This isn't hypothetical: it happened in this exact repo — the
16
+ same command with the `@latest` tag omitted silently ran a stale global
17
+ 0.2.0 install (missing every fix through 0.2.5, including the Critical
18
+ template-injection fix), while `npx -y @natjswenson/shipflow@latest -v`
19
+ correctly resolved 0.2.5. Every command prints JSON to stdout — parse it,
20
+ don't try to re-derive what it computed.
12
21
 
13
22
  **This skill never mutates repo state directly.** Every mutating action goes
14
23
  through `shipflow apply`, and the computed plan is always shown to the user
@@ -30,53 +39,62 @@ user; the CLI is the only thing that *does*.
30
39
 
31
40
  1. **Detect.** Run:
32
41
  ```
33
- npx -y @natjswenson/shipflow detect --repo <path> --main main --dev dev
42
+ npx -y @natjswenson/shipflow@latest detect --repo <path> --main main --dev dev
34
43
  ```
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 explicitlydo not silently assume either path:
44
+ (Use whatever branch names the user has, or `main`/`dev` as a starting guess — you'll confirm them next.) This prints a `RepoState` plus a `protectionOwnerClassification` of `"external"`, `"shipflow"`, or `"ambiguous"`, and now also a `rankedPatterns` array — every pattern's `{id, score, evidence}`, sorted descending by score.
45
+
46
+ 2. **Resolve `workflowPattern` before anything else** — a `github-flow` repo never asks about a `dev` branch name at all, so this has to happen before step 3 below. Classify `rankedPatterns` per these rules: **confident** if the top score is `>= 0.7` AND the gap over the second-place score is `> 0.3`; **greenfield** if the top score is `< 0.4`; **ambiguous** otherwise (the residual caseno separate condition to satisfy).
47
+ - **Confident:** state what was detected and why (the top entry's `evidence` array) — *"I detected this repo is using **`<pattern-id>`** because: `<evidence bullets>`. I'll set `workflowPattern` to this — confirm before I proceed, or tell me if you'd rather pick a different pattern."* This is still a confirm-before-write checkpoint per this section's mandatory-interview rule — a confident autodetect is not a substitute for the user's explicit confirmation.
48
+ - **Ambiguous or greenfield:** present all 3 patterns and ask the user to choose. Do not silently pick one:
49
+ - `dev-main-promotion` — long-lived `dev` + `main`; a promotion PR auto-merges `dev` into `main`.
50
+ - `github-flow` — single long-lived `main`; every PR merges (and auto-merges) directly to `main`. Suggest this as the lightweight default for a **greenfield** repo specifically, without auto-picking it.
51
+ - `gitflow` — `develop` + `main` + transient `release/*`/`hotfix/*` branches, for software that maintains multiple released versions concurrently.
52
+ - Once resolved, proceed with only the interview fields that pattern's config actually uses — skip asking about a `dev` branch name under `github-flow`, for instance.
53
+ - If `workflowPattern` is `gitflow`, additionally ask for `releaseBranchPrefix`/`hotfixBranchPrefix` (defaulting to `release/`/`hotfix/` if the user has no preference) — recorded under `patternConfig.gitflow` in the config.
54
+
55
+ 3. **Resolve a default-branch mismatch, if any.** Compare `repoState.repoSettings.defaultBranch` (the repo's actual GitHub default branch) to the `--main` name used in step 1. If they match, skip to step 4. If they differ (e.g. the repo's default is `master`), ask the user explicitly — do not silently assume either path:
38
56
  - **Map onto the existing default branch** — set the config's `branches.main` to the detected default branch name and continue with the rest of setup treating that as "main." No mutating calls needed; `branches.main` is fully configurable.
39
57
  - **Switch the repo's default branch to `main`** — flag this as a bigger, more disruptive action than the rest of setup (it affects every collaborator and every open PR), get a distinct explicit confirmation for it specifically, separate from the general setup go-ahead, then run:
40
58
  ```
41
- npx -y @natjswenson/shipflow rename-default-branch --repo <path> --branch <old-default> --to main
59
+ npx -y @natjswenson/shipflow@latest rename-default-branch --repo <path> --branch <old-default> --to main
42
60
  ```
43
61
  GitHub natively retargets the default-branch pointer and open PRs' base ref. On success, tell the user their own local checkout still points at the old name and needs `git fetch origin && git checkout main` to follow, then re-run step 1's `detect` (repo state changed) before continuing.
44
62
 
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.
63
+ 4. **Confirm branch names and required checks with the user.** Show `workflows.jobNames` from the detect output as candidate `requiredChecks` (this list is already filtered to jobs from workflows that actually trigger on `pull_request` — a job that only runs on `schedule`/`workflow_dispatch` can never satisfy a required check, so it's never offered as a candidate) and let the user confirm/edit the list. **An empty `requiredChecks` list is a fail-open state, not a valid steady state** — `shipflow apply` will hard-refuse to enable auto-merge with zero required checks (see Error handling below). Don't let the user skip this without understanding that consequence.
46
64
 
47
65
  **If the candidate list is empty, offer to scaffold a starter CI workflow yourself** — this is a judgment call for the agent, not something shipflow's CLI does (the CLI stays free of per-language/build-tool logic). Investigate the repo directly (`package.json`, `Cargo.toml`, `project.yml`/`.xcodeproj`, `go.mod`, `pyproject.toml`, or whatever's actually there) and draft a minimal, conservative `pull_request`-triggered build+test workflow. **Never silently overwrite an existing workflow file.** Present the drafted YAML to the user and wait for explicit confirmation before writing it — the same confirm-before-write pattern as everything else in this skill. Say plainly that this is a best-effort starting point inferred from repo structure, not a guarantee it's green on the first run — a required check that never passes blocks every future merge, so the user should watch it actually run successfully before relying on it as a required check. Once it exists, re-run step 1's `detect` (repo state changed) and continue this step with the new job name as a real candidate.
48
66
 
49
- 4. **Resolve `protectionOwner`:**
67
+ 5. **Resolve `protectionOwner`:**
50
68
  - `"external"` → tell the user which settings-as-code artifact was found (`settingsAsCodeArtifact` in the detect output) and that shipflow will defer to it, managing only cleanup/automerge/release, not installing a competing ruleset.
51
69
  - `"shipflow"` → tell the user no existing branch protection was found and shipflow will own it going forward.
52
70
  - `"ambiguous"` → **branch protection exists but no settings-as-code artifact was found** (e.g. hand-configured via the GitHub UI). Do NOT silently pick either value — this is exactly the false-positive failure mode a prior design iteration got wrong. Ask explicitly: *"Branch protection exists on this repo but isn't managed as code — should shipflow take ownership of it, or keep managing it externally even though no artifact was found?"* Record whichever the user picks.
53
71
 
54
- 5. **Resolve `release.releaseCredential` — never default it to `GITHUB_TOKEN`.** The rendered `dev-to-main-automerge.yml`'s `GH_TOKEN` comes from this secret name. A PR auto-merged under `secrets.GITHUB_TOKEN` completes (once checks pass) attributed to the `github-actions[bot]` identity, and GitHub's loop-prevention rule means that bot-attributed merge's `pull_request: closed` event **never triggers this or any other workflow** — so `label-release-pending` silently never runs, and the entire manual-gate release-ask flow never has anything to find. This was confirmed empirically, not theoretically: an otherwise-identical PR merged by a real, PAT-authenticated actor fired the closed-event trigger within 2 seconds; one completed by `GITHUB_TOKEN`-enabled auto-merge fired no run at all, even after 100+ seconds. Ask the user to create a fine-grained PAT (or GitHub App installation token) scoped to this repo with `contents: write` + `pull-requests: write`, and to store it as a repo secret themselves (e.g. `gh secret set <NAME> --repo <owner>/<repo>`, run in *their own* shell so the token value never passes through the agent or the transcript). Record only the secret's *name* in `release.releaseCredential` — never its value.
72
+ 6. **Resolve `release.releaseCredential` — never default it to `GITHUB_TOKEN`.** The rendered auto-merge workflow's `GH_TOKEN` comes from this secret name. A PR auto-merged under `secrets.GITHUB_TOKEN` completes (once checks pass) attributed to the `github-actions[bot]` identity, and GitHub's loop-prevention rule means that bot-attributed merge's `pull_request: closed` event **never triggers this or any other workflow** — so `label-release-pending` silently never runs, and the entire manual-gate release-ask flow never has anything to find. This was confirmed empirically, not theoretically: an otherwise-identical PR merged by a real, PAT-authenticated actor fired the closed-event trigger within 2 seconds; one completed by `GITHUB_TOKEN`-enabled auto-merge fired no run at all, even after 100+ seconds. Ask the user to create a fine-grained PAT (or GitHub App installation token) scoped to this repo with `contents: write` + `pull-requests: write`, and to store it as a repo secret themselves (e.g. `gh secret set <NAME> --repo <owner>/<repo>`, run in *their own* shell so the token value never passes through the agent or the transcript). Record only the secret's *name* in `release.releaseCredential` — never its value.
55
73
 
56
- 6. **Present the interview summary and write `.github/shipflow.json`.** Before writing anything, show the user the resolved branch names, `requiredChecks`, `protectionOwner`, and `release.releaseCredential` together in one place and wait for explicit confirmation — this is the checkpoint called out at the top of this section. Then write the config in the target repo (never inside the skill package) using `config.example.json` as the template, with `release.mode: "manual-gate"` (the only implemented mode in this version — see Auto mode, below). Tell the user `.github/shipflow.json` is committed policy and should be `git add`/committed — ideally in the same commit as the rendered auto-merge workflow, once step 10 produces one.
74
+ 7. **Present the interview summary and write `.github/shipflow.json`.** Before writing anything, show the user the resolved `workflowPattern`, branch names, `requiredChecks`, `protectionOwner`, and `release.releaseCredential` together in one place and wait for explicit confirmation — this is the checkpoint called out at the top of this section. Then write the config in the target repo (never inside the skill package) using `config.example.json` as the template, with `release.mode: "manual-gate"` (the only implemented mode in this version — see Auto mode, below). Tell the user `.github/shipflow.json` is committed policy and should be `git add`/committed — ideally in the same commit as the rendered auto-merge workflow(s), once step 11 produces them.
57
75
 
58
- 7. **Show the plan.** Run:
76
+ 8. **Show the plan.** Run:
59
77
  ```
60
- npx -y @natjswenson/shipflow plan --repo <path>
78
+ npx -y @natjswenson/shipflow@latest plan --repo <path>
61
79
  ```
62
- This prints `{ plan, stateHash }`. Present `plan.creates`/`plan.updates`/`plan.noops` to the user in plain language — what will be created, what will change, what's already correct. **Wait for explicit confirmation before proceeding.** If any entry has `handEditDetected: true`, call it out specifically and ask whether to override (see step 9).
80
+ This prints `{ plan, stateHash }`. Present `plan.creates`/`plan.updates`/`plan.noops` to the user in plain language — what will be created, what will change, what's already correct. **Wait for explicit confirmation before proceeding.** If any entry has `handEditDetected: true`, call it out specifically and ask whether to override (see step 10).
63
81
 
64
- 8. **Dry-run apply** (optional sanity check, same output shape as the real apply but nothing is mutated):
82
+ 9. **Dry-run apply** (optional sanity check, same output shape as the real apply but nothing is mutated):
65
83
  ```
66
- npx -y @natjswenson/shipflow apply --repo <path> --dry-run
84
+ npx -y @natjswenson/shipflow@latest apply --repo <path> --dry-run
67
85
  ```
68
86
 
69
- 9. **Apply for real**, passing the `stateHash` from step 7's plan output as `--expect-state-hash` — this is the TOCTOU guard: if repo state drifted between the plan you showed the user and this call, `apply` refuses to mutate anything and tells you to re-plan. **`--expect-state-hash` is mandatory for a real (non-dry-run) apply** — omitting it is a hard CLI refusal, not a silent skip of the check; the only way around it is the explicitly-named `--skip-hash-check` escape hatch, which you should never reach for as a matter of course.
70
- ```
71
- npx -y @natjswenson/shipflow apply --repo <path> --expect-state-hash <hash-from-step-7>
72
- ```
73
- If a `handEditDetected` entry was confirmed for override in step 7, pass `--force <entry-id>` (repeatable — one flag per confirmed entry id, never a blanket override) **and** `--force-reason "<short justification>"` — the CLI refuses any `--force` without an accompanying reason, and that reason is echoed back in the apply result for auditability. Write a real justification tied to the user's actual confirmation (e.g. `--force-reason "user confirmed hand-edit override for the branch-rename migration on 2026-07-15"`), never a placeholder string.
87
+ 10. **Apply for real**, passing the `stateHash` from step 8's plan output as `--expect-state-hash` — this is the TOCTOU guard: if repo state drifted between the plan you showed the user and this call, `apply` refuses to mutate anything and tells you to re-plan. **`--expect-state-hash` is mandatory for a real (non-dry-run) apply** — omitting it is a hard CLI refusal, not a silent skip of the check; the only way around it is the explicitly-named `--skip-hash-check` escape hatch, which you should never reach for as a matter of course.
88
+ ```
89
+ npx -y @natjswenson/shipflow@latest apply --repo <path> --expect-state-hash <hash-from-step-8>
90
+ ```
91
+ If a `handEditDetected` entry was confirmed for override in step 8, pass `--force <entry-id>` (repeatable — one flag per confirmed entry id, never a blanket override) **and** `--force-reason "<short justification>"` — the CLI refuses any `--force` without an accompanying reason, and that reason is echoed back in the apply result for auditability. Write a real justification tied to the user's actual confirmation (e.g. `--force-reason "user confirmed hand-edit override for the branch-rename migration on 2026-07-15"`), never a placeholder string.
74
92
 
75
- 10. **Report the result.** Read `applied`/`skipped`/`errors` from the response. A `skipped` entry can be a deliberate refusal (empty checks, hand-edit) or an environment limitation shipflow can't do anything about (e.g. a deletion-ruleset skipped because the repo is private and not on a paid GitHub tier) — read each `reason` and relay it plainly rather than treating every `skipped` entry the same. If `renderedTemplateHashes` is non-empty, update `.github/shipflow.json`'s `renderedTemplateHashes` field with those values and tell the user to commit the config change *and* the rendered workflow file **together, in the same commit** — a split commit is exactly what causes a false `handEditDetected` on a clean checkout later.
93
+ 11. **Report the result.** Read `applied`/`skipped`/`errors` from the response. A `skipped` entry can be a deliberate refusal (empty checks, hand-edit) or an environment limitation shipflow can't do anything about (e.g. a deletion-ruleset skipped because the repo is private and not on a paid GitHub tier) — read each `reason` and relay it plainly rather than treating every `skipped` entry the same. If `renderedTemplateHashes` is non-empty, update `.github/shipflow.json`'s `renderedTemplateHashes` field with those values and tell the user to commit the config change *and* the rendered workflow file(s) **together, in the same commit** — a split commit is exactly what causes a false `handEditDetected` on a clean checkout later.
76
94
 
77
95
  ## Re-run / audit
78
96
 
79
- Same as steps 1, 7, 8, 9, 10 above, skipping the interview (branch names/checks/protectionOwner/releaseCredential are already recorded in `.github/shipflow.json` — read it, don't re-ask, unless the user explicitly says they want to reconfigure). If `plan.creates`/`plan.updates` is non-empty, that's drift since the last apply — show it and confirm before applying, exactly as in first-run setup.
97
+ Same as steps 1, 8, 9, 10, 11 above, skipping the interview (`workflowPattern`/branch names/checks/protectionOwner/releaseCredential are already recorded in `.github/shipflow.json` — read it, don't re-ask, unless the user explicitly says they want to reconfigure). Step 2's pattern resolution never runs on a re-run — `workflowPattern`'s absence from a config genuinely means "not yet resolved," and its presence means "already resolved," so there's nothing to detect again. If `plan.creates`/`plan.updates` is non-empty, that's drift since the last apply — show it and confirm before applying, exactly as in first-run setup.
80
98
 
81
99
  ## Check pending releases (`manual-gate` ask-flow)
82
100
 
@@ -84,7 +102,7 @@ This is a **separate, later invocation** from the one that ran the promotion's `
84
102
 
85
103
  1. Run:
86
104
  ```
87
- npx -y @natjswenson/shipflow releases --repo <path>
105
+ npx -y @natjswenson/shipflow@latest releases --repo <path>
88
106
  ```
89
107
  This returns every `dev → main` PR still labeled `release-pending`, each with a `merged` flag (confirmed independently, not just inferred from the label).
90
108
 
@@ -92,7 +110,7 @@ This is a **separate, later invocation** from the one that ran the promotion's `
92
110
 
93
111
  3. If yes, dispatch each changed skill's release workflow and clear the label **only after every dispatch is confirmed successful**:
94
112
  ```
95
- npx -y @natjswenson/shipflow release-dispatch --repo <path> --pr <number> --workflow-file <skill1>.yml --workflow-file <skill2>.yml --ref main
113
+ npx -y @natjswenson/shipflow@latest release-dispatch --repo <path> --pr <number> --workflow-file <skill1>.yml --workflow-file <skill2>.yml --ref main
96
114
  ```
97
115
  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).
98
116
 
package/bin/shipflow.js CHANGED
@@ -15,9 +15,18 @@ import {
15
15
  renameDefaultBranch,
16
16
  } from '../lib/apply.mjs';
17
17
  import { readFileCapped } from '../lib/gh.mjs';
18
+ import { resolvePattern, scoreAll } from '../lib/pattern-registry.mjs';
18
19
 
19
20
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
- const TEMPLATE_PATH = join(PACKAGE_ROOT, 'templates', 'dev-to-main-automerge.yml.tmpl');
21
+
22
+ function buildTemplateSources(config) {
23
+ const pattern = resolvePattern(config);
24
+ const sources = {};
25
+ for (const entry of pattern.templates(config)) {
26
+ sources[entry.id] = readFileSync(entry.templateSourcePath, 'utf8');
27
+ }
28
+ return sources;
29
+ }
21
30
 
22
31
  function readPackageVersion() {
23
32
  const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'));
@@ -63,7 +72,8 @@ function cmdDetect(args) {
63
72
  releaseCredentialName: values['release-credential'] ?? null,
64
73
  });
65
74
  const protectionOwner = classifyProtectionOwner(repoState);
66
- printJson({ ...repoState, protectionOwnerClassification: protectionOwner });
75
+ const rankedPatterns = scoreAll(repoState);
76
+ printJson({ ...repoState, protectionOwnerClassification: protectionOwner, rankedPatterns });
67
77
  }
68
78
 
69
79
  function cmdPlan(args) {
@@ -88,10 +98,10 @@ function cmdPlan(args) {
88
98
  branches: config.branches,
89
99
  releaseCredentialName: config.release?.releaseCredential ?? null,
90
100
  });
91
- const templateSource = readFileSync(TEMPLATE_PATH, 'utf8');
101
+ const templateSources = buildTemplateSources(config);
92
102
  let plan;
93
103
  try {
94
- plan = computePlan(repoState, config, templateSource);
104
+ plan = computePlan(repoState, config, templateSources);
95
105
  } catch (e) {
96
106
  return fail(`plan: ${e.message}`);
97
107
  }
@@ -149,10 +159,10 @@ function cmdApply(args) {
149
159
  branches: config.branches,
150
160
  releaseCredentialName: config.release?.releaseCredential ?? null,
151
161
  });
152
- const templateSource = readFileSync(TEMPLATE_PATH, 'utf8');
162
+ const templateSources = buildTemplateSources(config);
153
163
  let plan;
154
164
  try {
155
- plan = computePlan(repoState, config, templateSource);
165
+ plan = computePlan(repoState, config, templateSources);
156
166
  } catch (e) {
157
167
  return fail(`apply: ${e.message}`);
158
168
  }
package/lib/apply.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { spawnArgs, ghApiJson } from './gh.mjs';
4
+ import { resolvePattern } from './pattern-registry.mjs';
4
5
 
5
6
  // applyPlan(plan, opts): opts extends the schematic { dryRun, currentStateHash,
6
7
  // force } from the design contract with the execution context (ownerRepo,
@@ -29,6 +30,22 @@ export function classifyRulesetError(stderr) {
29
30
  return { tierGated: false, reason: null };
30
31
  }
31
32
 
33
+ // Pure — no I/O, no gh calls. Exported so it's directly unit-testable without
34
+ // mocking the network layer, matching this file's existing classifyRulesetError
35
+ // pattern. protectedBranchList must come from a FRESH call to the resolved
36
+ // pattern's protectedBranches(config) — never from a stored config field — so
37
+ // this function intentionally takes a plain string array, not a config object,
38
+ // making "read a stale stored value" structurally impossible to do by accident.
39
+ export function buildDeletionRulesetBody(protectedBranchList) {
40
+ return {
41
+ name: 'shipflow-branch-deletion-protection',
42
+ target: 'branch',
43
+ enforcement: 'active',
44
+ conditions: { ref_name: { include: protectedBranchList.map((b) => `refs/heads/${b}`), exclude: [] } },
45
+ rules: [{ type: 'deletion' }],
46
+ };
47
+ }
48
+
32
49
  export function applyPlan(plan, opts) {
33
50
  const { dryRun, currentStateHash, force = [], forceReason = null, ownerRepo, repoPath, config } = opts;
34
51
 
@@ -114,15 +131,8 @@ function applyOne(entry, { ownerRepo, repoPath, config }) {
114
131
  }
115
132
 
116
133
  if (entry.id === 'deletion-ruleset') {
117
- const body = JSON.stringify({
118
- name: 'shipflow-branch-deletion-protection',
119
- target: 'branch',
120
- enforcement: 'active',
121
- conditions: {
122
- ref_name: { include: [`refs/heads/${config.branches.dev}`, `refs/heads/${config.branches.main}`], exclude: [] },
123
- },
124
- rules: [{ type: 'deletion' }],
125
- });
134
+ const protectedBranchList = resolvePattern(config).protectedBranches(config);
135
+ const body = JSON.stringify(buildDeletionRulesetBody(protectedBranchList));
126
136
  const r = spawnArgs('gh', ['api', `repos/${ownerRepo}/rulesets`, '-X', 'POST', '--input', '-'], { input: body });
127
137
  if (r.status === 0) return { ok: true };
128
138
  const { tierGated, reason } = classifyRulesetError(r.stderr);
package/lib/detect.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { spawnArgs, ghApiJson, git, sha256, readFileCapped } from './gh.mjs';
4
+ import { listPatterns } from './pattern-registry.mjs';
4
5
 
5
- const TEMPLATE_RELATIVE_PATH = '.github/workflows/dev-to-main-automerge.yml';
6
6
  const CONFIG_RELATIVE_PATH = '.github/shipflow.json';
7
7
 
8
8
  // Files/content patterns that indicate branch protection is already managed
@@ -93,8 +93,67 @@ export function listWorkflowJobNames(repoPath, trackedFiles) {
93
93
  return [...names].sort();
94
94
  }
95
95
 
96
- export function readTemplateFileHash(repoPath) {
97
- const full = join(repoPath, TEMPLATE_RELATIVE_PATH);
96
+ const GH_PR_MERGE_AUTO_RE = /gh pr merge --auto/;
97
+ const HEAD_REF_EQ_RE = /head\.ref\s*==/;
98
+
99
+ // One level finer than listWorkflowJobNames()'s whole-jobs:-section boundary: bounds
100
+ // each INDIVIDUAL job's own line range (its jobNameRe-matching name line down to the
101
+ // next line at that same or lesser indent — the next sibling job, or EOF) so a
102
+ // head.ref == check in one job is never misattributed to a different job's
103
+ // gh pr merge --auto step in the same file.
104
+ export function scanWorkflowShapeSignals(repoPath, trackedFiles) {
105
+ let restricted = false;
106
+ let unrestricted = false;
107
+ for (const f of trackedFiles.filter((f) => /^\.github\/workflows\/.*\.ya?ml$/.test(f))) {
108
+ const full = join(repoPath, f);
109
+ if (!existsSync(full)) continue;
110
+ const lines = readFileCapped(full).split('\n');
111
+ const jobsLineIdx = lines.findIndex((l) => l.trim() === 'jobs:');
112
+ if (jobsLineIdx === -1) continue;
113
+ let i = jobsLineIdx + 1;
114
+ while (i < lines.length) {
115
+ // Mirrors listWorkflowJobNames's own dedent-out-of-block termination: a line
116
+ // that dedents all the way to column 0 ends the WHOLE jobs: section (a
117
+ // trailing env:/concurrency: block, or EOF), not just the current job — stop
118
+ // the outer scan entirely rather than treating it as another job candidate.
119
+ if (lines[i].trim() !== '' && /^\S/.test(lines[i])) break;
120
+ const nameMatch = lines[i].match(/^\s{2}([\w.-]+):\s*$/);
121
+ if (!nameMatch) { i++; continue; }
122
+ const jobStart = i;
123
+ let jobEnd = lines.length;
124
+ for (let j = i + 1; j < lines.length; j++) {
125
+ if (lines[j].trim() === '') continue;
126
+ if (/^\S/.test(lines[j])) { jobEnd = j; break; } // dedents to column 0 — end of jobs: section
127
+ if (lines[j].match(/^\s{2}([\w.-]+):\s*$/)) { jobEnd = j; break; } // next sibling job
128
+ }
129
+ const jobBlock = lines.slice(jobStart, jobEnd).join('\n');
130
+ if (GH_PR_MERGE_AUTO_RE.test(jobBlock)) {
131
+ if (HEAD_REF_EQ_RE.test(jobBlock)) restricted = true;
132
+ else unrestricted = true;
133
+ }
134
+ i = jobEnd;
135
+ }
136
+ }
137
+ return { hasRestrictedPromotionWorkflow: restricted, hasUnrestrictedAutomergeWorkflow: unrestricted };
138
+ }
139
+
140
+ export function hasTagsFromMain(repoPath, mainBranch) {
141
+ const r = git(['tag', '--merged', mainBranch], { cwd: repoPath });
142
+ return r.status === 0 && r.stdout.trim().length > 0;
143
+ }
144
+
145
+ export function hasGitflowMarker(repoPath) {
146
+ if (existsSync(join(repoPath, '.gitflow'))) return true;
147
+ return git(['config', '--get', 'gitflow.branch.develop'], { cwd: repoPath }).status === 0;
148
+ }
149
+
150
+ export function listConfiguredRemotes(repoPath) {
151
+ const r = git(['remote'], { cwd: repoPath });
152
+ return r.status === 0 ? r.stdout.split('\n').filter(Boolean) : [];
153
+ }
154
+
155
+ export function readTemplateFileHash(repoPath, targetPath) {
156
+ const full = join(repoPath, targetPath);
98
157
  if (!existsSync(full)) return { exists: false, sha256: null };
99
158
  const content = readFileCapped(full);
100
159
  return { exists: true, sha256: sha256(content) };
@@ -172,8 +231,18 @@ export function detectRepoState(repoPath, { branches = { main: 'main', dev: 'dev
172
231
  const localBranches = branchList.status === 0 ? branchList.stdout.split('\n').filter(Boolean) : [];
173
232
 
174
233
  const workflowJobNames = listWorkflowJobNames(repoPath, trackedFiles);
175
- const templateFiles = { [TEMPLATE_RELATIVE_PATH]: readTemplateFileHash(repoPath) };
234
+ // Union of every registered pattern's templateTargetPaths — computed unconditionally
235
+ // with no resolved pattern in hand (there's no config yet on a genuine first run, so
236
+ // nothing to call resolvePattern(config) with). detect.mjs never imports a
237
+ // lib/patterns/<id>/index.mjs module directly, only listPatterns() — this is what
238
+ // keeps "adding a 4th pattern needs no changes to detect.mjs" true in practice.
239
+ const templateTargetPaths = listPatterns().flatMap((p) => p.templateTargetPaths);
240
+ const templateFiles = Object.fromEntries(
241
+ templateTargetPaths.map((path) => [path, readTemplateFileHash(repoPath, path)])
242
+ );
176
243
  const settingsAsCodeArtifact = findSettingsAsCodeArtifact(repoPath, trackedFiles);
244
+ const { hasRestrictedPromotionWorkflow, hasUnrestrictedAutomergeWorkflow } =
245
+ scanWorkflowShapeSignals(repoPath, trackedFiles);
177
246
 
178
247
  const protection = ownerRepo
179
248
  ? {
@@ -219,6 +288,11 @@ export function detectRepoState(repoPath, { branches = { main: 'main', dev: 'dev
219
288
  repoSettings,
220
289
  releasePendingLabelExists,
221
290
  stateHash,
291
+ hasTagsFromMain: hasTagsFromMain(repoPath, branches.main),
292
+ hasGitflowMarker: hasGitflowMarker(repoPath),
293
+ configuredRemotes: listConfiguredRemotes(repoPath),
294
+ hasRestrictedPromotionWorkflow,
295
+ hasUnrestrictedAutomergeWorkflow,
222
296
  };
223
297
  }
224
298
 
@@ -0,0 +1,79 @@
1
+ import * as devMainPromotion from './patterns/dev-main-promotion/index.mjs';
2
+ import * as githubFlow from './patterns/github-flow/index.mjs';
3
+ import * as gitflow from './patterns/gitflow/index.mjs';
4
+
5
+ const PATTERNS = [devMainPromotion, githubFlow, gitflow];
6
+
7
+ export function listPatterns() {
8
+ return PATTERNS.map((p) => ({ id: p.id, templateTargetPaths: p.templateTargetPaths }));
9
+ }
10
+
11
+ export function resolvePattern(config) {
12
+ const wanted = config?.workflowPattern ?? 'dev-main-promotion';
13
+ const found = PATTERNS.find((p) => p.id === wanted);
14
+ if (!found) throw new Error(`resolvePattern: unknown workflowPattern "${wanted}"`);
15
+ return found;
16
+ }
17
+
18
+ // Strips a leading '<remote>/' ONLY when it exactly matches one of repoState's
19
+ // configuredRemotes — never a blind "strip to first slash," which would corrupt a
20
+ // purely local release/1.2.0 into 1.2.0. configuredRemotes is populated by
21
+ // detect.mjs (Task 7) via `git remote` — this function takes the already-collected
22
+ // repoState, no git calls of its own, keeping it a pure, easily-testable function.
23
+ function normalizeBranchName(name, remotes) {
24
+ for (const remote of remotes) {
25
+ if (name.startsWith(`${remote}/`)) return name.slice(remote.length + 1);
26
+ }
27
+ return name;
28
+ }
29
+
30
+ const DEV_BRANCH_RE = /^(dev|develop|staging)$/;
31
+ const RELEASE_HOTFIX_RE = /^(release|hotfix)\//;
32
+
33
+ // Computes the 6 shared boolean signals every pattern's detect() consumes. Pure
34
+ // given its single repoState input (matches the contract's scoreAll(repoState)
35
+ // signature — repoPath/git calls stay confined to detect.mjs's collection step,
36
+ // Task 7 — repoState must already carry the raw material (branches,
37
+ // configuredRemotes, tags, .gitflow marker, workflow-shape scan) that step gathers.
38
+ export function computeDetectionSignals(repoState) {
39
+ const remotes = repoState.configuredRemotes ?? [];
40
+ const normalized = (repoState.branches?.local ?? []).map((b) => normalizeBranchName(b, remotes));
41
+ return {
42
+ hasDevBranch: normalized.some((b) => DEV_BRANCH_RE.test(b)),
43
+ hasReleaseOrHotfixBranch: normalized.some((b) => RELEASE_HOTFIX_RE.test(b)),
44
+ hasGitflowMarker: repoState.hasGitflowMarker ?? false,
45
+ hasRestrictedPromotionWorkflow: repoState.hasRestrictedPromotionWorkflow ?? false,
46
+ hasUnrestrictedAutomergeWorkflow: repoState.hasUnrestrictedAutomergeWorkflow ?? false,
47
+ hasTagsFromMain: repoState.hasTagsFromMain ?? false,
48
+ };
49
+ }
50
+
51
+ function scoreFromSignals(signals) {
52
+ return PATTERNS.map((p) => ({ id: p.id, ...p.detect(signals) })).sort((a, b) => b.score - a.score);
53
+ }
54
+
55
+ export function scoreAll(repoState) {
56
+ return scoreFromSignals(computeDetectionSignals(repoState));
57
+ }
58
+ // Test-only seam: exercise scoring against a hand-built DetectionSignals object
59
+ // directly, bypassing computeDetectionSignals/repoState entirely. Not part of the
60
+ // public CLI-facing API — the 5 worked-example tests above use this seam because
61
+ // they're testing the SCORING rules in isolation; the two normalization tests
62
+ // above instead call computeDetectionSignals(repoState) directly, since THAT is
63
+ // what those tests are about. Attaching a property to an exported `function`
64
+ // declaration works fine in ESM (the export binding is the function object
65
+ // itself, and function objects are ordinary mutable objects) — this is not the
66
+ // same footgun as trying to reassign a `const`-exported binding from outside the
67
+ // module, which ESM does forbid.
68
+ scoreAll.__scoreFromSignals = scoreFromSignals;
69
+
70
+ // Confident: top >= 0.7 AND (top - second) > 0.3. Greenfield: top < 0.4. Else
71
+ // Ambiguous — the residual/else branch, no separate condition to satisfy, which is
72
+ // what makes this exhaustive by construction (see design doc's Autodetection section).
73
+ export function classify(ranked) {
74
+ const [top, second] = ranked;
75
+ const secondScore = second?.score ?? 0;
76
+ if (top.score >= 0.7 && top.score - secondScore > 0.3) return 'confident';
77
+ if (top.score < 0.4) return 'greenfield';
78
+ return 'ambiguous';
79
+ }
@@ -0,0 +1,49 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { mergeMethodToFlag } from '../../render.mjs';
4
+
5
+ const PATTERN_DIR = dirname(fileURLToPath(import.meta.url));
6
+ const TEMPLATE_SOURCE_PATH = join(
7
+ PATTERN_DIR, '..', '..', '..', 'templates', 'dev-main-promotion', 'dev-to-main-automerge.yml.tmpl'
8
+ );
9
+ const TARGET_PATH = '.github/workflows/dev-to-main-automerge.yml';
10
+
11
+ export const id = 'dev-main-promotion';
12
+ export const templateTargetPaths = [TARGET_PATH];
13
+
14
+ export function protectedBranches(config) {
15
+ return [config.branches.dev, config.branches.main];
16
+ }
17
+
18
+ export function templates(config) {
19
+ return [{
20
+ id: 'dev-to-main-automerge',
21
+ targetPath: TARGET_PATH,
22
+ templateSourcePath: TEMPLATE_SOURCE_PATH,
23
+ params: {
24
+ devBranch: config.branches.dev,
25
+ mainBranch: config.branches.main,
26
+ mergeFlag: mergeMethodToFlag(config.mergeMethod?.devToMainMethod),
27
+ releaseCredentialSecret: config.release?.releaseCredential ?? 'GITHUB_TOKEN',
28
+ },
29
+ }];
30
+ }
31
+
32
+ // signals: precomputed DetectionSignals (see pattern-registry.mjs's computeDetectionSignals).
33
+ export function detect(signals) {
34
+ const evidence = [];
35
+ let score = 0;
36
+ if (signals.hasDevBranch && !signals.hasReleaseOrHotfixBranch) {
37
+ score += 0.5;
38
+ evidence.push('a dev/develop/staging branch exists with no release/* or hotfix/* branches present');
39
+ }
40
+ if (signals.hasRestrictedPromotionWorkflow) {
41
+ score += 0.5;
42
+ evidence.push('an existing workflow restricts auto-merge-to-main to one specific branch');
43
+ }
44
+ return { score, evidence };
45
+ }
46
+
47
+ export function planEntries(_repoState, _config) {
48
+ return [];
49
+ }
@@ -0,0 +1,55 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { mergeMethodToFlag } from '../../render.mjs';
4
+
5
+ const PATTERN_DIR = dirname(fileURLToPath(import.meta.url));
6
+ const TEMPLATE_DIR = join(PATTERN_DIR, '..', '..', '..', 'templates', 'gitflow');
7
+
8
+ export const id = 'gitflow';
9
+ export const templateTargetPaths = [
10
+ '.github/workflows/release-automerge.yml',
11
+ '.github/workflows/hotfix-automerge.yml',
12
+ '.github/workflows/hotfix-merge-back.yml',
13
+ '.github/workflows/release-merge-back.yml',
14
+ ];
15
+
16
+ // release/* and hotfix/* are deliberately excluded — transient, cleaned up
17
+ // post-merge like any feature branch under every pattern.
18
+ export function protectedBranches(config) {
19
+ return [config.branches.dev, config.branches.main];
20
+ }
21
+
22
+ export function templates(config) {
23
+ const releasePrefix = config.patternConfig?.gitflow?.releaseBranchPrefix ?? 'release/';
24
+ const hotfixPrefix = config.patternConfig?.gitflow?.hotfixBranchPrefix ?? 'hotfix/';
25
+ const baseParams = {
26
+ devBranch: config.branches.dev,
27
+ mainBranch: config.branches.main,
28
+ mergeFlag: mergeMethodToFlag(config.mergeMethod?.devToMainMethod),
29
+ releaseCredentialSecret: config.release?.releaseCredential ?? 'GITHUB_TOKEN',
30
+ releaseBranchPrefix: releasePrefix,
31
+ hotfixBranchPrefix: hotfixPrefix,
32
+ };
33
+ return [
34
+ { id: 'release-automerge', targetPath: '.github/workflows/release-automerge.yml',
35
+ templateSourcePath: join(TEMPLATE_DIR, 'release-automerge.yml.tmpl'), params: baseParams },
36
+ { id: 'hotfix-automerge', targetPath: '.github/workflows/hotfix-automerge.yml',
37
+ templateSourcePath: join(TEMPLATE_DIR, 'hotfix-automerge.yml.tmpl'), params: baseParams },
38
+ { id: 'hotfix-merge-back', targetPath: '.github/workflows/hotfix-merge-back.yml',
39
+ templateSourcePath: join(TEMPLATE_DIR, 'hotfix-merge-back.yml.tmpl'), params: baseParams },
40
+ { id: 'release-merge-back', targetPath: '.github/workflows/release-merge-back.yml',
41
+ templateSourcePath: join(TEMPLATE_DIR, 'release-merge-back.yml.tmpl'), params: baseParams },
42
+ ];
43
+ }
44
+
45
+ export function detect(signals) {
46
+ const evidence = [];
47
+ let score = 0;
48
+ if (signals.hasDevBranch) { score += 0.5; evidence.push('a develop/dev/staging branch exists'); }
49
+ if (signals.hasReleaseOrHotfixBranch) { score += 0.5; evidence.push('a release/* or hotfix/* branch exists'); }
50
+ // hasGitflowMarker is a best-effort bonus signal only — carries no numeric
51
+ // weight in v1 (see design doc's Autodetection section).
52
+ return { score, evidence };
53
+ }
54
+
55
+ export function planEntries() { return []; }
@@ -0,0 +1,43 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { mergeMethodToFlag } from '../../render.mjs';
4
+
5
+ const PATTERN_DIR = dirname(fileURLToPath(import.meta.url));
6
+ const TEMPLATE_SOURCE_PATH = join(PATTERN_DIR, '..', '..', '..', 'templates', 'github-flow', 'main-automerge.yml.tmpl');
7
+ const TARGET_PATH = '.github/workflows/main-automerge.yml';
8
+
9
+ export const id = 'github-flow';
10
+ export const templateTargetPaths = [TARGET_PATH];
11
+
12
+ export function protectedBranches(config) {
13
+ return [config.branches.main];
14
+ }
15
+
16
+ export function templates(config) {
17
+ return [{
18
+ id: 'main-automerge',
19
+ targetPath: TARGET_PATH,
20
+ templateSourcePath: TEMPLATE_SOURCE_PATH,
21
+ params: {
22
+ mainBranch: config.branches.main,
23
+ mergeFlag: mergeMethodToFlag(config.mergeMethod?.devToMainMethod),
24
+ releaseCredentialSecret: config.release?.releaseCredential ?? 'GITHUB_TOKEN',
25
+ },
26
+ }];
27
+ }
28
+
29
+ export function detect(signals) {
30
+ const evidence = [];
31
+ let score = 0;
32
+ if (signals.hasUnrestrictedAutomergeWorkflow || signals.hasTagsFromMain) {
33
+ score += 0.5;
34
+ evidence.push('an unrestricted auto-merge-to-main workflow exists, or tags are reachable from main');
35
+ }
36
+ if (!signals.hasDevBranch && !signals.hasReleaseOrHotfixBranch) {
37
+ score += 0.3;
38
+ evidence.push('no dev/develop/staging branch and no release/* or hotfix/* branches exist');
39
+ }
40
+ return { score, evidence };
41
+ }
42
+
43
+ export function planEntries() { return []; }
package/lib/plan.mjs CHANGED
@@ -1,17 +1,18 @@
1
- import { renderTemplate, mergeMethodToFlag } from './render.mjs';
1
+ import { resolvePattern } from './pattern-registry.mjs';
2
+ import { renderTemplate } from './render.mjs';
2
3
  import { sha256 } from './gh.mjs';
3
4
 
4
- const TEMPLATE_PATH = '.github/workflows/dev-to-main-automerge.yml';
5
-
6
5
  // Pure function — no I/O, no gh/git calls. Diffs repoState (what detect.mjs
7
6
  // observed) against config (what the user wants) into a Plan the caller
8
7
  // shows to the user before any mutation happens.
9
- export function computePlan(repoState, config, templateSource) {
8
+ export function computePlan(repoState, config, templateSources) {
9
+ const pattern = resolvePattern(config);
10
+ const protectedBranchList = pattern.protectedBranches(config);
10
11
  const creates = [];
11
12
  const updates = [];
12
13
  const noops = [];
13
14
 
14
- // 1. delete_branch_on_merge repo setting
15
+ // 1. delete_branch_on_merge — unchanged, repo-wide boolean, no branch names involved.
15
16
  const wantDeleteOnMerge = config.branchCleanup?.deleteOnMerge ?? true;
16
17
  const haveDeleteOnMerge = repoState.repoSettings?.deleteBranchOnMerge;
17
18
  if (haveDeleteOnMerge === wantDeleteOnMerge) {
@@ -24,38 +25,48 @@ export function computePlan(repoState, config, templateSource) {
24
25
  });
25
26
  }
26
27
 
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.
28
+ // 2. deletion-ruleset — protects protectedBranchList, not a hardcoded [dev, main].
29
+ // Description strings below are copied VERBATIM from the pre-existing (single-pattern)
30
+ // plan.mjs, not rephrased this repo's own live .github/shipflow.json has
31
+ // protectionOwner: "external", so the dogfood smoke test
32
+ // (`node bin/shipflow.js plan --repo .../claude-skills`) exercises this exact else-branch
33
+ // and must reproduce byte-identical plan-entry text, not just equivalent behavior.
33
34
  if (config.protectionOwner === 'shipflow') {
34
35
  if ((repoState.rulesets ?? []).length > 0) {
35
36
  noops.push({ id: 'deletion-ruleset', description: 'a ruleset already exists (coarse check — see plan.mjs comment)' });
36
37
  } else {
37
- creates.push({ id: 'deletion-ruleset', description: `create a ruleset protecting ${config.branches.dev}/${config.branches.main} from deletion` });
38
+ creates.push({ id: 'deletion-ruleset', description: `create a ruleset protecting ${protectedBranchList.join('/')} from deletion` });
38
39
  }
39
40
  } else {
40
41
  noops.push({ id: 'deletion-ruleset', description: `protectionOwner is "${config.protectionOwner}" — deferring to existing mechanism, shipflow installs nothing` });
41
42
  }
42
43
 
43
- // 3. dev-to-main-automerge.ymlcontent-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);
44
+ // 3. per-pattern templates generalized from one hardcoded entry to N.
45
+ for (const entry of pattern.templates(config)) {
46
+ const templateSource = templateSources[entry.id];
47
+ if (templateSource === undefined) {
48
+ throw new Error(`computePlan: no templateSources entry for template id "${entry.id}"`);
49
+ }
50
+ const planEntry = computeTemplatePlanEntry(repoState, config, entry, templateSource);
51
+ if (planEntry.kind === 'noop') noops.push(planEntry);
52
+ else if (planEntry.kind === 'create') creates.push(planEntry);
53
+ else updates.push(planEntry);
54
+ }
49
55
 
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).
56
+ // 4. release-pending label — unconditional across every release.mode and pattern.
53
57
  if (repoState.releasePendingLabelExists) {
54
58
  noops.push({ id: 'release-pending-label', description: 'release-pending label already exists' });
55
59
  } else {
56
60
  creates.push({ id: 'release-pending-label', description: 'create the release-pending label' });
57
61
  }
58
62
 
63
+ // 5. pattern-specific entries beyond the 4 common ones above (empty for all 3 v1 patterns).
64
+ for (const entry of pattern.planEntries(repoState, config)) {
65
+ if (entry.kind === 'noop') noops.push(entry);
66
+ else if (entry.kind === 'create') creates.push(entry);
67
+ else updates.push(entry);
68
+ }
69
+
59
70
  // liveRequiredChecks: union of classic branch-protection required checks
60
71
  // (on the configured main branch) and every fetched ruleset's required
61
72
  // checks. v1 scope note: rulesets are unioned without filtering by which
@@ -65,51 +76,46 @@ export function computePlan(repoState, config, templateSource) {
65
76
  const rulesetChecks = (repoState.rulesets ?? []).flatMap((rs) => rs.requiredChecks ?? []);
66
77
  const liveRequiredChecks = [...new Set([...classicChecks, ...rulesetChecks])].sort();
67
78
 
68
- return {
69
- creates,
70
- updates,
71
- noops,
72
- sourceStateHash: repoState.stateHash,
73
- liveRequiredChecks,
74
- };
79
+ return { creates, updates, noops, sourceStateHash: repoState.stateHash, liveRequiredChecks, protectedBranches: protectedBranchList };
75
80
  }
76
81
 
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
- // Must be a real PAT/App-installation-token secret name, not
83
- // "GITHUB_TOKEN" — see the template's header comment for why a
84
- // GITHUB_TOKEN-attributed auto-merge never fires the closed-event
85
- // label job at all.
86
- releaseCredentialSecret: config.release?.releaseCredential ?? 'GITHUB_TOKEN',
87
- };
88
- const renderedContent = renderTemplate(templateSource, params);
82
+ // entry is one {id, targetPath, templateSourcePath, params} item from
83
+ // pattern.templates(config); templateSource is that entry's already-read-off-disk
84
+ // content (looked up from the caller-supplied templateSources map above).
85
+ //
86
+ // IMPORTANT: the returned plan entry's `id` field is NOT entry.id (the pattern
87
+ // module's own template identifier, e.g. 'release-automerge') it MUST stay the
88
+ // pre-existing 'template:' + targetPath convention, because apply.mjs's dispatch
89
+ // (`entry.id.startsWith('template:')`) and the empty-required-checks refusal both
90
+ // key off that exact prefix today. Reusing the pattern module's own template id
91
+ // verbatim here would silently break both of those existing mechanisms.
92
+ function computeTemplatePlanEntry(repoState, config, entry, templateSource) {
93
+ const planId = 'template:' + entry.targetPath;
94
+ const renderedContent = renderTemplate(templateSource, entry.params);
89
95
  const freshHash = sha256(renderedContent);
90
- const onDisk = repoState.templateFiles?.[TEMPLATE_PATH];
91
- const lastRenderedHash = config.renderedTemplateHashes?.[TEMPLATE_PATH] ?? null;
96
+ const onDisk = repoState.templateFiles?.[entry.targetPath];
97
+ const lastRenderedHash = config.renderedTemplateHashes?.[entry.targetPath] ?? null;
92
98
 
93
99
  if (!onDisk || !onDisk.exists) {
94
- return { id: 'template:' + TEMPLATE_PATH, kind: 'create', path: TEMPLATE_PATH, description: `write ${TEMPLATE_PATH}`, renderedHash: freshHash, content: renderedContent };
100
+ return { id: planId, kind: 'create', path: entry.targetPath, description: `write ${entry.targetPath}`, renderedHash: freshHash, content: renderedContent };
95
101
  }
96
102
  if (onDisk.sha256 === freshHash) {
97
- return { id: 'template:' + TEMPLATE_PATH, kind: 'noop', path: TEMPLATE_PATH, description: `${TEMPLATE_PATH} already matches config` };
103
+ return { id: planId, kind: 'noop', path: entry.targetPath, description: `${entry.targetPath} already matches config` };
98
104
  }
99
105
  if (onDisk.sha256 === lastRenderedHash) {
100
106
  // On-disk content matches what shipflow itself last rendered, but the
101
107
  // config has changed since — a legitimate re-render, not a hand-edit.
102
- return { id: 'template:' + TEMPLATE_PATH, kind: 'update', path: TEMPLATE_PATH, description: `re-render ${TEMPLATE_PATH} (config changed)`, renderedHash: freshHash, content: renderedContent, handEditDetected: false };
108
+ return { id: planId, kind: 'update', path: entry.targetPath, description: `re-render ${entry.targetPath} (config changed)`, renderedHash: freshHash, content: renderedContent, handEditDetected: false };
103
109
  }
104
110
  // On-disk content matches neither the fresh render nor our last recorded
105
111
  // render — someone hand-edited it (or it was never rendered by shipflow).
106
112
  // Flagged, not silently overwritten; apply.mjs blocks this entry unless
107
113
  // the caller passes an explicit force override naming this entry's id.
108
114
  return {
109
- id: 'template:' + TEMPLATE_PATH,
115
+ id: planId,
110
116
  kind: 'update',
111
- path: TEMPLATE_PATH,
112
- description: `${TEMPLATE_PATH} was hand-edited — blocked pending --force`,
117
+ path: entry.targetPath,
118
+ description: `${entry.targetPath} was hand-edited — blocked pending --force`,
113
119
  renderedHash: freshHash,
114
120
  content: renderedContent,
115
121
  handEditDetected: true,
package/lib/render.mjs CHANGED
@@ -35,11 +35,28 @@ const UNSAFE_YAML_STRING_RE = /['\r\n]/;
35
35
  // with a digit (case-insensitivity aside, this is the full safe charset).
36
36
  const SAFE_SECRET_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
37
37
 
38
+ // Prefix tokens for gitflow's release/* and hotfix/* head.ref match guards.
39
+ // These do NOT reuse DEV_BRANCH/MAIN_BRANCH's bare UNSAFE_YAML_STRING_RE check
40
+ // unmodified: those two substitute into an == equality comparison, where an
41
+ // empty string is just a branch name that will never equal anything and so
42
+ // fails CLOSED. These two instead substitute into
43
+ // startsWith(head.ref, '{{...}}') — and EVERY string starts with the empty
44
+ // string, so an empty prefix fails OPEN, collapsing gitflow's release/hotfix-
45
+ // scoped auto-merge into an unrestricted one matching any PR into main. That
46
+ // is the same severity class as the quote-injection Critical finding this
47
+ // module was already hardened against, and it's trivially reachable non-
48
+ // maliciously (a user who wants "no prefix restriction" would naturally try
49
+ // ""). Reject a non-empty-string requirement in addition to the quote/newline
50
+ // check.
51
+ const NON_EMPTY_SAFE_STRING_RE = (v) => v.length > 0 && !UNSAFE_YAML_STRING_RE.test(v);
52
+
38
53
  const TOKEN_VALIDATORS = Object.freeze({
39
54
  DEV_BRANCH: (v) => !UNSAFE_YAML_STRING_RE.test(v),
40
55
  MAIN_BRANCH: (v) => !UNSAFE_YAML_STRING_RE.test(v),
41
56
  MERGE_FLAG: () => true, // closed enum from mergeMethodToFlag — never attacker-shaped
42
57
  RELEASE_CREDENTIAL_SECRET: (v) => SAFE_SECRET_NAME_RE.test(v),
58
+ RELEASE_BRANCH_PREFIX: NON_EMPTY_SAFE_STRING_RE,
59
+ HOTFIX_BRANCH_PREFIX: NON_EMPTY_SAFE_STRING_RE,
43
60
  });
44
61
 
45
62
  // params: { devBranch, mainBranch, mergeFlag, releaseCredentialSecret }
@@ -53,7 +70,14 @@ export function renderTemplate(templateSource, params) {
53
70
  const unsafe = [];
54
71
  const rendered = templateSource.replace(TOKEN_RE, (_, name) => {
55
72
  const key = TOKEN_TO_PARAM[name];
56
- if (!key || !(key in params)) {
73
+ // A present-but-undefined param counts as MISSING, not as the string
74
+ // "undefined". `key in params` alone was true for it, so String(undefined)
75
+ // flowed through as a real value and passed the safety regexes — a config
76
+ // with no `branches.main` rendered `branches: [undefined]` and
77
+ // `name: auto-merge dev to undefined`, installing a workflow that could
78
+ // never fire, with no error at apply time. Found by the rendered-workflow
79
+ // baseline (tests/baseline.test.mjs) on its first run.
80
+ if (!key || params[key] === undefined || params[key] === null) {
57
81
  missing.push(name);
58
82
  return `{{${name}}}`;
59
83
  }
@@ -69,7 +93,7 @@ export function renderTemplate(templateSource, params) {
69
93
  }
70
94
  if (unsafe.length > 0) {
71
95
  throw new Error(
72
- `renderTemplate: unsafe value for token(s): ${unsafe.join(', ')} — branch names must not contain a quote or newline, and the release-credential secret name must match GitHub's secret-naming rules (letters/digits/underscore, not starting with a digit)`
96
+ `renderTemplate: unsafe value for token(s): ${unsafe.join(', ')} — branch names and release/hotfix prefixes must be non-empty and must not contain a quote or newline, and the release-credential secret name must match GitHub's secret-naming rules (letters/digits/underscore, not starting with a digit)`
73
97
  );
74
98
  }
75
99
  return rendered;
@@ -80,8 +104,24 @@ const TOKEN_TO_PARAM = Object.freeze({
80
104
  MAIN_BRANCH: 'mainBranch',
81
105
  MERGE_FLAG: 'mergeFlag',
82
106
  RELEASE_CREDENTIAL_SECRET: 'releaseCredentialSecret',
107
+ RELEASE_BRANCH_PREFIX: 'releaseBranchPrefix',
108
+ HOTFIX_BRANCH_PREFIX: 'hotfixBranchPrefix',
83
109
  });
84
110
 
111
+ // INV-MP-12: every TOKEN_TO_PARAM key must have a matching TOKEN_VALIDATORS key, or a
112
+ // substituted value could reach a template with zero validation (the exact class of
113
+ // gap a 2026-07-15 Siege audit found and fixed). Called once at module load against
114
+ // the real exported objects; also independently callable so a unit test can assert
115
+ // the logic itself (not just today's two maps happening to agree) by passing in
116
+ // deliberately-mismatched local fixture objects.
117
+ export function assertTokenValidatorsComplete(tokenToParam, tokenValidators) {
118
+ const missing = Object.keys(tokenToParam).filter((key) => !(key in tokenValidators));
119
+ if (missing.length > 0) {
120
+ throw new Error(`assertTokenValidatorsComplete: TOKEN_VALIDATORS missing entr(y/ies) for: ${missing.join(', ')}`);
121
+ }
122
+ }
123
+ assertTokenValidatorsComplete(TOKEN_TO_PARAM, TOKEN_VALIDATORS);
124
+
85
125
  export function mergeMethodToFlag(devToMainMethod) {
86
126
  switch (devToMainMethod) {
87
127
  case 'squash':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natjswenson/shipflow",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold a configurable dev/main branching, auto-merge, branch-cleanup, and release-tagging workflow into any repo",
5
5
  "license": "MIT",
6
6
  "author": "Nate Swenson",
@@ -75,7 +75,31 @@
75
75
  "id": "force-requires-reason",
76
76
  "pattern": "refuses any `--force` without an accompanying reason",
77
77
  "rationale": "Found by a Siege security audit (2026-07-15, SIEGE-2026-07-15-002): --force had zero code-level friction beyond the flag itself, so a confused or prompt-injected agent could force through an unprotected merge or a tampered template with no audit trail. --force now requires --force-reason, echoed back in the apply result."
78
+ },
79
+ {
80
+ "id": "npx-must-pin-latest",
81
+ "pattern": "always with the explicit\\s+`@latest` tag, never bare",
82
+ "rationale": "Self-discovered 2026-07-15 during PAT-wiring dogfood on claude-skills itself: a bare `npx -y @natjswenson/shipflow <command>` silently resolved a stale global install (0.2.0) instead of fetching the current version from the registry, with no warning — meaning every fix through 0.2.5 (including the Critical template-injection fix) was silently skipped. Every CLI invocation in this file must pin @latest."
83
+ },
84
+ {
85
+ "id": "ambiguous-pattern-no-silent-pick",
86
+ "pattern": "present all 3 (templates|patterns).{0,60}ask the user to choose",
87
+ "rationale": "Ambiguous/greenfield autodetection must never silently pick a workflow pattern — mirrors the existing protectionOwner disambiguation precedent (ambiguous-protection-owner-prompt)."
78
88
  }
79
89
  ],
80
- "cli_commands_referenced": ["detect", "plan", "apply", "releases", "release-dispatch", "rename-default-branch"]
90
+ "cli_commands_referenced": ["detect", "plan", "apply", "releases", "release-dispatch", "rename-default-branch"],
91
+ "_baseline_comment": "Baseline eval sets: deterministic, offline, $0 checks pinned against artifacts from real local runs. These gate `ci / shipflow` alongside the unit tests. Every entry names the test that enforces it so tools/lint_baseline.py can verify the declaration is not aspirational.",
92
+ "baseline": [
93
+ {
94
+ "id": "dogfood-rendered-workflow-golden",
95
+ "kind": "golden",
96
+ "test": "tests/baseline.test.mjs",
97
+ "fixtures": [
98
+ "evals/baseline/dogfood-shipflow.json",
99
+ "evals/baseline/dogfood-dev-to-main-automerge.yml"
100
+ ],
101
+ "update_command": "node evals/baseline/update.mjs",
102
+ "rationale": "This monorepo dogfoods shipflow on itself, so .github/shipflow.json and the workflow rendered from it are a genuine input/output pair from a real `apply` run, and that config's renderedTemplateHashes is the receipt shipflow wrote at the time. The baseline re-runs config -> params -> render and asserts byte equality against the frozen golden, that the golden's sha256 still equals the recorded receipt, and that the frozen golden still equals the repo's live committed workflow (so the fixture cannot quietly go stale). Byte-exactness is correct here and nowhere else in the baseline suite: for a workflow file, one changed character is a behavior change to the repo's merge automation. The paired negative assertions (quote injection rejected, missing param throws, merge method actually reaches the output) stop the golden from passing while the validators rot -- the missing-param one found a real bug on its first run: a present-but-undefined param rendered the literal string 'undefined' into `branches: [...]`, installing a workflow that could never fire."
103
+ }
104
+ ]
81
105
  }
@@ -0,0 +1,65 @@
1
+ name: auto-merge hotfix/* 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
+ # GH_TOKEN uses config.release.releaseCredential, NOT a hardcoded
10
+ # secrets.GITHUB_TOKEN, because of GitHub's loop-prevention rule: a PR
11
+ # auto-merged via `gh pr merge --auto` run under the default GITHUB_TOKEN
12
+ # completes (later, asynchronously, once checks pass) attributed to the
13
+ # github-actions[bot] identity — and a `pull_request: closed` event
14
+ # resulting from that bot-attributed merge does NOT trigger this or any
15
+ # other workflow's `on: pull_request` handlers. Confirmed empirically:
16
+ # an identical PR merged by a real, PAT-authenticated actor fired the
17
+ # closed-event trigger immediately; one completed by GITHUB_TOKEN-enabled
18
+ # auto-merge fired no run at all. releaseCredential must therefore name a
19
+ # real PAT/App-installation-token secret (not GITHUB_TOKEN) for
20
+ # label-release-pending to ever actually run.
21
+
22
+ on:
23
+ pull_request:
24
+ types: [opened, reopened, synchronize, closed]
25
+ branches: [{{MAIN_BRANCH}}]
26
+
27
+ permissions:
28
+ contents: write
29
+ pull-requests: write
30
+
31
+ jobs:
32
+ # Enables native GitHub auto-merge on open/reopen/synchronize — this job
33
+ # does NOT wait for checks itself; it turns on auto-merge and exits. The
34
+ # actual merge happens asynchronously, later, whenever GitHub's own
35
+ # required-checks gate is satisfied (see the design's discussion of why a
36
+ # bespoke polling/blocking job was rejected).
37
+ auto-merge:
38
+ if: >-
39
+ github.event.action != 'closed' &&
40
+ startsWith(github.event.pull_request.head.ref, '{{HOTFIX_BRANCH_PREFIX}}')
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - name: Enable auto-merge
44
+ run: gh pr merge --auto {{MERGE_FLAG}} "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}"
45
+ env:
46
+ GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
47
+
48
+ # Fires once, when the hotfix PR actually merges (a separate event from
49
+ # the job above, which only *enables* auto-merge). Applies a durable
50
+ # release-pending label so a later, disconnected shipflow invocation can
51
+ # find this promotion and ask about a release — the merge completion has
52
+ # no live Claude session attached to react to it directly.
53
+ label-release-pending:
54
+ if: >-
55
+ github.event.action == 'closed' &&
56
+ github.event.pull_request.merged == true &&
57
+ startsWith(github.event.pull_request.head.ref, '{{HOTFIX_BRANCH_PREFIX}}')
58
+ runs-on: ubuntu-latest
59
+ permissions:
60
+ pull-requests: write
61
+ steps:
62
+ - name: Apply release-pending label
63
+ run: gh pr edit "${{ github.event.pull_request.number }}" --add-label release-pending --repo "${{ github.repository }}"
64
+ env:
65
+ GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
@@ -0,0 +1,54 @@
1
+ name: merge {{MAIN_BRANCH}} back to {{DEV_BRANCH}} after a hotfix
2
+
3
+ # Rendered by shipflow's apply.mjs. See dev-to-main-automerge.yml.tmpl's header
4
+ # comment for the general hand-edit/renderedTemplateHashes discipline — same rules
5
+ # apply here.
6
+ #
7
+ # GitFlow's hotfix branches merge into BOTH main and develop — this is the
8
+ # defining semantic of a hotfix, not an optional convention (confirmed via
9
+ # research: git-flow CLI's own `hotfix finish` command does both merges
10
+ # atomically). This job runs the develop-side merge automatically once the
11
+ # main-side merge (hotfix-automerge.yml) lands.
12
+
13
+ on:
14
+ pull_request:
15
+ types: [closed]
16
+ branches: [{{MAIN_BRANCH}}]
17
+
18
+ permissions:
19
+ contents: write
20
+
21
+ jobs:
22
+ merge-back-to-dev:
23
+ if: >-
24
+ github.event.pull_request.merged == true &&
25
+ startsWith(github.event.pull_request.head.ref, '{{HOTFIX_BRANCH_PREFIX}}')
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ with:
30
+ fetch-depth: 0
31
+ token: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
32
+ - name: Attempt a clean merge of {{MAIN_BRANCH}} into {{DEV_BRANCH}}
33
+ id: merge
34
+ continue-on-error: true
35
+ run: |
36
+ git config user.name "shipflow"
37
+ git config user.email "shipflow@users.noreply.github.com"
38
+ git checkout {{DEV_BRANCH}}
39
+ git merge origin/{{MAIN_BRANCH}} --no-edit
40
+ git push origin {{DEV_BRANCH}}
41
+ env:
42
+ GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
43
+ - name: On any failure (conflict, rejected push, or otherwise), open a PR for manual resolution
44
+ if: steps.merge.outcome == 'failure'
45
+ run: |
46
+ git merge --abort || true
47
+ BRANCH="shipflow/merge-back-{{MAIN_BRANCH}}-to-{{DEV_BRANCH}}-${{ github.run_id }}"
48
+ git checkout -b "$BRANCH" origin/{{MAIN_BRANCH}}
49
+ git push origin "$BRANCH"
50
+ gh pr create --base {{DEV_BRANCH}} --head "$BRANCH" \
51
+ --title "Manual merge-back needed: {{MAIN_BRANCH}} -> {{DEV_BRANCH}}" \
52
+ --body "Automatic merge-back failed (conflict or rejected push) after a hotfix/release merged to {{MAIN_BRANCH}}. Resolve and merge this PR manually."
53
+ env:
54
+ GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
@@ -0,0 +1,65 @@
1
+ name: auto-merge release/* 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
+ # GH_TOKEN uses config.release.releaseCredential, NOT a hardcoded
10
+ # secrets.GITHUB_TOKEN, because of GitHub's loop-prevention rule: a PR
11
+ # auto-merged via `gh pr merge --auto` run under the default GITHUB_TOKEN
12
+ # completes (later, asynchronously, once checks pass) attributed to the
13
+ # github-actions[bot] identity — and a `pull_request: closed` event
14
+ # resulting from that bot-attributed merge does NOT trigger this or any
15
+ # other workflow's `on: pull_request` handlers. Confirmed empirically:
16
+ # an identical PR merged by a real, PAT-authenticated actor fired the
17
+ # closed-event trigger immediately; one completed by GITHUB_TOKEN-enabled
18
+ # auto-merge fired no run at all. releaseCredential must therefore name a
19
+ # real PAT/App-installation-token secret (not GITHUB_TOKEN) for
20
+ # label-release-pending to ever actually run.
21
+
22
+ on:
23
+ pull_request:
24
+ types: [opened, reopened, synchronize, closed]
25
+ branches: [{{MAIN_BRANCH}}]
26
+
27
+ permissions:
28
+ contents: write
29
+ pull-requests: write
30
+
31
+ jobs:
32
+ # Enables native GitHub auto-merge on open/reopen/synchronize — this job
33
+ # does NOT wait for checks itself; it turns on auto-merge and exits. The
34
+ # actual merge happens asynchronously, later, whenever GitHub's own
35
+ # required-checks gate is satisfied (see the design's discussion of why a
36
+ # bespoke polling/blocking job was rejected).
37
+ auto-merge:
38
+ if: >-
39
+ github.event.action != 'closed' &&
40
+ startsWith(github.event.pull_request.head.ref, '{{RELEASE_BRANCH_PREFIX}}')
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - name: Enable auto-merge
44
+ run: gh pr merge --auto {{MERGE_FLAG}} "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}"
45
+ env:
46
+ GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
47
+
48
+ # Fires once, when the release PR actually merges (a separate event from
49
+ # the job above, which only *enables* auto-merge). Applies a durable
50
+ # release-pending label so a later, disconnected shipflow invocation can
51
+ # find this promotion and ask about a release — the merge completion has
52
+ # no live Claude session attached to react to it directly.
53
+ label-release-pending:
54
+ if: >-
55
+ github.event.action == 'closed' &&
56
+ github.event.pull_request.merged == true &&
57
+ startsWith(github.event.pull_request.head.ref, '{{RELEASE_BRANCH_PREFIX}}')
58
+ runs-on: ubuntu-latest
59
+ permissions:
60
+ pull-requests: write
61
+ steps:
62
+ - name: Apply release-pending label
63
+ run: gh pr edit "${{ github.event.pull_request.number }}" --add-label release-pending --repo "${{ github.repository }}"
64
+ env:
65
+ GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
@@ -0,0 +1,54 @@
1
+ name: merge {{MAIN_BRANCH}} back to {{DEV_BRANCH}} after a release
2
+
3
+ # Rendered by shipflow's apply.mjs. See dev-to-main-automerge.yml.tmpl's header
4
+ # comment for the general hand-edit/renderedTemplateHashes discipline — same rules
5
+ # apply here.
6
+ #
7
+ # GitFlow's release branches merge into BOTH main and develop — this is the
8
+ # defining semantic of a release, not an optional convention (confirmed via
9
+ # research: git-flow CLI's own `release finish` command does both merges
10
+ # atomically, mirroring hotfix finish). This job runs the develop-side merge
11
+ # automatically once the main-side merge (release-automerge.yml) lands.
12
+
13
+ on:
14
+ pull_request:
15
+ types: [closed]
16
+ branches: [{{MAIN_BRANCH}}]
17
+
18
+ permissions:
19
+ contents: write
20
+
21
+ jobs:
22
+ merge-back-to-dev:
23
+ if: >-
24
+ github.event.pull_request.merged == true &&
25
+ startsWith(github.event.pull_request.head.ref, '{{RELEASE_BRANCH_PREFIX}}')
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ with:
30
+ fetch-depth: 0
31
+ token: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
32
+ - name: Attempt a clean merge of {{MAIN_BRANCH}} into {{DEV_BRANCH}}
33
+ id: merge
34
+ continue-on-error: true
35
+ run: |
36
+ git config user.name "shipflow"
37
+ git config user.email "shipflow@users.noreply.github.com"
38
+ git checkout {{DEV_BRANCH}}
39
+ git merge origin/{{MAIN_BRANCH}} --no-edit
40
+ git push origin {{DEV_BRANCH}}
41
+ env:
42
+ GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
43
+ - name: On any failure (conflict, rejected push, or otherwise), open a PR for manual resolution
44
+ if: steps.merge.outcome == 'failure'
45
+ run: |
46
+ git merge --abort || true
47
+ BRANCH="shipflow/merge-back-{{MAIN_BRANCH}}-to-{{DEV_BRANCH}}-${{ github.run_id }}"
48
+ git checkout -b "$BRANCH" origin/{{MAIN_BRANCH}}
49
+ git push origin "$BRANCH"
50
+ gh pr create --base {{DEV_BRANCH}} --head "$BRANCH" \
51
+ --title "Manual merge-back needed: {{MAIN_BRANCH}} -> {{DEV_BRANCH}}" \
52
+ --body "Automatic merge-back failed (conflict or rejected push) after a hotfix/release merged to {{MAIN_BRANCH}}. Resolve and merge this PR manually."
53
+ env:
54
+ GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
@@ -0,0 +1,66 @@
1
+ name: auto-merge 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
+ # GitHub Flow has no separate "promotion" branch — every PR into {{MAIN_BRANCH}}
10
+ # is eligible for auto-merge and every merge is release-worthy, so unlike
11
+ # dev-main-promotion's template, neither job here restricts on head.ref.
12
+ #
13
+ # GH_TOKEN uses config.release.releaseCredential, NOT a hardcoded
14
+ # secrets.GITHUB_TOKEN, because of GitHub's loop-prevention rule: a PR
15
+ # auto-merged via `gh pr merge --auto` run under the default GITHUB_TOKEN
16
+ # completes (later, asynchronously, once checks pass) attributed to the
17
+ # github-actions[bot] identity — and a `pull_request: closed` event
18
+ # resulting from that bot-attributed merge does NOT trigger this or any
19
+ # other workflow's `on: pull_request` handlers. Confirmed empirically:
20
+ # an identical PR merged by a real, PAT-authenticated actor fired the
21
+ # closed-event trigger immediately; one completed by GITHUB_TOKEN-enabled
22
+ # auto-merge fired no run at all. releaseCredential must therefore name a
23
+ # real PAT/App-installation-token secret (not GITHUB_TOKEN) for
24
+ # label-release-pending to ever actually run.
25
+
26
+ on:
27
+ pull_request:
28
+ types: [opened, reopened, synchronize, closed]
29
+ branches: [{{MAIN_BRANCH}}]
30
+
31
+ permissions:
32
+ contents: write
33
+ pull-requests: write
34
+
35
+ jobs:
36
+ # Enables native GitHub auto-merge on open/reopen/synchronize — this job
37
+ # does NOT wait for checks itself; it turns on auto-merge and exits. The
38
+ # actual merge happens asynchronously, later, whenever GitHub's own
39
+ # required-checks gate is satisfied (see the design's discussion of why a
40
+ # bespoke polling/blocking job was rejected).
41
+ auto-merge:
42
+ if: github.event.action != 'closed'
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - name: Enable auto-merge
46
+ run: gh pr merge --auto {{MERGE_FLAG}} "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}"
47
+ env:
48
+ GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
49
+
50
+ # Fires once, when a PR actually merges (a separate event from the job
51
+ # above, which only *enables* auto-merge). Applies a durable
52
+ # release-pending label so a later, disconnected shipflow invocation can
53
+ # find this merge and ask about a release — the merge completion has no
54
+ # live Claude session attached to react to it directly.
55
+ label-release-pending:
56
+ if: >-
57
+ github.event.action == 'closed' &&
58
+ github.event.pull_request.merged == true
59
+ runs-on: ubuntu-latest
60
+ permissions:
61
+ pull-requests: write
62
+ steps:
63
+ - name: Apply release-pending label
64
+ run: gh pr edit "${{ github.event.pull_request.number }}" --add-label release-pending --repo "${{ github.repository }}"
65
+ env:
66
+ GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}