/shipflow — branching + release-automation setup
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/devas a starting guess — you'll confirm them next.) This prints aRepoStateplus aprotectionOwnerClassificationof"external","shipflow", or"ambiguous", and now also arankedPatternsarray — every pattern's{id, score, evidence}, sorted descending by score.Resolve
workflowPatternbefore anything else — agithub-flowrepo never asks about adevbranch name at all, so this has to happen before step 3 below. ClassifyrankedPatternsper these rules: confident if the top score is>= 0.7AND 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
evidencearray) — "I detected this repo is using<pattern-id>because:<evidence bullets>. I'll setworkflowPatternto 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-liveddev+main; a promotion PR auto-mergesdevintomain.github-flow— single long-livedmain; every PR merges (and auto-merges) directly tomain. Suggest this as the lightweight default for a greenfield repo specifically, without auto-picking it.gitflow—develop+main+ transientrelease/*/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
devbranch name undergithub-flow, for instance. - If
workflowPatternisgitflow, additionally ask forreleaseBranchPrefix/hotfixBranchPrefix(defaulting torelease//hotfix/if the user has no preference) — recorded underpatternConfig.gitflowin the config.
- Confident: state what was detected and why (the top entry's
Resolve a default-branch mismatch, if any. Compare
repoState.repoSettings.defaultBranch(the repo's actual GitHub default branch) to the--mainname used in step 1. If they match, skip to step 4. If they differ (e.g. the repo's default ismaster), ask the user explicitly — do not silently assume either path:- Map onto the existing default branch — set the config's
branches.mainto the detected default branch name and continue with the rest of setup treating that as "main." No mutating calls needed;branches.mainis 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:
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 needsnpx -y @natjswenson/shipflow@latest rename-default-branch --repo <path> --branch <old-default> --to maingit fetch origin && git checkout mainto follow, then re-run step 1'sdetect(repo state changed) before continuing.
- Map onto the existing default branch — set the config's
Confirm branch names and required checks with the user. Show
workflows.jobNamesfrom the detect output as candidaterequiredChecks(this list is already filtered to jobs from workflows that actually trigger onpull_request— a job that only runs onschedule/workflow_dispatchcan never satisfy a required check, so it's never offered as a candidate) and let the user confirm/edit the list. An emptyrequiredCheckslist is a fail-open state, not a valid steady state —shipflow applywill 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
ghfactoryskill — 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 eachwith:key against the action's ownaction.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, conservativepull_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 (settingsAsCodeArtifactin 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 toGITHUB_TOKEN. The rendered auto-merge workflow'sGH_TOKENcomes from this secret name. A PR auto-merged undersecrets.GITHUB_TOKENcompletes (once checks pass) attributed to thegithub-actions[bot]identity, and GitHub's loop-prevention rule means that bot-attributed merge'spull_request: closedevent never triggers this or any other workflow — solabel-release-pendingsilently 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 byGITHUB_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 withcontents: 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 inrelease.releaseCredential— never its value.Present the interview summary and write
.github/shipflow.json. Before writing anything, show the user the resolvedworkflowPattern, branch names,requiredChecks,protectionOwner, andrelease.releaseCredentialtogether 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) usingconfig.example.jsonas the template, withrelease.mode: "manual-gate"(the only implemented mode in this version — see Auto mode, below). Tell the user.github/shipflow.jsonis committed policy and should begit 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 }. Presentplan.creates/plan.updates/plan.noopsto 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 hashandEditDetected: 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-runApply for real, passing the
stateHashfrom 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,applyrefuses to mutate anything and tells you to re-plan.--expect-state-hashis 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-checkescape 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
handEditDetectedentry 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--forcewithout 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/errorsfrom the response. Askippedentry 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 eachreasonand relay it plainly rather than treating everyskippedentry the same. IfrenderedTemplateHashesis non-empty, update.github/shipflow.json'srenderedTemplateHashesfield 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 falsehandEditDetectedon 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.
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 → mainPR still labeledrelease-pending, each with amergedflag (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
mergedisfalse, skip it for now (native auto-merge hasn't landed yet; don't ask about a promotion that isn't actually onmain). Ifmergedistrue, 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 mainIf
dispatchedshows a partial failure, the label is deliberately left in place — report this to the user and note the promotion will resurface next timereleasesis 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.
Read the state. Never guess it.
npx -y @natjswenson/shipflow@latest release-status --repo <path> --component <name>Returns
state, the version on main and dev, the last tag, every commit since that tag that touched this component's paths, asuggestedBumpwith its reason,blockers,notes, and astatusHash.statedecides 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-cutdispatches and verifies. Skip to step 3.untagged-bump-on-mainis not, by itself, permission to cut. CheckdevAheadfirst: 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-cutrefuses in this shape unless you pass--versionnaming 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,blockersand the proposed version, and wait. Adev → mainpromotion is atomic and carries all of dev, so every component listed undercollateralhas its bump moved tomainby the same promotion. It is not released by that — every caller's release job isworkflow_dispatch-only, so merging tags nothing; each becomesuntagged-bump-on-main, one deliberaterelease-cutaway from a tag. Never runrelease-cutwithout 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.suggestedBumpis a suggestion. The user decides, and asuggestedBumpCapped: truemeans 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
mainat 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> \ --expect-status-hash <hash-from-step-1> --wait 240--expect-status-hashis mandatory (same TOCTOU discipline asapply's--expect-state-hash);--skip-hash-checkis a named escape hatch, never a default.If step 1's
devAheadwas set,release-cutrefuses 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. Promotedev → mainand re-runrelease-statusto 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.--versionis a confirmation, not a bypass — it is only ever accepted when it matches a version already on main or dev; anything else is refused the same as passing nothing.release-cutis resumable and bounded, and it will usually returndone: 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 thestageit is parked at and anextline. Call it again, unchanged, untildone: 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.The promotion merging cuts nothing.
release-cutdispatches the component's release workflow itself, after the promotion lands — 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: truecarriestagandreleaseUrl, read back from origin. A dispatched workflow, a merged PR and a green check are not a release —release-cutconfirms 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:applyrefuses 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--forcefor this; always show the user what changed and get explicit confirmation per entry.- TOCTOU abort: if
applyreturns atoctouerror, repo state changed between plan and apply — re-run the plan step, don't retry the same--expect-state-hash. gh authfailures: surface these immediately; branch protection and rulesets need repo-admin scope. Don't proceed partway through a plan on missing auth.release.releaseCredentialleft as (or defaulted to)GITHUB_TOKEN: auto-merge and the required-check gate still work, butlabel-release-pendingwill silently never run — aGITHUB_TOKEN-attributed auto-merge'spull_request: closedevent never triggers it, so no promotion will ever surface viashipflow 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 requiredrefusal: a real apply was attempted with neither--expect-state-hashnor--skip-hash-check. Go back and get (or re-fetch viaplan) the hash — don't reach for--skip-hash-checkjust to make the error go away; that flag exists for a deliberate, documented exception, not as a default workaround.--force was passed without --force-reasonrefusal: a--forceflag 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-cutreturnsdone: false: not an error. It is parked at thestageit reports, waiting on something remote. Call it again with the same arguments. Do not report a release.release-statusreportscomponent-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 undernotesand is deliberately not a blocker —prepareruns in an isolated worktree specifically so other people's in-flight work is safe.release-statusreportsversion-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/gitcall hangs or times out: every subprocess call has a 30-second timeout (ETIMEDOUTsurfaces in the error message). A timeout ondetect/planusually 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/gitinvocations in the CLI are argv-style (spawnSyncwith an args array, no shell) — never construct a shell command string from user input when extending this skill. .github/shipflow.jsonis 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.jsonin the target repo. renderTemplatevalidates every substituted value before writing YAML, and this must never be weakened.config.branches.dev/mainandrelease.releaseCredentialare 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'sif: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 inTOKEN_VALIDATORSbefore it ships — never assume a config field is pre-sanitized.
Edge cases
- Greenfield repo, no CI yet:
requiredCheckswill 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.jsonwithrelease.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).
applyreports this as askippedentry with that reason, not anerrorsentry — it's an expected environment limitation, not a shipflow bug. Cleanup and the release-pending label still apply normally.