@natjswenson/shipflow 0.2.1 → 0.2.3
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 +13 -9
- package/bin/shipflow.js +12 -2
- package/config.example.json +1 -1
- package/lib/plan.mjs +5 -0
- package/lib/render.mjs +42 -2
- package/package.json +1 -1
- package/skill-invariants.json +10 -0
- package/templates/dev-to-main-automerge.yml.tmpl +15 -2
package/SKILL.md
CHANGED
|
@@ -51,30 +51,32 @@ user; the CLI is the only thing that *does*.
|
|
|
51
51
|
- `"shipflow"` → tell the user no existing branch protection was found and shipflow will own it going forward.
|
|
52
52
|
- `"ambiguous"` → **branch protection exists but no settings-as-code artifact was found** (e.g. hand-configured via the GitHub UI). Do NOT silently pick either value — this is exactly the false-positive failure mode a prior design iteration got wrong. Ask explicitly: *"Branch protection exists on this repo but isn't managed as code — should shipflow take ownership of it, or keep managing it externally even though no artifact was found?"* Record whichever the user picks.
|
|
53
53
|
|
|
54
|
-
5. **
|
|
54
|
+
5. **Resolve `release.releaseCredential` — never default it to `GITHUB_TOKEN`.** The rendered `dev-to-main-automerge.yml`'s `GH_TOKEN` comes from this secret name. A PR auto-merged under `secrets.GITHUB_TOKEN` completes (once checks pass) attributed to the `github-actions[bot]` identity, and GitHub's loop-prevention rule means that bot-attributed merge's `pull_request: closed` event **never triggers this or any other workflow** — so `label-release-pending` silently never runs, and the entire manual-gate release-ask flow never has anything to find. This was confirmed empirically, not theoretically: an otherwise-identical PR merged by a real, PAT-authenticated actor fired the closed-event trigger within 2 seconds; one completed by `GITHUB_TOKEN`-enabled auto-merge fired no run at all, even after 100+ seconds. Ask the user to create a fine-grained PAT (or GitHub App installation token) scoped to this repo with `contents: write` + `pull-requests: write`, and to store it as a repo secret themselves (e.g. `gh secret set <NAME> --repo <owner>/<repo>`, run in *their own* shell so the token value never passes through the agent or the transcript). Record only the secret's *name* in `release.releaseCredential` — never its value.
|
|
55
55
|
|
|
56
|
-
6. **
|
|
56
|
+
6. **Present the interview summary and write `.github/shipflow.json`.** Before writing anything, show the user the resolved branch names, `requiredChecks`, `protectionOwner`, and `release.releaseCredential` together in one place and wait for explicit confirmation — this is the checkpoint called out at the top of this section. Then write the config in the target repo (never inside the skill package) using `config.example.json` as the template, with `release.mode: "manual-gate"` (the only implemented mode in this version — see Auto mode, below). Tell the user `.github/shipflow.json` is committed policy and should be `git add`/committed — ideally in the same commit as the rendered auto-merge workflow, once step 10 produces one.
|
|
57
|
+
|
|
58
|
+
7. **Show the plan.** Run:
|
|
57
59
|
```
|
|
58
60
|
npx -y @natjswenson/shipflow plan --repo <path>
|
|
59
61
|
```
|
|
60
|
-
This prints `{ plan, stateHash }`. Present `plan.creates`/`plan.updates`/`plan.noops` to the user in plain language — what will be created, what will change, what's already correct. **Wait for explicit confirmation before proceeding.** If any entry has `handEditDetected: true`, call it out specifically and ask whether to override (see step
|
|
62
|
+
This prints `{ plan, stateHash }`. Present `plan.creates`/`plan.updates`/`plan.noops` to the user in plain language — what will be created, what will change, what's already correct. **Wait for explicit confirmation before proceeding.** If any entry has `handEditDetected: true`, call it out specifically and ask whether to override (see step 9).
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
8. **Dry-run apply** (optional sanity check, same output shape as the real apply but nothing is mutated):
|
|
63
65
|
```
|
|
64
66
|
npx -y @natjswenson/shipflow apply --repo <path> --dry-run
|
|
65
67
|
```
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
9. **Apply for real**, passing the `stateHash` from step 7's plan output as `--expect-state-hash` — this is the TOCTOU guard: if repo state drifted between the plan you showed the user and this call, `apply` refuses to mutate anything and tells you to re-plan.
|
|
68
70
|
```
|
|
69
|
-
npx -y @natjswenson/shipflow apply --repo <path> --expect-state-hash <hash-from-step-
|
|
71
|
+
npx -y @natjswenson/shipflow apply --repo <path> --expect-state-hash <hash-from-step-7>
|
|
70
72
|
```
|
|
71
|
-
If a `handEditDetected` entry was confirmed for override in step
|
|
73
|
+
If a `handEditDetected` entry was confirmed for override in step 7, pass `--force <entry-id>` (repeatable — one flag per confirmed entry id, never a blanket override).
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
10. **Report the result.** Read `applied`/`skipped`/`errors` from the response. A `skipped` entry can be a deliberate refusal (empty checks, hand-edit) or an environment limitation shipflow can't do anything about (e.g. a deletion-ruleset skipped because the repo is private and not on a paid GitHub tier) — read each `reason` and relay it plainly rather than treating every `skipped` entry the same. If `renderedTemplateHashes` is non-empty, update `.github/shipflow.json`'s `renderedTemplateHashes` field with those values and tell the user to commit the config change *and* the rendered workflow file **together, in the same commit** — a split commit is exactly what causes a false `handEditDetected` on a clean checkout later.
|
|
74
76
|
|
|
75
77
|
## Re-run / audit
|
|
76
78
|
|
|
77
|
-
Same as steps 1,
|
|
79
|
+
Same as steps 1, 7, 8, 9, 10 above, skipping the interview (branch names/checks/protectionOwner/releaseCredential are already recorded in `.github/shipflow.json` — read it, don't re-ask, unless the user explicitly says they want to reconfigure). If `plan.creates`/`plan.updates` is non-empty, that's drift since the last apply — show it and confirm before applying, exactly as in first-run setup.
|
|
78
80
|
|
|
79
81
|
## Check pending releases (`manual-gate` ask-flow)
|
|
80
82
|
|
|
@@ -106,12 +108,14 @@ This is a **separate, later invocation** from the one that ran the promotion's `
|
|
|
106
108
|
- **`handEditDetected`:** a template file's on-disk content doesn't match what shipflow last rendered *or* what it would freshly render — someone hand-edited it. Never silently pass `--force` for this; always show the user what changed and get explicit confirmation per entry.
|
|
107
109
|
- **TOCTOU abort:** if `apply` returns a `toctou` error, repo state changed between plan and apply — re-run the plan step, don't retry the same `--expect-state-hash`.
|
|
108
110
|
- **`gh auth` failures:** surface these immediately; branch protection and rulesets need repo-admin scope. Don't proceed partway through a plan on missing auth.
|
|
111
|
+
- **`release.releaseCredential` left as (or defaulted to) `GITHUB_TOKEN`:** auto-merge and the required-check gate still work, but `label-release-pending` will silently never run — a `GITHUB_TOKEN`-attributed auto-merge's `pull_request: closed` event never triggers it, so no promotion will ever surface via `shipflow releases`. This fails silently, not loudly — there's no error to catch it — so it must be caught at setup time (step 5) rather than discovered later. If a user reports "releases never show up," check this first.
|
|
109
112
|
|
|
110
113
|
## Security rules
|
|
111
114
|
|
|
112
115
|
- All `gh`/`git` invocations in the CLI are argv-style (`spawnSync` with an args array, no shell) — never construct a shell command string from user input when extending this skill.
|
|
113
116
|
- `.github/shipflow.json` is committed policy, not secrets — never write credential *values* into it, only the *name* of a secret (`release.releaseCredential`).
|
|
114
117
|
- Never write shipflow's config anywhere other than `.github/shipflow.json` in the target repo.
|
|
118
|
+
- **`renderTemplate` validates every substituted value before writing YAML, and this must never be weakened.** `config.branches.dev`/`main` and `release.releaseCredential` are editable by anyone with repo *write* access (not just the admin who ran setup), yet they land in single-quoted YAML string comparisons and a `${{ secrets.X }}` expression with pure string substitution. An unvalidated branch name containing a quote (e.g. `dev' || 'x'=='x`) makes the auto-merge job's `if:` condition unconditionally true — auto-merge would enable on *any* PR to main, not just genuine dev-branch promotions; a value containing a newline can inject arbitrary new YAML steps into the committed, then-executed workflow. If you add a new substitution token, it needs a validator in `TOKEN_VALIDATORS` before it ships — never assume a config field is pre-sanitized.
|
|
115
119
|
|
|
116
120
|
## Edge cases
|
|
117
121
|
|
package/bin/shipflow.js
CHANGED
|
@@ -85,7 +85,12 @@ function cmdPlan(args) {
|
|
|
85
85
|
releaseCredentialName: config.release?.releaseCredential ?? null,
|
|
86
86
|
});
|
|
87
87
|
const templateSource = readFileSync(TEMPLATE_PATH, 'utf8');
|
|
88
|
-
|
|
88
|
+
let plan;
|
|
89
|
+
try {
|
|
90
|
+
plan = computePlan(repoState, config, templateSource);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return fail(`plan: ${e.message}`);
|
|
93
|
+
}
|
|
89
94
|
printJson({ plan, stateHash: repoState.stateHash });
|
|
90
95
|
}
|
|
91
96
|
|
|
@@ -122,7 +127,12 @@ function cmdApply(args) {
|
|
|
122
127
|
releaseCredentialName: config.release?.releaseCredential ?? null,
|
|
123
128
|
});
|
|
124
129
|
const templateSource = readFileSync(TEMPLATE_PATH, 'utf8');
|
|
125
|
-
|
|
130
|
+
let plan;
|
|
131
|
+
try {
|
|
132
|
+
plan = computePlan(repoState, config, templateSource);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return fail(`apply: ${e.message}`);
|
|
135
|
+
}
|
|
126
136
|
|
|
127
137
|
// The CLI-level TOCTOU gate: compare the freshly-detected state against
|
|
128
138
|
// what the user confirmed at plan time (--expect-state-hash, captured
|
package/config.example.json
CHANGED
package/lib/plan.mjs
CHANGED
|
@@ -79,6 +79,11 @@ function computeTemplatePlanEntry(repoState, config, templateSource) {
|
|
|
79
79
|
devBranch: config.branches.dev,
|
|
80
80
|
mainBranch: config.branches.main,
|
|
81
81
|
mergeFlag: mergeMethodToFlag(config.mergeMethod?.devToMainMethod),
|
|
82
|
+
// Must be a real PAT/App-installation-token secret name, not
|
|
83
|
+
// "GITHUB_TOKEN" — see the template's header comment for why a
|
|
84
|
+
// GITHUB_TOKEN-attributed auto-merge never fires the closed-event
|
|
85
|
+
// label job at all.
|
|
86
|
+
releaseCredentialSecret: config.release?.releaseCredential ?? 'GITHUB_TOKEN',
|
|
82
87
|
};
|
|
83
88
|
const renderedContent = renderTemplate(templateSource, params);
|
|
84
89
|
const freshHash = sha256(renderedContent);
|
package/lib/render.mjs
CHANGED
|
@@ -14,7 +14,35 @@
|
|
|
14
14
|
|
|
15
15
|
const TOKEN_RE = /\{\{(\w+)\}\}/g;
|
|
16
16
|
|
|
17
|
-
//
|
|
17
|
+
// DEV_BRANCH/MAIN_BRANCH land inside single-quoted YAML string comparisons
|
|
18
|
+
// (`... == '{{DEV_BRANCH}}'`) and RELEASE_CREDENTIAL_SECRET lands inside a
|
|
19
|
+
// `${{ secrets.X }}` GitHub Actions expression — this function does pure
|
|
20
|
+
// string substitution with NO awareness of YAML or GHA-expression grammar,
|
|
21
|
+
// so any of these three params can break out of their quoting context if
|
|
22
|
+
// not validated first. Concretely: devBranch = "dev' || 'x'=='x" renders
|
|
23
|
+
// the auto-merge job's `if:` condition to `... == 'dev' || 'x'=='x'`,
|
|
24
|
+
// which is unconditionally true — enabling auto-merge on ANY pull request
|
|
25
|
+
// to main, not just genuine dev-branch promotions. A value containing a
|
|
26
|
+
// newline in any of the three can inject entirely new YAML keys/steps into
|
|
27
|
+
// the committed, then-executed workflow file. This is not a theoretical
|
|
28
|
+
// input: config.branches.{main,dev} and config.release.releaseCredential
|
|
29
|
+
// come from .github/shipflow.json, a file anyone with repo WRITE access
|
|
30
|
+
// (not just the admin who ran shipflow's setup) can edit — a strictly
|
|
31
|
+
// lower trust level than the admin-scoped `gh` credential the rendered
|
|
32
|
+
// workflow runs with. Found via a Siege security audit (2026-07-15).
|
|
33
|
+
const UNSAFE_YAML_STRING_RE = /['\r\n]/;
|
|
34
|
+
// GitHub Actions secret names: letters, digits, underscore; cannot start
|
|
35
|
+
// with a digit (case-insensitivity aside, this is the full safe charset).
|
|
36
|
+
const SAFE_SECRET_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
37
|
+
|
|
38
|
+
const TOKEN_VALIDATORS = Object.freeze({
|
|
39
|
+
DEV_BRANCH: (v) => !UNSAFE_YAML_STRING_RE.test(v),
|
|
40
|
+
MAIN_BRANCH: (v) => !UNSAFE_YAML_STRING_RE.test(v),
|
|
41
|
+
MERGE_FLAG: () => true, // closed enum from mergeMethodToFlag — never attacker-shaped
|
|
42
|
+
RELEASE_CREDENTIAL_SECRET: (v) => SAFE_SECRET_NAME_RE.test(v),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// params: { devBranch, mainBranch, mergeFlag, releaseCredentialSecret }
|
|
18
46
|
// mergeFlag is one of "--merge" | "--squash" | "--rebase", derived from
|
|
19
47
|
// config.mergeMethod.devToMainMethod by the caller (not this function —
|
|
20
48
|
// mapping method name -> gh flag is a config-schema concern, kept out of
|
|
@@ -22,17 +50,28 @@ const TOKEN_RE = /\{\{(\w+)\}\}/g;
|
|
|
22
50
|
// config shape, only of the template's token names).
|
|
23
51
|
export function renderTemplate(templateSource, params) {
|
|
24
52
|
const missing = [];
|
|
53
|
+
const unsafe = [];
|
|
25
54
|
const rendered = templateSource.replace(TOKEN_RE, (_, name) => {
|
|
26
55
|
const key = TOKEN_TO_PARAM[name];
|
|
27
56
|
if (!key || !(key in params)) {
|
|
28
57
|
missing.push(name);
|
|
29
58
|
return `{{${name}}}`;
|
|
30
59
|
}
|
|
31
|
-
|
|
60
|
+
const value = String(params[key]);
|
|
61
|
+
const validate = TOKEN_VALIDATORS[name];
|
|
62
|
+
if (validate && !validate(value)) {
|
|
63
|
+
unsafe.push(name);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
32
66
|
});
|
|
33
67
|
if (missing.length > 0) {
|
|
34
68
|
throw new Error(`renderTemplate: missing param(s) for token(s): ${missing.join(', ')}`);
|
|
35
69
|
}
|
|
70
|
+
if (unsafe.length > 0) {
|
|
71
|
+
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)`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
36
75
|
return rendered;
|
|
37
76
|
}
|
|
38
77
|
|
|
@@ -40,6 +79,7 @@ const TOKEN_TO_PARAM = Object.freeze({
|
|
|
40
79
|
DEV_BRANCH: 'devBranch',
|
|
41
80
|
MAIN_BRANCH: 'mainBranch',
|
|
42
81
|
MERGE_FLAG: 'mergeFlag',
|
|
82
|
+
RELEASE_CREDENTIAL_SECRET: 'releaseCredentialSecret',
|
|
43
83
|
});
|
|
44
84
|
|
|
45
85
|
export function mergeMethodToFlag(devToMainMethod) {
|
package/package.json
CHANGED
package/skill-invariants.json
CHANGED
|
@@ -55,6 +55,16 @@
|
|
|
55
55
|
"id": "ci-scaffold-never-overwrites",
|
|
56
56
|
"pattern": "[Nn]ever silently overwrite an existing workflow file",
|
|
57
57
|
"rationale": "Agent-driven CI scaffolding (added after 1.00s had no pull_request-triggered CI at all) must never clobber a workflow file that's already there — same confirm-before-write discipline as every other mutation in this skill, applied to a step the CLI itself never touches."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "release-credential-never-github-token",
|
|
61
|
+
"pattern": "never default it to `GITHUB_TOKEN`",
|
|
62
|
+
"rationale": "Found by dogfooding on claude-skills itself (2026-07-15): a PR auto-merged under secrets.GITHUB_TOKEN completes attributed to github-actions[bot], and GitHub's loop-prevention rule means that bot-attributed merge's pull_request:closed event never triggers label-release-pending — the entire manual-gate release-ask flow silently never has anything to find unless releaseCredential names a real PAT."
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": "render-template-validates-substitutions",
|
|
66
|
+
"pattern": "never assume a config field is pre-sanitized",
|
|
67
|
+
"rationale": "Found by a Siege security audit (2026-07-15): renderTemplate did pure string substitution with zero escaping, so a repo-write-level (not admin-level) edit to .github/shipflow.json's branch names or release.releaseCredential could break out of the rendered YAML's quoting and make the auto-merge job's if: condition unconditionally true, or inject arbitrary new workflow steps — a privilege escalation via the credential the rendered workflow runs with."
|
|
58
68
|
}
|
|
59
69
|
],
|
|
60
70
|
"cli_commands_referenced": ["detect", "plan", "apply", "releases", "release-dispatch", "rename-default-branch"]
|
|
@@ -5,6 +5,19 @@ name: auto-merge {{DEV_BRANCH}} to {{MAIN_BRANCH}}
|
|
|
5
5
|
# for this file, or shipflow's next apply will refuse to overwrite it
|
|
6
6
|
# (handEditDetected) until an explicit --force is passed. Commit both files
|
|
7
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.
|
|
8
21
|
|
|
9
22
|
on:
|
|
10
23
|
pull_request:
|
|
@@ -30,7 +43,7 @@ jobs:
|
|
|
30
43
|
- name: Enable auto-merge
|
|
31
44
|
run: gh pr merge --auto {{MERGE_FLAG}} "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}"
|
|
32
45
|
env:
|
|
33
|
-
GH_TOKEN: ${{ secrets.
|
|
46
|
+
GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
|
|
34
47
|
|
|
35
48
|
# Fires once, when the promotion PR actually merges (a separate event from
|
|
36
49
|
# the job above, which only *enables* auto-merge). Applies a durable
|
|
@@ -49,4 +62,4 @@ jobs:
|
|
|
49
62
|
- name: Apply release-pending label
|
|
50
63
|
run: gh pr edit "${{ github.event.pull_request.number }}" --add-label release-pending --repo "${{ github.repository }}"
|
|
51
64
|
env:
|
|
52
|
-
GH_TOKEN: ${{ secrets.
|
|
65
|
+
GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
|