| name | branch-promotion-model |
| description | Use when bootstrapping a new repo's CI, setting up deploy workflows, deciding what branch a feature PR should target, or whenever someone proposes "auto-deploy prod on every main merge". Captures the feat → dev → main promotion model with separate Deploy Dev (push to dev) and Deploy Prod (push to main), and links the templates that implement it. |
Branch promotion model
The rule
Adopt a real promotion flow across every deploying repo:
feature/<id> → PR into dev → merge fires Deploy Dev
↓
soak / validate
↓
PR `dev` into `main` → merge fires Deploy Prod
dev is the integration branch. Feature PRs target dev, not main.
main is production. The only PRs to main are dev → main promotion PRs. After each promotion merges, back-merge main into dev (git merge --ff-only origin/main) so the two never drift — see "When promoting dev → main" below.
Deploy Dev workflow trigger: on: push: branches: [dev].
Deploy Prod workflow trigger: on: push: branches: [main].
- Keep
workflow_dispatch on both as a manual escape hatch, not as the normal trigger.
For non-deploying repos in the same org (docs, templates, plugin bundles) — still create a dev branch and route feature PRs through it, even if no workflow fires. Uniform process across repos prevents "wait, where do I PR this?" friction.
Why
Real incident (2026-05-29, learning-with-court/platform). The repo's initial setup had Deploy Dev AND Deploy Prod both triggering on push to main. A single feature merge auto-deployed to dev and prod in lockstep — no soak gate. The pattern looked safe until it wasn't:
- A correctness-fine feature (walker-post-lesson-summary) was promoted under that model.
- An unrelated CI/quota issue (workshop fetch hammering GitHub REST per file) surfaced mid-burst because cumulative PR + deploy activity exhausted the GitHub App installation's 15000/hr rate limit.
- Under the lockstep model, that would have failed in prod. Under the promotion model, it failed at the
dev → main promotion PR — visible, recoverable, not a prod incident.
The promotion model didn't catch the API quota issue per se — but it gave a clean place to ship the fix for that issue (workshop-fetch-rate-limit-fix) onto dev, observe the deploy succeed in 1m13s with the new code, then deliberately promote to prod with confidence. That's the structural payoff: every fix gets a soak before prod, every prod deploy is a chosen moment, not a side effect.
The cost is one extra PR cycle per feature. Worth it.
How to apply
When bootstrapping a fresh repo
- Create
main (usual) and dev off main. Push both.
- Drop in the workflows from
templates/github-workflows/:
deploy-dev.yml triggers on push: branches: [dev].
deploy-prod.yml triggers on push: branches: [main].
ci.yml triggers on pull_request: [dev, main] — the server-side gate. The promotion soak only catches what's deployed; ci.yml is what blocks a red PR from merging in the first place. It's the enforceable third leg alongside the lefthook commit/push hooks (which have an escape path — --no-verify, or not running lefthook install — see quality-stack-setup).
- All keep
workflow_dispatch for manual reruns.
- Enforce it server-side — this is what separates the promotion model from a naming convention. See the
github-repo-setup skill for the exact gh commands: branch protection on main (PR required, ci a required status check, no direct push), dev/prod deploy environments, and required reviewers on prod — the literal mechanism behind "every prod deploy is a chosen moment." Without protection, nothing stops a direct push to main that skips the soak entirely.
When promoting dev → main (every promotion)
Open the promotion PR (dev base main), let CI + required reviewers gate it, and merge. Then immediately back-merge main into dev — this is not optional cleanup, it's the closing half of the promotion:
git checkout dev
git fetch origin
git merge --ff-only origin/main
git push origin dev
Why this step exists. GitHub's "Create a merge commit" (the default, --no-ff) writes a merge commit onto main whose parents are (old main tip, dev tip). That merge commit lives on main and never on dev, so it accumulates — one bubble per promotion:
git log origin/dev..origin/main → N merge bubbles (grows every promotion)
git log origin/main..origin/dev → 0
git diff origin/dev origin/main → empty (trees are identical)
Content is always in sync; the commit graph drifts, and dev is N behind main is a recurring, confusing-but-cosmetic readout. The back-merge drives it permanently to zero.
Why the --ff-only always succeeds. The promotion merge commit's second parent is dev's tip, so dev is already an ancestor of main. dev can only move forward to that merge commit — no new merge commit, no conflict possible. After the push, git rev-parse dev main are identical and dev..main is 0 until the next feature lands. If --ff-only ever refuses, that's a real signal (a direct commit landed on main outside the promotion PR) — investigate, don't --no-ff over it.
Don't try to fix this at the source by fast-forwarding the promotion itself (git merge --ff-only dev into main): GitHub's merge button can't do ff-only, it fights branch protection on main, and "Rebase and merge" rewrites commit hashes so the two diverge again. Back-merging is the only approach that keeps the PR-based, protected promotion flow and keeps the graphs identical.
When migrating an existing repo from "merge to main = both envs"
The cutover order matters because the workflow file's trigger is read from the pushed commit:
- Create
dev off main and push it.
- On a
chore/branch-promotion-model branch, edit deploy-dev.yml's trigger from branches: [main] → branches: [dev].
- PR that change into
main. Merging triggers one no-op Deploy Prod (only the workflow file changed); after that, main deploys prod only.
- Fast-forward
dev to main so dev carries the new workflow config too. Push dev.
- If a feature PR was in-flight against
main mid-cutover, retarget it to dev (gh pr edit <n> --base dev) or close-and-reopen for a cleaner title.
- From here on, the normal flow: feature →
dev → soak/validate → dev → main PR → prod.
When someone proposes "auto-deploy prod on every main merge"
Push back. The collapsed model looks cheap until the day a deploy needs to be staged or paused. The right move is the promotion model from the start; retrofitting later (as in the 2026-05-29 cutover) is more work than the small initial setup cost.
Templates referenced
${CLAUDE_PLUGIN_ROOT}/templates/github-workflows/deploy-dev.yml
${CLAUDE_PLUGIN_ROOT}/templates/github-workflows/deploy-prod.yml
${CLAUDE_PLUGIN_ROOT}/templates/github-workflows/ci.yml — the pull_request gate.
Customize the secrets.*, with: paths, smoke-check URLs, bundling steps, and ci.yml's lint/test/build commands — keep the trigger blocks and concurrency groups as written. The GitHub-side enforcement (protection, environments, secrets) lives in the github-repo-setup skill.