@natjswenson/shipflow 0.2.6 → 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 +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 +1 -1
- 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/SKILL.md
CHANGED
|
@@ -41,9 +41,18 @@ user; the CLI is the only thing that *does*.
|
|
|
41
41
|
```
|
|
42
42
|
npx -y @natjswenson/shipflow@latest detect --repo <path> --main main --dev dev
|
|
43
43
|
```
|
|
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"
|
|
45
|
-
|
|
46
|
-
2. **Resolve a
|
|
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 case — no 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:
|
|
47
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.
|
|
48
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:
|
|
49
58
|
```
|
|
@@ -51,41 +60,41 @@ user; the CLI is the only thing that *does*.
|
|
|
51
60
|
```
|
|
52
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.
|
|
53
62
|
|
|
54
|
-
|
|
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.
|
|
55
64
|
|
|
56
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.
|
|
57
66
|
|
|
58
|
-
|
|
67
|
+
5. **Resolve `protectionOwner`:**
|
|
59
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.
|
|
60
69
|
- `"shipflow"` → tell the user no existing branch protection was found and shipflow will own it going forward.
|
|
61
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.
|
|
62
71
|
|
|
63
|
-
|
|
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.
|
|
64
73
|
|
|
65
|
-
|
|
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.
|
|
66
75
|
|
|
67
|
-
|
|
76
|
+
8. **Show the plan.** Run:
|
|
68
77
|
```
|
|
69
78
|
npx -y @natjswenson/shipflow@latest plan --repo <path>
|
|
70
79
|
```
|
|
71
|
-
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
|
|
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).
|
|
72
81
|
|
|
73
|
-
|
|
82
|
+
9. **Dry-run apply** (optional sanity check, same output shape as the real apply but nothing is mutated):
|
|
74
83
|
```
|
|
75
84
|
npx -y @natjswenson/shipflow@latest apply --repo <path> --dry-run
|
|
76
85
|
```
|
|
77
86
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
83
92
|
|
|
84
|
-
|
|
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.
|
|
85
94
|
|
|
86
95
|
## Re-run / audit
|
|
87
96
|
|
|
88
|
-
Same as steps 1,
|
|
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.
|
|
89
98
|
|
|
90
99
|
## Check pending releases (`manual-gate` ask-flow)
|
|
91
100
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
101
|
+
const templateSources = buildTemplateSources(config);
|
|
92
102
|
let plan;
|
|
93
103
|
try {
|
|
94
|
-
plan = computePlan(repoState, config,
|
|
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
|
|
162
|
+
const templateSources = buildTemplateSources(config);
|
|
153
163
|
let plan;
|
|
154
164
|
try {
|
|
155
|
-
plan = computePlan(repoState, config,
|
|
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
|
|
118
|
-
|
|
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
|
-
|
|
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
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
|
}
|
|
@@ -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}} }}
|
|
File without changes
|