# Shipflow

> Scaffold a configurable dev/main branching, auto-merge, branch-cleanup, and release-tagging workflow into any repo. Detects existing branch protection, CI checks, and release conventions; always shows a plan and waits for confirmation before mutating anything. Use when the user asks to set up branch protection standards, apply deployment/release standards to a repo, or wants long-lived dev/main branches with auto-merge and branch cleanup.

- Skill: `natejswenson/shipflow` (Agent Skill, multi-file: 47 files)
- Install (CLI): `npx skillmds@latest add natejswenson/shipflow`
- Raw SKILL.md: https://api.skillmd.com/api/skills/natejswenson/shipflow/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- Author: natejswenson (https://skillmd.com/u/natejswenson)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/natejswenson/shipflow

---


## Codex runtime

When running in Codex, invoke this skill as `$shipflow`. Resolve scripts, assets,
and references from the directory containing this SKILL.md, regardless of the
current working directory. Existing `~/.claude/` personal-data paths remain valid
and are still used by the bundled scripts; they do not require Claude to run.
Map `Read`/`Write`/`Edit`/`Bash` to the available file and shell tools, and
`WebSearch`/`WebFetch` to available web tools. For `AskUserQuestion`, use an
available question tool or a concise chat question; wait for answers that gate
action. Use Codex's delegation tools for required subagents when available;
otherwise disclose that independent execution is unavailable. Discover connected
apps by capability rather than assuming Claude MCP tool names exist.

# /shipflow — branching + release-automation setup

<!-- press:runtime -->
In Claude Code, load `/press`; in Codex, load `$press`; then follow the shared PRESS terminal/UI contract from `brand/agent-ui.md`. Do not copy or override that contract here.
<!-- press:runtime -->

All deterministic work is delegated to the CLI. Invoke it as
`npx -y @natjswenson/shipflow@latest <command>` — **always with the explicit
`@latest` tag, never bare `@natjswenson/shipflow`.** Without a version/tag,
`npx` prefers an already-resolvable install on `PATH` (e.g. a stale global
`npm install -g @natjswenson/shipflow` from a prior manual test) over
fetching the current version from the registry, and does so silently with
no warning. This isn't hypothetical: it happened in this exact repo — the
same command with the `@latest` tag omitted silently ran a stale global
0.2.0 install (missing every fix through 0.2.5, including the Critical
template-injection fix), while `npx -y @natjswenson/shipflow@latest -v`
correctly resolved 0.2.5. Every command prints JSON to stdout — parse it,
don't try to re-derive what it computed.

**This skill never mutates repo state directly.** Every mutating action goes
through `shipflow apply`, and the computed plan is always shown to the user
and confirmed before the real (non-dry-run) apply runs. This is the
deterministic/nondeterministic split: you decide *what* and confirm with the
user; the CLI is the only thing that *does*.

## Decide which mode you're in

| Situation | Mode |
|---|---|
| `.github/shipflow.json` doesn't exist in the target repo yet | **First-run setup** |
| `.github/shipflow.json` exists, user wants to check/repair drift | **Re-run / audit** |
| User asks "any releases pending?" / periodic check-in / after a `dev → main` merge | **Check pending releases** |
| User wants to cut a release for one named thing ("release devlog") | **Cut a component release** |

## First-run setup

**This whole section is a mandatory interactive interview, not a narrate-and-proceed pass.** Steps 2–4 below must end with the agent presenting a plain-language summary of what was detected and what's about to be written, and waiting for the user's explicit go-ahead — even when detected values already look correct. Never go from step 1's `detect` straight to step 4's config write without that confirmation turn; a value looking right is not the same as the user confirming it.

