@natjswenson/shipflow 0.2.6 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +320 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/SKILL.md +26 -17
- package/bin/shipflow.js +16 -6
- package/lib/apply.mjs +19 -9
- package/lib/detect.mjs +78 -4
- package/lib/pattern-registry.mjs +79 -0
- package/lib/patterns/dev-main-promotion/index.mjs +49 -0
- package/lib/patterns/gitflow/index.mjs +55 -0
- package/lib/patterns/github-flow/index.mjs +43 -0
- package/lib/plan.mjs +54 -48
- package/lib/render.mjs +42 -2
- package/package.json +4 -2
- package/skill-invariants.json +20 -1
- package/templates/gitflow/hotfix-automerge.yml.tmpl +65 -0
- package/templates/gitflow/hotfix-merge-back.yml.tmpl +54 -0
- package/templates/gitflow/release-automerge.yml.tmpl +65 -0
- package/templates/gitflow/release-merge-back.yml.tmpl +54 -0
- package/templates/github-flow/main-automerge.yml.tmpl +66 -0
- /package/templates/{dev-to-main-automerge.yml.tmpl → dev-main-promotion/dev-to-main-automerge.yml.tmpl} +0 -0
package/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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
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-
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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 ${
|
|
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.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
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?.[
|
|
91
|
-
const lastRenderedHash = config.renderedTemplateHashes?.[
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
115
|
+
id: planId,
|
|
110
116
|
kind: 'update',
|
|
111
|
-
path:
|
|
112
|
-
description: `${
|
|
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
|
-
|
|
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.
|
|
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"
|
package/skill-invariants.json
CHANGED
|
@@ -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
|
}
|