@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 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
- applied.push({ id: entry.id, description: entry.description });
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, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', ...opts });
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: (r.stderr || '').trim(),
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.4",
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": { "type": "git", "url": "git+https://github.com/natejswenson/shipflow.git" },
9
- "bugs": { "url": "https://github.com/natejswenson/shipflow/issues" },
10
- "keywords": ["shipflow", "claude-code", "claude-skill", "git", "branching", "release-automation", "ci-cd"],
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": { "shipflow": "bin/shipflow.js" },
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": { "node": ">=18" },
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
- "dependencies": {}
46
+ "devDependencies": {
47
+ "yaml": "^2.9.0"
48
+ }
30
49
  }
@@ -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"]