1. **Detect.** Run:
   ```
   npx -y @natjswenson/shipflow@latest detect --repo <path> --main main --dev dev
   ```
   (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.

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).
   - **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.
   - **Ambiguous or greenfield:** present all 3 patterns and ask the user to choose. Do not silently pick one:
     - `dev-main-promotion` — long-lived `dev` + `main`; a promotion PR auto-merges `dev` into `main`.
     - `github-flow` — single long-lived `main`; ready same-repository PRs can auto-merge directly to `main`; forks require maintainer action. Suggest this as the lightweight default for a **greenfield** repo specifically, without auto-picking it.
     - `gitflow` — `develop` + `main` + transient `release/*`/`hotfix/*` branches, for software that maintains multiple released versions concurrently.
   - 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.
   - 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.

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:
   - **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.
   - **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:
     ```
     npx -y @natjswenson/shipflow@latest rename-default-branch --repo <path> --branch <old-default> --to main
     ```
     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.

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.

   **If the candidate list is empty, a CI workflow has to exist before auto-merge
   can be enabled. Hand that job to the `ghfactory` skill** — authoring and *verifying*
   workflow YAML is its whole subject, and it does things shipflow never will:
   it resolves every action ref against the real API (no linter checks that an
   action exists), validates each `with:` key against the action's own
   `action.yml`, reports how many majors behind each pin is, and runs actionlint
   and zizmor before showing you anything. Two skills answering "scaffold me a CI
   workflow" differently is worse than either answer.

   > Use the ghfactory skill to create a `pull_request`-triggered build+test workflow
   > for this repo, then come back here with the job name.

   **If ghfactory is not installed**, draft it here instead: investigate the repo
   directly (`package.json`, `Cargo.toml`, `project.yml`/`.xcodeproj`, `go.mod`,
   `pyproject.toml`, or whatever's actually there) and write a minimal,
   conservative `pull_request`-triggered build+test workflow.
   **Never silently overwrite an existing workflow file.** Present it and wait for
   explicit confirmation before writing it — the same confirm-before-write pattern
   as everything else in this skill.

   Either way, say plainly that a fresh workflow is a starting point, 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 run successfully
   before relying on it as one. 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.

5. **Resolve `protectionOwner`:**
   - `"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.
   - `"shipflow"` → tell the user no existing branch protection was found and shipflow will own it going forward.
   - `"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.

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.

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.

8. **Show the plan.** Run:
   ```
   npx -y @natjswenson/shipflow@latest plan --repo <path>
   ```
   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).

9. **Dry-run apply** (optional sanity check, same output shape as the real apply but nothing is mutated):
   ```
   npx -y @natjswenson/shipflow@latest apply --repo <path> --dry-run
   ```

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.
    ```
    npx -y @natjswenson/shipflow@latest apply --repo <path> --expect-state-hash <hash-from-step-8>
    ```
    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.

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.

## Re-run / audit

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.

## GitHub Flow readiness, forks and migration

For `workflowPattern: "github-flow"`, the generated `pull_request` workflow handles
`opened`, `reopened`, `synchronize`, `ready_for_review` and `closed` on configured
main. Only a non-draft PR whose head repository matches the target repository can
enable native auto-merge. Drafts skip cleanly; making a reviewed draft ready has
its own trigger. Native GitHub required checks still gate the merge. Keep an
implementation PR draft until review and the authorized merge decision are complete.

Repository write access is the trust boundary. Forks and missing head repositories
skip both jobs, regardless of contributor association or labels. Maintainers must
review fork PRs and explicitly enable native auto-merge or merge with their own
authorized credentials after required checks. Do not switch to
`pull_request_target`, check out PR code, or run contributor-controlled scripts to
make credentials available.

Both commands use the configured `release.releaseCredential` PAT/App secret from
setup step 6. If that named secret is unavailable or unset, the step reports a clean
skip and invokes no `gh` command; it does not fall back to `GITHUB_TOKEN`. This does
not provision or validate the credential. Omitting `release.releaseCredential`
from configuration instead retains the renderer’s legacy `GITHUB_TOKEN` default,
which does not guarantee a skip or the merged-PR reminder. Always configure and
provision the named PAT/App secret for this flow.

Only a merged same-repository PR receives the optional `release-pending` reminder.
Unmerged closes and fork merges skip it; a maintainer may label a fork merge
manually. Component `release-status` discovers untagged work without labels. No
PR event cuts a tag or release.

When upgrading this template, use the corrected checkout's
`node <skill-directory>/bin/shipflow.js plan --repo <target>` and matching `apply`
during unreleased development; use the explicit `@latest` invocation after release.
Follow the reviewed plan and state-hash apply process above, including any live
settings changes it proposes. If the existing workflow matches its recorded old
`renderedTemplateHashes` receipt, plan recognizes a template update. Apply generates
new bytes and returns the new receipt; commit both together and replan to verify
no template drift. A genuine hand edit still requires the explicit scoped override;
never manufacture a matching receipt by hand to bypass that refusal.

## Check pending releases (`manual-gate` ask-flow)

This is a **separate, later invocation** from the one that ran the promotion's `apply` — native GitHub auto-merge completes asynchronously, with no live session attached at the moment of the actual merge. A durable `release-pending` label is what survives that gap.

1. Run:
   ```
   npx -y @natjswenson/shipflow@latest releases --repo <path>
   ```
   This returns every `dev → main` PR still labeled `release-pending`, each with a `merged` flag (confirmed independently, not just inferred from the label).

