@natjswenson/shipflow 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.6",
3
+ "version": "0.3.1",
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",
@@ -41,7 +41,9 @@
41
41
  },
42
42
  "scripts": {
43
43
  "test": "node --test \"tests/**/*.test.mjs\"",
44
- "audit": "npm audit --audit-level=moderate"
44
+ "audit": "npm audit --audit-level=moderate",
45
+ "prepack": "cp ../../README.md ../../LICENSE ../../CHANGELOG.md .",
46
+ "postpack": "rm -f README.md LICENSE CHANGELOG.md"
45
47
  },
46
48
  "devDependencies": {
47
49
  "yaml": "^2.9.0"
@@ -80,7 +80,26 @@
80
80
  "id": "npx-must-pin-latest",
81
81
  "pattern": "always with the explicit\\s+`@latest` tag, never bare",
82
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)."
83
88
  }
84
89
  ],
85
- "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
+ ]
86
105
  }