@natjswenson/shipflow 0.2.4 → 0.2.5
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 +6 -3
- package/bin/shipflow.js +21 -1
- package/lib/apply.mjs +7 -2
- package/lib/gh.mjs +19 -2
- package/package.json +26 -7
- package/skill-invariants.json +10 -0
package/SKILL.md
CHANGED
|
@@ -66,11 +66,11 @@ user; the CLI is the only thing that *does*.
|
|
|
66
66
|
npx -y @natjswenson/shipflow apply --repo <path> --dry-run
|
|
67
67
|
```
|
|
68
68
|
|
|
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.
|
|
69
|
+
9. **Apply for real**, passing the `stateHash` from step 7's plan output as `--expect-state-hash` — this is the TOCTOU guard: if repo state drifted between the plan you showed the user and this call, `apply` refuses to mutate anything and tells you to re-plan. **`--expect-state-hash` is mandatory for a real (non-dry-run) apply** — omitting it is a hard CLI refusal, not a silent skip of the check; the only way around it is the explicitly-named `--skip-hash-check` escape hatch, which you should never reach for as a matter of course.
|
|
70
70
|
```
|
|
71
71
|
npx -y @natjswenson/shipflow apply --repo <path> --expect-state-hash <hash-from-step-7>
|
|
72
72
|
```
|
|
73
|
-
If a `handEditDetected` entry was confirmed for override in step 7, pass `--force <entry-id>` (repeatable — one flag per confirmed entry id, never a blanket override).
|
|
73
|
+
If a `handEditDetected` entry was confirmed for override in step 7, pass `--force <entry-id>` (repeatable — one flag per confirmed entry id, never a blanket override) **and** `--force-reason "<short justification>"` — the CLI refuses any `--force` without an accompanying reason, and that reason is echoed back in the apply result for auditability. Write a real justification tied to the user's actual confirmation (e.g. `--force-reason "user confirmed hand-edit override for the branch-rename migration on 2026-07-15"`), never a placeholder string.
|
|
74
74
|
|
|
75
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.
|
|
76
76
|
|
|
@@ -104,11 +104,14 @@ This is a **separate, later invocation** from the one that ran the promotion's `
|
|
|
104
104
|
|
|
105
105
|
## Error handling
|
|
106
106
|
|
|
107
|
-
- **Empty `requiredChecks`:** `apply` refuses to wire up auto-merge with zero required checks. Don't work around this by suggesting `--force allow-no-checks` unless the user has explicitly and knowingly accepted an unprotected merge — surface the refusal message plainly first.
|
|
107
|
+
- **Empty `requiredChecks`:** `apply` refuses to wire up auto-merge with zero required checks. Don't work around this by suggesting `--force allow-no-checks` (plus the now-mandatory `--force-reason`) unless the user has explicitly and knowingly accepted an unprotected merge — surface the refusal message plainly first.
|
|
108
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.
|
|
109
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`.
|
|
110
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
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.
|
|
112
|
+
- **`--expect-state-hash is required` refusal:** a real apply was attempted with neither `--expect-state-hash` nor `--skip-hash-check`. Go back and get (or re-fetch via `plan`) the hash — don't reach for `--skip-hash-check` just to make the error go away; that flag exists for a deliberate, documented exception, not as a default workaround.
|
|
113
|
+
- **`--force was passed without --force-reason` refusal:** a `--force` flag was about to be sent with no accompanying justification. Stop and get (or write) an explicit reason tied to what the user actually confirmed before retrying — never pass a placeholder string just to satisfy the flag.
|
|
114
|
+
- **A `gh`/`git` call hangs or times out:** every subprocess call has a 30-second timeout (`ETIMEDOUT` surfaces in the error message). A timeout on `detect`/`plan` usually means a real GitHub outage or rate-limit — retry once, and if it persists, tell the user rather than looping silently.
|
|
112
115
|
|
|
113
116
|
## Security rules
|
|
114
117
|
|
package/bin/shipflow.js
CHANGED
|
@@ -106,10 +106,29 @@ function cmdApply(args) {
|
|
|
106
106
|
config: { type: 'string' },
|
|
107
107
|
'dry-run': { type: 'boolean', default: false },
|
|
108
108
|
'expect-state-hash': { type: 'string' },
|
|
109
|
+
'skip-hash-check': { type: 'boolean', default: false },
|
|
109
110
|
force: { type: 'string', multiple: true, default: [] },
|
|
111
|
+
'force-reason': { type: 'string' },
|
|
110
112
|
},
|
|
111
113
|
});
|
|
112
114
|
if (!values.repo) return fail('apply: --repo is required');
|
|
115
|
+
if (values.force.length > 0 && !values['force-reason']) {
|
|
116
|
+
return fail(
|
|
117
|
+
'apply: --force was passed without --force-reason — every force override must carry a short, explicit human-readable justification (surfaced back in the apply result for auditability)'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
// --expect-state-hash is the TOCTOU guard: without it, a real apply
|
|
121
|
+
// proceeds against whatever live state happens to exist at call time,
|
|
122
|
+
// with no confirmation that a human/agent actually reviewed that exact
|
|
123
|
+
// state via `plan` first. Omitting it silently used to just skip the
|
|
124
|
+
// check — now it's a hard refusal unless the caller explicitly opts out
|
|
125
|
+
// via --skip-hash-check (a named, greppable escape hatch, not a default).
|
|
126
|
+
// Found via a Siege security audit (2026-07-15, SIEGE-2026-07-15-003).
|
|
127
|
+
if (!values['dry-run'] && !values['expect-state-hash'] && !values['skip-hash-check']) {
|
|
128
|
+
return fail(
|
|
129
|
+
'apply: --expect-state-hash is required for a real apply — pass the stateHash a prior `plan` call returned, or --skip-hash-check to explicitly bypass the drift guard (not recommended)'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
113
132
|
|
|
114
133
|
const configPath = values.config ?? defaultConfigPath(values.repo);
|
|
115
134
|
let config;
|
|
@@ -158,6 +177,7 @@ function cmdApply(args) {
|
|
|
158
177
|
dryRun: values['dry-run'],
|
|
159
178
|
currentStateHash: repoState.stateHash,
|
|
160
179
|
force: values.force,
|
|
180
|
+
forceReason: values['force-reason'] ?? null,
|
|
161
181
|
ownerRepo,
|
|
162
182
|
repoPath: values.repo,
|
|
163
183
|
config,
|
|
@@ -241,7 +261,7 @@ Usage: shipflow <command> [options]
|
|
|
241
261
|
Commands:
|
|
242
262
|
detect --repo <path> [--main <name>] [--dev <name>] [--release-credential <name>]
|
|
243
263
|
plan --repo <path> [--config <path>]
|
|
244
|
-
apply --repo <path> [--config <path>] [--dry-run] [--expect-state-hash <hash>] [--force <id>]...
|
|
264
|
+
apply --repo <path> [--config <path>] [--dry-run] [--expect-state-hash <hash> | --skip-hash-check] [--force <id>]... [--force-reason <text>]
|
|
245
265
|
releases --repo <path> [--config <path>]
|
|
246
266
|
release-dispatch --repo <path> --pr <number> --workflow-file <file>... --ref <ref>
|
|
247
267
|
rename-default-branch --repo <path> --branch <current-name> --to <new-name>
|
package/lib/apply.mjs
CHANGED
|
@@ -30,7 +30,7 @@ export function classifyRulesetError(stderr) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function applyPlan(plan, opts) {
|
|
33
|
-
const { dryRun, currentStateHash, force = [], ownerRepo, repoPath, config } = opts;
|
|
33
|
+
const { dryRun, currentStateHash, force = [], forceReason = null, ownerRepo, repoPath, config } = opts;
|
|
34
34
|
|
|
35
35
|
// TOCTOU guard — refuse before making ANY mutating call if live state has
|
|
36
36
|
// drifted since the plan was computed. Idempotency is the primary safety
|
|
@@ -88,7 +88,12 @@ export function applyPlan(plan, opts) {
|
|
|
88
88
|
|
|
89
89
|
const result = applyOne(entry, { ownerRepo, repoPath, config });
|
|
90
90
|
if (result.ok) {
|
|
91
|
-
|
|
91
|
+
const wasForced = force.includes(entry.id) || (entry.id.startsWith('template:') && force.includes('allow-no-checks'));
|
|
92
|
+
applied.push({
|
|
93
|
+
id: entry.id,
|
|
94
|
+
description: entry.description,
|
|
95
|
+
...(wasForced ? { forced: true, forceReason } : {}),
|
|
96
|
+
});
|
|
92
97
|
if (entry.id.startsWith('template:') && entry.renderedHash) {
|
|
93
98
|
renderedTemplateHashes[entry.path] = entry.renderedHash;
|
|
94
99
|
}
|
package/lib/gh.mjs
CHANGED
|
@@ -18,16 +18,33 @@ export function readFileCapped(path, encoding = 'utf8') {
|
|
|
18
18
|
return readFileSync(path, encoding);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Every gh/git call crosses a network or filesystem-lock boundary shipflow
|
|
22
|
+
// doesn't control (GitHub rate-limiting, a network partition, a stuck git
|
|
23
|
+
// index lock) — with no timeout, a single stuck call hangs the whole
|
|
24
|
+
// process indefinitely with no recovery. 30s is generous for any real gh
|
|
25
|
+
// API call or local git operation this codebase makes. Found via a Siege
|
|
26
|
+
// security audit (2026-07-15, SIEGE-2026-07-15-004).
|
|
27
|
+
const DEFAULT_SUBPROCESS_TIMEOUT_MS = 30_000;
|
|
28
|
+
|
|
21
29
|
// argv-style invocation; no shell, so user-supplied args cannot inject.
|
|
22
30
|
// Returns { status, stdout, stderr } so callers can distinguish failure modes
|
|
23
31
|
// (e.g. a 404 from gh vs. a network error) instead of collapsing to a boolean.
|
|
24
32
|
export function spawnArgs(cmd, args, opts = {}) {
|
|
25
33
|
try {
|
|
26
|
-
const r = spawnSync(cmd, args, {
|
|
34
|
+
const r = spawnSync(cmd, args, {
|
|
35
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
timeout: DEFAULT_SUBPROCESS_TIMEOUT_MS,
|
|
38
|
+
...opts,
|
|
39
|
+
});
|
|
40
|
+
const stderr = (r.stderr || '').trim();
|
|
41
|
+
// spawnSync does not throw on timeout — it kills the child and sets
|
|
42
|
+
// r.error (code 'ETIMEDOUT') with r.status left null, so this must be
|
|
43
|
+
// surfaced explicitly rather than silently collapsing to a bare "1".
|
|
27
44
|
return {
|
|
28
45
|
status: r.status ?? 1,
|
|
29
46
|
stdout: (r.stdout || '').trim(),
|
|
30
|
-
stderr:
|
|
47
|
+
stderr: r.error ? [stderr, r.error.message].filter(Boolean).join(': ') : stderr,
|
|
31
48
|
};
|
|
32
49
|
} catch (e) {
|
|
33
50
|
return { status: 1, stdout: '', stderr: String((e && e.message) || e) };
|
package/package.json
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natjswenson/shipflow",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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",
|
|
7
7
|
"homepage": "https://github.com/natejswenson/shipflow",
|
|
8
|
-
"repository": {
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/natejswenson/shipflow.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/natejswenson/shipflow/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"shipflow",
|
|
17
|
+
"claude-code",
|
|
18
|
+
"claude-skill",
|
|
19
|
+
"git",
|
|
20
|
+
"branching",
|
|
21
|
+
"release-automation",
|
|
22
|
+
"ci-cd"
|
|
23
|
+
],
|
|
11
24
|
"type": "module",
|
|
12
|
-
"bin": {
|
|
25
|
+
"bin": {
|
|
26
|
+
"shipflow": "bin/shipflow.js"
|
|
27
|
+
},
|
|
13
28
|
"files": [
|
|
14
29
|
"bin/",
|
|
15
30
|
"lib/",
|
|
@@ -21,10 +36,14 @@
|
|
|
21
36
|
"README.md",
|
|
22
37
|
"LICENSE"
|
|
23
38
|
],
|
|
24
|
-
"engines": {
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
25
42
|
"scripts": {
|
|
26
43
|
"test": "node --test \"tests/**/*.test.mjs\"",
|
|
27
44
|
"audit": "npm audit --audit-level=moderate"
|
|
28
45
|
},
|
|
29
|
-
"
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"yaml": "^2.9.0"
|
|
48
|
+
}
|
|
30
49
|
}
|
package/skill-invariants.json
CHANGED
|
@@ -65,6 +65,16 @@
|
|
|
65
65
|
"id": "render-template-validates-substitutions",
|
|
66
66
|
"pattern": "never assume a config field is pre-sanitized",
|
|
67
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."
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"id": "expect-state-hash-mandatory",
|
|
71
|
+
"pattern": "is mandatory for a real \\(non-dry-run\\) apply",
|
|
72
|
+
"rationale": "Found by a Siege security audit (2026-07-15, SIEGE-2026-07-15-003): --expect-state-hash was optional, so a real apply could silently proceed with zero TOCTOU/drift protection if the caller simply omitted it. Now a hard CLI refusal unless the named --skip-hash-check escape hatch is passed explicitly."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "force-requires-reason",
|
|
76
|
+
"pattern": "refuses any `--force` without an accompanying reason",
|
|
77
|
+
"rationale": "Found by a Siege security audit (2026-07-15, SIEGE-2026-07-15-002): --force had zero code-level friction beyond the flag itself, so a confused or prompt-injected agent could force through an unprotected merge or a tampered template with no audit trail. --force now requires --force-reason, echoed back in the apply result."
|
|
68
78
|
}
|
|
69
79
|
],
|
|
70
80
|
"cli_commands_referenced": ["detect", "plan", "apply", "releases", "release-dispatch", "rename-default-branch"]
|