2. For **each** promotion returned (there can be more than one if several merged before you last checked — handle the whole list, not just the most recent): if `merged` is `false`, skip it for now (native auto-merge hasn't landed yet; don't ask about a promotion that isn't actually on `main`). If `merged` is `true`, ask the user: *"A promotion merged to main — cut a release for [changed skills]?"*

3. If yes, dispatch each changed skill's release workflow and clear the label **only after every dispatch is confirmed successful**:
   ```
   npx -y @natjswenson/shipflow@latest release-dispatch --repo <path> --pr <number> --workflow-file <skill1>.yml --workflow-file <skill2>.yml --ref main
   ```
   If `dispatched` shows a partial failure, the label is deliberately left in place — report this to the user and note the promotion will resurface next time `releases` is checked; a later re-dispatch is safe (each skill's release workflow is idempotent).

4. If no, leave the label as-is — there is no "defer" state in this version; declining is final for that promotion short of a manual dispatch. (Deliberate v1 simplification, not an oversight.)

## Cut a component release

For the conversational "release devlog" flow, prefer the **`release` skill** — it owns the
bump judgment, the CHANGELOG prose and the run presentation. This section is the CLI contract
underneath it, and the fallback when that skill is not installed.

A **component** is one independently-versioned thing in a repo: a skill in a monorepo, or the
repo itself. `release.componentLayout` describes where a component's version, changelog, tag
and release workflow live, with `{name}` as the only substitution token;
`release.components` lists the names. A repo with neither gets a single component inferred from
its root (`package.json`, `CHANGELOG.md`, `v{version}`), so a one-project repo needs no config
at all and `--component` may be omitted.

`workflowPattern: "github-flow"` is authoritative: preparation and feature PRs
use configured main, status never reads dev, and cut skips promotion. A stale dev
field does not change that. Absent-pattern and two-branch consumers retain the
integration branch, promotion and dev-ahead refusal. GitHub-flow `releases`
returns `mergedPrs`; legacy consumers retain `promotions`. These labels are
optional reminders: component `release-status` inventories pending versions and
tags without them, and `release-cut` requires no label.

For the declared `skills/{name}/.codex-plugin/plugin.json` layout, preparation
requires this repository's `tools/sync_codex.py` and `tools/check_compatibility.py`.
It checks existing metadata, updates both lockfile version fields and the shared
version files, runs generation and compatibility checks in the worktree, and
refuses unrelated generated edits. Generic consumers have no Python dependency.
Before requesting merge, cut reads the full live required-check set; before
explicit dispatch, it fetches and verifies main's intended version and changelog.
Keep main PRs draft until their review and authorized merge decision are complete;
the existing GitHub-flow workflow enables native auto-merge on eligible PRs.

1. **Read the state. Never guess it.**
   ```
   npx -y @natjswenson/shipflow@latest release-status --repo <path> --component <name>
   ```
   Returns `state`, `workflowPattern`, `releaseBase`, the version on main (and dev for two-branch repos), the last tag, every commit since that tag that
   touched this component's paths, a `suggestedBump` with its reason, `blockers`, `notes`, and a
   `statusHash`. `state` decides the path:
   - `clean` — the released version is what's on main. A bump is needed: go to step 2.
   - `untagged-bump-on-main` — the bump is already on main and was never tagged (a cancelled or
     failed release run). **No PR is needed** — `release-cut` dispatches and verifies. Skip to step 3.
     **`untagged-bump-on-main` is not, by itself, permission to cut.** Check `devAhead` first: if
     it is set, dev already carries a *higher* version than what's on main, and cutting here would
     tag the version on main, not the one on dev — the version you almost certainly mean to
     release. `release-cut` refuses in this shape unless you pass `--version` naming exactly which
     one to release (see step 3); it never guesses.
   - `bump-on-dev-unpromoted` — the bump is on dev, waiting for a promotion. Skip to step 3.
   - `version-behind-tag` — main carries a *lower* version than an existing tag. Stop and ask;
     this means a tag was cut from something other than main, and guessing is how it gets worse.

2. **Show the user `collateral`, `blockers` and the proposed version, and wait.**
   A `dev → main` promotion is atomic and carries all of dev, so every component listed under
   `collateral` has its bump moved to `main` by the same promotion. It is **not released** by
   that — every caller's release job is `workflow_dispatch`-only, so merging tags nothing; each
   becomes `untagged-bump-on-main`, one deliberate `release-cut` away from a tag.
   **Never run `release-cut` without naming that list to the user first.** They should know what
   their promotion moves, and which components are now one dispatch from a release nobody asked
   for.

   `suggestedBump` is a suggestion. The user decides, and a `suggestedBumpCapped: true` means a
   breaking change was held at minor because the component is still 0.x — going to 1.0.0 is a
   release decision, never a commit message's. Then:
   ```
   npx -y @natjswenson/shipflow@latest release-prepare --repo <path> --component <name> \
     --version <x.y.z> --notes-file <path>
   ```
   Local only, no network. It works in a **throwaway git worktree**, so unrelated uncommitted work
   in the user's tree is untouched and cannot be swept into the release commit. The version bump
   and the CHANGELOG entry land in **one commit** — the notes are read off `main` at dispatch
   time, so a CHANGELOG that lands in a later promotion than its version is notes the release
   will never carry.

3. **Cut it, and prove it.**
   ```
   npx -y @natjswenson/shipflow@latest release-cut --repo <path> --component <name> \
     --version <prepared-or-confirmed-version> --expect-status-hash <hash-from-step-1> --wait 240
   ```
   `--expect-status-hash` is mandatory (same TOCTOU discipline as `apply`'s `--expect-state-hash`);
   `--skip-hash-check` is a named escape hatch, never a default.

   If step 1's `devAhead` was set, `release-cut` refuses outright with an error naming both
   versions — this is the ambiguous three-way state (main has an untagged bump, dev already
   carries something higher) where guessing would tag the wrong one. Promote `dev → main` and
   re-run `release-status` to release what's on dev (the normal recovery), **or** add
   `--version <x.y.z>` naming exactly the version on main, if you deliberately mean to release
   that one and leave dev's higher version for later. `--version` is a confirmation, not a
   bypass — it is only ever accepted when it matches a version already on main, dev, or the verified prepared branch; anything
   else is refused the same as passing nothing.

   **`release-cut` is resumable and bounded, and it will usually return `done: false`.** The full
   path — feature PR, checks, merge, promotion, auto-merge, **dispatch**, release run, tag — takes
   longer than one call should block for. Each call advances as far as it can, then returns the
   `stage` it is parked at and a `next` line. **Call it again, unchanged, until `done: true`.** It
   derives every stage from live remote state and never from a record of what a previous call did,
   so a resumed run and a fresh one are the same code path.

   **Merging cuts nothing.** `release-cut` dispatches the component's release
   workflow itself, after the selected version and notes reach main — that dispatch is the single point at which any tag
   is created in this repo, which is why a merge can no longer surprise anyone with a release.

4. **Report the tag, and only the tag.** `done: true` carries `tag` and `releaseUrl`, read back
   from origin. A dispatched workflow, a merged PR and a green check are **not** a release —
   `release-cut` confirms the tag exists on the remote before it says done, and so must you.

## Auto mode (not yet implemented)

`release.mode: "auto"` is a valid value in the config schema (the full design covers automatic tagging via `release-please`), but `shipflow apply` in this version **refuses to run** against a config with `release.mode: "auto"`, with a clear error rather than silently no-oping. If a user asks for fully automatic tagging, tell them it's designed but not yet shipped (see `CHANGELOG.md`) and that `"manual-gate"` — the deliberate ask-before-tagging flow above — is what's available today.

## Error handling

- **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.
- **`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.
- **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`.
- **`gh auth` failures:** surface these immediately; branch protection and rulesets need repo-admin scope. Don't proceed partway through a plan on missing auth.
- **`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.
- **`--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.
- **`--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.
- **`release-cut` returns `done: false`:** not an error. It is parked at the `stage` it reports,
  waiting on something remote. Call it again with the same arguments. Do not report a release.
- **`release-status` reports `component-files-dirty`:** this component's own version files or
  CHANGELOG have uncommitted edits, so a bump would collide with them. Unrelated dirt elsewhere in
  the tree is reported under `notes` and is deliberately **not** a blocker — `prepare` runs in an
  isolated worktree specifically so other people's in-flight work is safe.
- **`release-status` reports `version-unreadable-on-main`:** the component's version files do not
  exist on main, or they disagree with each other. A disagreement is a hard refusal, never a
  "pick the highest" — releasing from a disagreeing set tags one version and ships another.
- **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.

## Security rules

- 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.
- `.github/shipflow.json` is committed policy, not secrets — never write credential *values* into it, only the *name* of a secret (`release.releaseCredential`).
- Never write shipflow's config anywhere other than `.github/shipflow.json` in the target repo.
- **`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.

## Edge cases

- **Greenfield repo, no CI yet:** `requiredChecks` will detect empty. Don't silently proceed — tell the user auto-merge can't be enabled until at least one check exists, and that's a real ordering dependency (CI first, then shipflow setup), not a shipflow bug.
- **Repo already has `shipflow.json` with `release.mode: "auto"`:** refuse per "Auto mode," above, even on a re-run/audit — don't silently downgrade it to `"manual-gate"` either; surface the refusal and let the user decide.
- **Private repo on a free GitHub plan:** the deletion-protection ruleset requires GitHub Pro/Team/Enterprise for private repos (rulesets are free for public repos only). `apply` reports this as a `skipped` entry with that reason, not an `errors` entry — it's an expected environment limitation, not a shipflow bug. Cleanup and the release-pending label still apply normally.

