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
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.
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.
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.
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.
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:
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.
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.
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.
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.
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).
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
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.
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.
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).
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]?"
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).
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.
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.
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.
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.
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.
1---2name: shipflow3description: 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.4---56## Codex runtime78When running in Codex, invoke this skill as `$shipflow`. Resolve scripts, assets,9and references from the directory containing this SKILL.md, regardless of the10current working directory. Existing `~/.claude/` personal-data paths remain valid11and are still used by the bundled scripts; they do not require Claude to run.12Map `Read`/`Write`/`Edit`/`Bash` to the available file and shell tools, and13`WebSearch`/`WebFetch` to available web tools. For `AskUserQuestion`, use an14available question tool or a concise chat question; wait for answers that gate15action. Use Codex's delegation tools for required subagents when available;16otherwise disclose that independent execution is unavailable. Discover connected17apps by capability rather than assuming Claude MCP tool names exist.1819# /shipflow — branching + release-automation setup2021<!-- press:runtime -->22In 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.23<!-- press:runtime -->2425All deterministic work is delegated to the CLI. Invoke it as26`npx -y @natjswenson/shipflow@latest <command>` — **always with the explicit27`@latest` tag, never bare `@natjswenson/shipflow`.** Without a version/tag,28`npx` prefers an already-resolvable install on `PATH` (e.g. a stale global29`npm install -g @natjswenson/shipflow` from a prior manual test) over30fetching the current version from the registry, and does so silently with31no warning. This isn't hypothetical: it happened in this exact repo — the32same command with the `@latest` tag omitted silently ran a stale global330.2.0 install (missing every fix through 0.2.5, including the Critical34template-injection fix), while `npx -y @natjswenson/shipflow@latest -v`35correctly resolved 0.2.5. Every command prints JSON to stdout — parse it,36don't try to re-derive what it computed.3738**This skill never mutates repo state directly.** Every mutating action goes39through `shipflow apply`, and the computed plan is always shown to the user40and confirmed before the real (non-dry-run) apply runs. This is the41deterministic/nondeterministic split: you decide *what* and confirm with the42user; the CLI is the only thing that *does*.4344## Decide which mode you're in4546| Situation | Mode |47|---|---|48| `.github/shipflow.json` doesn't exist in the target repo yet | **First-run setup** |49| `.github/shipflow.json` exists, user wants to check/repair drift | **Re-run / audit** |50| User asks "any releases pending?" / periodic check-in / after a `dev → main` merge | **Check pending releases** |51| User wants to cut a release for one named thing ("release devlog") | **Cut a component release** |5253## First-run setup5455**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.56571. **Detect.** Run:58 ```59 npx -y @natjswenson/shipflow@latest detect --repo <path> --main main --dev dev60 ```61 (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.62632. **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).64 - **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.65 - **Ambiguous or greenfield:** present all 3 patterns and ask the user to choose. Do not silently pick one:66 - `dev-main-promotion` — long-lived `dev` + `main`; a promotion PR auto-merges `dev` into `main`.67 - `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.68 - `gitflow` — `develop` + `main` + transient `release/*`/`hotfix/*` branches, for software that maintains multiple released versions concurrently.69 - 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.70 - 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.71723. **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:73 - **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.74 - **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:75 ```76 npx -y @natjswenson/shipflow@latest rename-default-branch --repo <path> --branch <old-default> --to main77 ```78 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.79804. **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.8182 **If the candidate list is empty, a CI workflow has to exist before auto-merge83 can be enabled. Hand that job to the `ghfactory` skill** — authoring and *verifying*84 workflow YAML is its whole subject, and it does things shipflow never will:85 it resolves every action ref against the real API (no linter checks that an86 action exists), validates each `with:` key against the action's own87 `action.yml`, reports how many majors behind each pin is, and runs actionlint88 and zizmor before showing you anything. Two skills answering "scaffold me a CI89 workflow" differently is worse than either answer.9091 > Use the ghfactory skill to create a `pull_request`-triggered build+test workflow92 > for this repo, then come back here with the job name.9394 **If ghfactory is not installed**, draft it here instead: investigate the repo95 directly (`package.json`, `Cargo.toml`, `project.yml`/`.xcodeproj`, `go.mod`,96 `pyproject.toml`, or whatever's actually there) and write a minimal,97 conservative `pull_request`-triggered build+test workflow.98 **Never silently overwrite an existing workflow file.** Present it and wait for99 explicit confirmation before writing it — the same confirm-before-write pattern100 as everything else in this skill.101102 Either way, say plainly that a fresh workflow is a starting point, not a103 guarantee it's green on the first run — **a required check that never passes104 blocks every future merge**, so the user should watch it run successfully105 before relying on it as one. Once it exists, re-run step 1's `detect` (repo106 state changed) and continue this step with the new job name as a real candidate.1071085. **Resolve `protectionOwner`:**109 - `"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.110 - `"shipflow"` → tell the user no existing branch protection was found and shipflow will own it going forward.111 - `"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.1121136. **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.1141157. **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.1161178. **Show the plan.** Run:118 ```119 npx -y @natjswenson/shipflow@latest plan --repo <path>120 ```121 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).1221239. **Dry-run apply** (optional sanity check, same output shape as the real apply but nothing is mutated):124 ```125 npx -y @natjswenson/shipflow@latest apply --repo <path> --dry-run126 ```12712810. **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.129 ```130 npx -y @natjswenson/shipflow@latest apply --repo <path> --expect-state-hash <hash-from-step-8>131 ```132 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.13313411. **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.135136## Re-run / audit137138Same 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.139140## GitHub Flow readiness, forks and migration141142For `workflowPattern: "github-flow"`, the generated `pull_request` workflow handles143`opened`, `reopened`, `synchronize`, `ready_for_review` and `closed` on configured144main. Only a non-draft PR whose head repository matches the target repository can145enable native auto-merge. Drafts skip cleanly; making a reviewed draft ready has146its own trigger. Native GitHub required checks still gate the merge. Keep an147implementation PR draft until review and the authorized merge decision are complete.148149Repository write access is the trust boundary. Forks and missing head repositories150skip both jobs, regardless of contributor association or labels. Maintainers must151review fork PRs and explicitly enable native auto-merge or merge with their own152authorized credentials after required checks. Do not switch to153`pull_request_target`, check out PR code, or run contributor-controlled scripts to154make credentials available.155156Both commands use the configured `release.releaseCredential` PAT/App secret from157setup step 6. If that named secret is unavailable or unset, the step reports a clean158skip and invokes no `gh` command; it does not fall back to `GITHUB_TOKEN`. This does159not provision or validate the credential. Omitting `release.releaseCredential`160from configuration instead retains the renderer’s legacy `GITHUB_TOKEN` default,161which does not guarantee a skip or the merged-PR reminder. Always configure and162provision the named PAT/App secret for this flow.163164Only a merged same-repository PR receives the optional `release-pending` reminder.165Unmerged closes and fork merges skip it; a maintainer may label a fork merge166manually. Component `release-status` discovers untagged work without labels. No167PR event cuts a tag or release.168169When upgrading this template, use the corrected checkout's170`node <skill-directory>/bin/shipflow.js plan --repo <target>` and matching `apply`171during unreleased development; use the explicit `@latest` invocation after release.172Follow the reviewed plan and state-hash apply process above, including any live173settings changes it proposes. If the existing workflow matches its recorded old174`renderedTemplateHashes` receipt, plan recognizes a template update. Apply generates175new bytes and returns the new receipt; commit both together and replan to verify176no template drift. A genuine hand edit still requires the explicit scoped override;177never manufacture a matching receipt by hand to bypass that refusal.178179## Check pending releases (`manual-gate` ask-flow)180181This 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.1821831. Run:184 ```185 npx -y @natjswenson/shipflow@latest releases --repo <path>186 ```187 This returns every `dev → main` PR still labeled `release-pending`, each with a `merged` flag (confirmed independently, not just inferred from the label).1881892. 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]?"*1901913. If yes, dispatch each changed skill's release workflow and clear the label **only after every dispatch is confirmed successful**:192 ```193 npx -y @natjswenson/shipflow@latest release-dispatch --repo <path> --pr <number> --workflow-file <skill1>.yml --workflow-file <skill2>.yml --ref main194 ```195 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).1961974. 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.)198199## Cut a component release200201For the conversational "release devlog" flow, prefer the **`release` skill** — it owns the202bump judgment, the CHANGELOG prose and the run presentation. This section is the CLI contract203underneath it, and the fallback when that skill is not installed.204205A **component** is one independently-versioned thing in a repo: a skill in a monorepo, or the206repo itself. `release.componentLayout` describes where a component's version, changelog, tag207and release workflow live, with `{name}` as the only substitution token;208`release.components` lists the names. A repo with neither gets a single component inferred from209its root (`package.json`, `CHANGELOG.md`, `v{version}`), so a one-project repo needs no config210at all and `--component` may be omitted.211212`workflowPattern: "github-flow"` is authoritative: preparation and feature PRs213use configured main, status never reads dev, and cut skips promotion. A stale dev214field does not change that. Absent-pattern and two-branch consumers retain the215integration branch, promotion and dev-ahead refusal. GitHub-flow `releases`216returns `mergedPrs`; legacy consumers retain `promotions`. These labels are217optional reminders: component `release-status` inventories pending versions and218tags without them, and `release-cut` requires no label.219220For the declared `skills/{name}/.codex-plugin/plugin.json` layout, preparation221requires this repository's `tools/sync_codex.py` and `tools/check_compatibility.py`.222It checks existing metadata, updates both lockfile version fields and the shared223version files, runs generation and compatibility checks in the worktree, and224refuses unrelated generated edits. Generic consumers have no Python dependency.225Before requesting merge, cut reads the full live required-check set; before226explicit dispatch, it fetches and verifies main's intended version and changelog.227Keep main PRs draft until their review and authorized merge decision are complete;228the existing GitHub-flow workflow enables native auto-merge on eligible PRs.2292301. **Read the state. Never guess it.**231 ```232 npx -y @natjswenson/shipflow@latest release-status --repo <path> --component <name>233 ```234 Returns `state`, `workflowPattern`, `releaseBase`, the version on main (and dev for two-branch repos), the last tag, every commit since that tag that235 touched this component's paths, a `suggestedBump` with its reason, `blockers`, `notes`, and a236 `statusHash`. `state` decides the path:237 - `clean` — the released version is what's on main. A bump is needed: go to step 2.238 - `untagged-bump-on-main` — the bump is already on main and was never tagged (a cancelled or239 failed release run). **No PR is needed** — `release-cut` dispatches and verifies. Skip to step 3.240 **`untagged-bump-on-main` is not, by itself, permission to cut.** Check `devAhead` first: if241 it is set, dev already carries a *higher* version than what's on main, and cutting here would242 tag the version on main, not the one on dev — the version you almost certainly mean to243 release. `release-cut` refuses in this shape unless you pass `--version` naming exactly which244 one to release (see step 3); it never guesses.245 - `bump-on-dev-unpromoted` — the bump is on dev, waiting for a promotion. Skip to step 3.246 - `version-behind-tag` — main carries a *lower* version than an existing tag. Stop and ask;247 this means a tag was cut from something other than main, and guessing is how it gets worse.2482492. **Show the user `collateral`, `blockers` and the proposed version, and wait.**250 A `dev → main` promotion is atomic and carries all of dev, so every component listed under251 `collateral` has its bump moved to `main` by the same promotion. It is **not released** by252 that — every caller's release job is `workflow_dispatch`-only, so merging tags nothing; each253 becomes `untagged-bump-on-main`, one deliberate `release-cut` away from a tag.254 **Never run `release-cut` without naming that list to the user first.** They should know what255 their promotion moves, and which components are now one dispatch from a release nobody asked256 for.257258 `suggestedBump` is a suggestion. The user decides, and a `suggestedBumpCapped: true` means a259 breaking change was held at minor because the component is still 0.x — going to 1.0.0 is a260 release decision, never a commit message's. Then:261 ```262 npx -y @natjswenson/shipflow@latest release-prepare --repo <path> --component <name> \263 --version <x.y.z> --notes-file <path>264 ```265 Local only, no network. It works in a **throwaway git worktree**, so unrelated uncommitted work266 in the user's tree is untouched and cannot be swept into the release commit. The version bump267 and the CHANGELOG entry land in **one commit** — the notes are read off `main` at dispatch268 time, so a CHANGELOG that lands in a later promotion than its version is notes the release269 will never carry.2702713. **Cut it, and prove it.**272 ```273 npx -y @natjswenson/shipflow@latest release-cut --repo <path> --component <name> \274 --version <prepared-or-confirmed-version> --expect-status-hash <hash-from-step-1> --wait 240275 ```276 `--expect-status-hash` is mandatory (same TOCTOU discipline as `apply`'s `--expect-state-hash`);277 `--skip-hash-check` is a named escape hatch, never a default.278279 If step 1's `devAhead` was set, `release-cut` refuses outright with an error naming both280 versions — this is the ambiguous three-way state (main has an untagged bump, dev already281 carries something higher) where guessing would tag the wrong one. Promote `dev → main` and282 re-run `release-status` to release what's on dev (the normal recovery), **or** add283 `--version <x.y.z>` naming exactly the version on main, if you deliberately mean to release284 that one and leave dev's higher version for later. `--version` is a confirmation, not a285 bypass — it is only ever accepted when it matches a version already on main, dev, or the verified prepared branch; anything286 else is refused the same as passing nothing.287288 **`release-cut` is resumable and bounded, and it will usually return `done: false`.** The full289 path — feature PR, checks, merge, promotion, auto-merge, **dispatch**, release run, tag — takes290 longer than one call should block for. Each call advances as far as it can, then returns the291 `stage` it is parked at and a `next` line. **Call it again, unchanged, until `done: true`.** It292 derives every stage from live remote state and never from a record of what a previous call did,293 so a resumed run and a fresh one are the same code path.294295 **Merging cuts nothing.** `release-cut` dispatches the component's release296 workflow itself, after the selected version and notes reach main — that dispatch is the single point at which any tag297 is created in this repo, which is why a merge can no longer surprise anyone with a release.2982994. **Report the tag, and only the tag.** `done: true` carries `tag` and `releaseUrl`, read back300 from origin. A dispatched workflow, a merged PR and a green check are **not** a release —301 `release-cut` confirms the tag exists on the remote before it says done, and so must you.302303## Auto mode (not yet implemented)304305`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.306307## Error handling308309- **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.310- **`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.311- **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`.312- **`gh auth` failures:** surface these immediately; branch protection and rulesets need repo-admin scope. Don't proceed partway through a plan on missing auth.313- **`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.314- **`--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.315- **`--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.316- **`release-cut` returns `done: false`:** not an error. It is parked at the `stage` it reports,317 waiting on something remote. Call it again with the same arguments. Do not report a release.318- **`release-status` reports `component-files-dirty`:** this component's own version files or319 CHANGELOG have uncommitted edits, so a bump would collide with them. Unrelated dirt elsewhere in320 the tree is reported under `notes` and is deliberately **not** a blocker — `prepare` runs in an321 isolated worktree specifically so other people's in-flight work is safe.322- **`release-status` reports `version-unreadable-on-main`:** the component's version files do not323 exist on main, or they disagree with each other. A disagreement is a hard refusal, never a324 "pick the highest" — releasing from a disagreeing set tags one version and ships another.325- **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.326327## Security rules328329- 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.330- `.github/shipflow.json` is committed policy, not secrets — never write credential *values* into it, only the *name* of a secret (`release.releaseCredential`).331- Never write shipflow's config anywhere other than `.github/shipflow.json` in the target repo.332- **`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.333334## Edge cases335336- **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.337- **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.338- **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.