Merge and ship
Landing a PR in these projects is three steps that belong together: merge → ship to production → clean up. People remember the merge and forget the other two, so changes sit unshipped or worktrees/branches pile up. This skill keeps them attached and, just as importantly, verifies each step actually happened instead of assuming it did.
It is written for the user's deployed apps (travel_app, traderprep, and
siblings — mostly under C:\dev; a few older projects still live under a
OneDrive path with a space in it, CS Programs — quote paths). OneDrive
introduces a few specific failure modes this skill is built to absorb (see
Failure modes). Note the same repo can be
built by MORE THAN ONE platform (traderprep: Render serves production,
Vercel still auto-builds previews) — which is why step 3 starts by
identifying the host that actually serves the domain.
The one rule that makes this safe
Never destroy the user's uncommitted work to unblock a step. No
git checkout -- <file>, git reset --hard, git stash, or git clean on
files you didn't create, just to make a fast-forward or a deploy go through. If
the working tree is in the way, work around it (deploy from a throwaway
worktree — see step 3). Discarding their changes is almost never what they
want and is hard to undo.
Before you start
Confirm you're in the project's git repo (the one whose PR this is), not the workspace root.
You usually don't need a PR number — resolve it yourself and only ask when it's genuinely ambiguous. "Merge my PR" / "merge it" / "ship it" with no number is the common case, not an error. Resolve in this order:
- Number given → use it.
- The current branch's PR →
gh pr view --json number,state,title,headRefName(with no number,gh pr viewtargets the PR for the checked-out branch). If it exists and isOPEN, that's the target — this is the most common case and needs no prompt. - Exactly one open PR in the repo →
gh pr list --state open --json number,title,headRefName; if the list has one entry, that's it. - Only ask when truly ambiguous — several unrelated open PRs and the current branch isn't one of them. Show the short list and let the user pick.
Always narrate which PR you resolved before acting ("merging the open PR for this branch — #9, 'Size album cover preview…'") so a wrong guess is caught before the merge, not after.
Step 1 — Pre-merge checks
Verify the PR is actually ready. Don't merge on faith.
gh pr view <N> --json state,mergeable,mergeStateStatus,baseRefName,headRefName
gh pr checks <N> # CI status — don't merge over red checks
stateisOPEN,mergeableisMERGEABLE,mergeStateStatusisCLEAN(orUNSTABLEonly if the failing checks are non-blocking and the user is aware).baseRefNameis the intended base (usuallymain). A PR accidentally based on the wrong branch is a common foot-gun — surface it, don't merge.- If CI is red or pending, say so and stop. Ask before merging anyway.
needs-local-ui-checklabel → FAIL the pre-merge check unless the user has already confirmed the UI pass earlier in this conversation. This is the local twin of a factory cloud worker's E2E gap (spec §5.1 step 5): cloud PRs can't drive a browser, so a UI-heavy task's PR gets this label and the merge gate refuses it until a human actually looked at it. Check labels withgh pr view <N> --json labels; if present and unconfirmed, stop and ask the user to do the UI pass (or confirm they already did) before proceeding — do not merge on the strength of green CI alone for these PRs.
If the user invoked the skill with a PR number, that is the go-ahead to merge — once the checks pass, proceed without a second "shall I merge?" gate. Just narrate a one-line plan ("PR #9 is clean, base main — squashing, then deploying").
Step 2 — Merge
Match the repo's existing merge convention rather than imposing one. Look at recent history:
git log --oneline -8 origin/<base>
If each past PR is a single commit titled … (#N), the repo squash-merges —
use --squash. If you see merge commits, use --merge. When in doubt,
--squash is the safe default for these projects.
gh pr merge <N> --squash --delete-branch
Gotcha — --delete-branch fails when the branch is checked out in a
worktree. These projects use git worktrees heavily (.worktrees/…,
.claude/worktrees/…, Codex worktrees). If the PR's head branch is checked out
in one, --delete-branch errors with "cannot delete branch … used by
worktree at …". This does not mean the merge failed — the squash-merge on
the remote succeeds. But the failed local delete also aborts the REMOTE branch
deletion (gh runs both as one post-merge step), so the branch survives on
origin too — clean up both sides in step 4. Confirm the merge with:
gh pr view <N> --json state,mergeCommit # expect state MERGED + a commit sha
Note the merge commit SHA — step 3 deploys exactly that. Defer the leftover branch/worktree to step 4.
Step 3 — Ship to production
The merge put the code on the remote main, but that does not always mean
it's deployed.
First: confirm which host actually serves the production domain
The same repo can be connected to more than one platform, and everything downstream — deploy mode, env vars, webhooks, where to read runtime logs — belongs to the host serving the domain, not whichever dashboard you happen to have open. One header read settles it:
curl -sI https://<production-domain>/ | grep -iE "server|x-vercel-id|x-render-origin-server"
# x-vercel-id → Vercel serves it
# x-render-origin-server → Render serves it (often behind "server: cloudflare")
Known example: traderprep.org is served by Render (auto-deploys from
main); the connected Vercel project only builds branch previews and
traderprep.vercel.app. A Vercel deploy reaching READY there ships nothing
to the live domain. Don't debug "prod" env/deploys until this check is done.
Then read the project's own deploy docs — they record per-project quirks you can't infer:
# look for a "## Deploy" section and any deploy script
grep -iA6 "deploy" AGENTS.md CLAUDE.md README.md 2>/dev/null
cat vercel.json package.json 2>/dev/null | grep -i deploy
ls .vercel 2>/dev/null
Two modes:
- Auto-deploy mode (e.g. traderprep — Render auto-deploys
mainto traderprep.org): the merge already triggered the deploy. Your job is to verify it landed, not to deploy again. Check the serving host's dashboard/CLI for a new production deployment that goes live (for a Vercel- served project,npx vercel lsreaching READY; for Render, confirm via the live-domain check in "Verify the deploy" below). Only fall back to a manual deploy if nothing new appears within a few minutes. - Manual mode (e.g. travel_app — AGENTS.md states pushes don't auto-deploy because the Git integration isn't firing; the user has standing authorization to deploy, no need to re-ask): you must run the deploy yourself.
Manual deploy — and why the working tree matters
npx vercel --prod (or vercel deploy --prod) uploads the current working
directory's files, not a git commit. This is the trap: if you run it from a
repo whose working tree has uncommitted WIP — or is still on the pre-merge
commit — you'll ship the wrong bytes (WIP code, or code missing the PR you just
merged). So you must deploy from a tree that is the merged commit.
git fetch origin
If the working tree is clean and local <base> fast-forwards cleanly:
git merge --ff-only origin/<base> # safely aborts if it can't; never forced
npx vercel --prod --yes # from the repo root
If the fast-forward is blocked ("Your local changes … would be overwritten") or the tree has uncommitted WIP — deploy the exact merged
commit from a throwaway detached worktree, leaving the user's tree untouched:
git worktree add --detach .worktrees/deploy-prod-tmp <merge-sha>
cp -r .vercel ".worktrees/deploy-prod-tmp/.vercel" # carry the project link
cd .worktrees/deploy-prod-tmp && npx vercel --prod --yes
If .vercel/ has only repo.json (repo-level linking) and no
project.json, the CLI may not target the project non-interactively — write
one from the ids in repo.json:
# project.json: {"projectId":"<id from repo.json>","orgId":"<orgId from repo.json>"}
Verify the deploy — don't assume
A deploy command returning isn't proof. Confirm, in two layers:
Layer 1 — the deployment exists and is live:
- The
--prodJSON output shows"readyState": "READY"and"target": "production", and the production alias was re-pointed (look forAliased https://<your-domain>in the output), or npx vercel lsshows the newest Production deployment is minutes-old (or the Render dashboard/live check below for Render-served domains).
Layer 2 — the shipped change actually works on the PUBLIC domain. READY/live proves the build, not the feature: runtime-only failures (edge-runtime restrictions, module-load crashes, env-var problems) have surfaced ONLY on the public production domain — invisible locally and unverifiable on previews, because preview deployments are SSO-gated (curl gets a 302 to an auth interstitial, so "the preview worked" was never actually tested). Hit the route(s) the PR touched on the real domain:
curl -sS -o /dev/null -w "%{http_code}" https://<production-domain>/<changed-route>
# Windows curl dying with schannel error 0x80092013 (revocation server
# offline)? Add --ssl-no-revoke — environment noise, not a server problem.
For an API/OG-image/binary endpoint, check the content-type or first bytes, not just the status. If the change is visual and non-trivial, load it in Chrome per the global UI-testing rule.
State the deployed URL/alias, READY status, and the live-route check in your report. If it errored or stuck in BUILDING, say so — a failed deploy after a successful merge is the worst silent outcome.
Step 4 — Clean up
Tidy what the merge left behind. Each item is best-effort — never let a cleanup failure undo or obscure the successful merge+deploy, and never discard uncommitted work to make cleanup easier.
Throwaway deploy worktree (if you made one):
git worktree remove --force .worktrees/deploy-prod-tmp. On OneDrive/Windows the folder delete may fail withPermission denied/Device or resource busyeven though--forcealready deregistered the worktree from git — git state is then clean and the orphan folder clears on the next OneDrive sync. Don't fight it; note it and move on.Merged feature branch + its worktree. If
--delete-branchwas skipped because the branch was in a worktree: check that worktree for uncommitted changes (git -C <wt> status --short). If clean,git worktree remove <wt>then delete the local branch withgit branch -D(squash merges aren't ancestors of main, so-drefuses "not fully merged";-Dis correct oncegh pr viewshowsMERGED). Also delete the REMOTE branch — the aborted--delete-branchleft it on origin:git push origin --delete <branch>(batching several is fine), then confirm withgit ls-remote --heads origin <branch>(empty = gone). If the worktree has uncommitted WIP, leave it and report it — don't force-remove and lose the work.Fast-forward local
<base>so it matches the shipped remote — but only if the working tree allows it (step 3's--ff-only); never force it past the user's uncommitted changes.Prune the shipped task from the queue. The merge+deploy you just finished is the moment a queued task is actually done, and you hold the only key that identifies it: this PR's number and head branch. Follow the
task-managecontract (~/.claude/skills/task-manage/references/tasks-md-contract.md) — the queue has no done state, so "done" means deleting the matching entry, not adding a[done]marker. Two cases:Factory-managed project (present in
C:\dev\factory\config\factory.json): the queue isC:\dev\factory\queues\<project>.md.git -C /c/dev/factory pullfirst — read current state, not a stale copy.- Match on an unambiguous key (same rule as below): find the one task
whose
Evidencerecords this PR (URL/number) or this head branch, using the immutable task ID (e.g.tp-014) in the header line to identify it precisely. - Delete that task's entire block from the queue file; preserve the rest of the file's order and format.
- Then renumber the remaining headers 1..N (the validator rejects gaps); IDs stay untouched.
- Write a
prunedledger event file atC:\dev\factory\ledger\events\<YYYYMMDDTHHMMSSZ>-<6-char [a-z0-9]>-pruned.json(schema per spec §4.3:v:1,ts,actor: "local:<project>",run,event: "pruned",project,task: "<id>",detail,pr: "<PR URL>"). ThetsFIELD is full ISO 8601 (2026-09-03T13:30:53Z) — only the FILENAME uses the compact20260903T133053Zform. Mixing them up turns the factoryvalidateCI red (it did on 2026-09-03). Runnode scripts/validate.mjsin the factory checkout before pushing. - One commit covering both the queue edit and the new event file:
git -C /c/dev/factory add queues/<project>.md ledger/events/<new-file>then commitstate: prune <id> after merge of PR #<n>. git -C /c/dev/factory push. On rejection: this specific case (one deleted task block + one newly added event file, nothing else touched) is safe togit -C /c/dev/factory pull --rebaserather than the full discard-and-rebuild dance — a concurrent writer can only have appended other event files or edited other tasks, so a rebase can't silently reclaim a task someone else is still working. Retry the push once after the rebase; if it fails again, leave the commit as-is locally (do not force-push, do not loop) and tell the user to resolve/push it manually.
Non-factory project (a local
TASKS.md, if the project keeps one):- Match on an unambiguous key. Find the one task whose
Evidencerecords this PR (URL/number) or this head branch — tasks in these repos embed both (e.g.PR …/pull/11,branch task/use-album-actions-hook). Delete only that entry; preserve the rest of the file's order and format. - Don't guess. No entry matches → leave the file untouched. Multiple
entries match, or the matched entry has unfinished follow-up beyond what
this PR shipped → don't delete; flag it for the user (or a later
task-managepass). This mirrors the contract's "if merge evidence is unclear, keep the task" rule. - This single delete is the only write
merge-and-shipmakes toTASKS.md. Promotion, reprioritizing, and bulk pruning stay withtask-manage, the file's owner — here you're only retiring the item you just shipped.
Report what you couldn't auto-clean and why, so the user can finish it: e.g. "left
.worktrees/foo— it has an uncommitted IDEAS.md edit" or "localmainstill behind; you have CRLF-only changes on two files blocking the fast-forward."
Failure modes (fold these in)
These are the specific traps that have bitten in these repos. Recognize the symptom, apply the fix, don't re-debug from scratch.
- Stale
.git/index.lockblocks every git command ("Unable to create '…/index.lock': File exists. Another git process seems to be running"). Common on OneDrive (sync touches.git) or after a crashed/interrupted git op. Before removing it, confirm it's stale: check its age (ls -la .git/index.lock) and that no git process is live (tasklist | grep -i giton Windows). If old and orphaned,rm -f .git/index.lockand retry. Linked worktrees have their own lock at.git/worktrees/<name>/index.lock, so the main.git/index.lockbelongs to the primary tree alone. git merge --ff-onlyrefuses over files with no real change. OneDrive's LF→CRLF normalization marks filesMwith an empty content diff (git diff --statshows nothing for them). The fast-forward still refuses because the index is stat-dirty. Do notgit checkout --them to unblock — that's discarding the user's tree state without their say-so (and the permission classifier will rightly block it). Deploy from the throwaway worktree instead (step 3).verceldeploys the cwd, not a ref. Covered in step 3 — the reason the clean/dirty branch exists at all..vercelrepo-level linking has noproject.json. Covered in step 3 — hand-write one fromrepo.json.git worktree remove"Permission denied" under OneDrive. Covered in step 4 —--forcestill deregisters; the orphan folder is harmless.- Merged to main but no production deploy appeared (auto-deploy mode). If
main was fast-forwarded onto a SHA Vercel already built as a preview (the
feature-branch tip), Vercel dedups deployments by commit SHA and never
creates the production deployment. Fix: push a new SHA
(
git commit --allow-empty -m "chore: trigger production deploy"on main) or Promote/Redeploy the existing deployment in the dashboard (a Redeploy-to-Production rebuilds with prod env vars). Squash/merge commits create fresh SHAs and avoid this; it bites on manual fast-forward merges. gh pr mergefails withfatal: bad object worktrees/<name>/HEAD+ "did not send all necessary objects". A stale half-created worktree with an all-zero HEAD poisons fetch negotiation; the wording blames the remote but the problem is local. The PR usually still merged fine server-side — confirm withgh pr view <N> --json state,mergedAtFIRST. Repair:git worktree listto find the culprit (0000000 (detached HEAD)); if its dir holds only a.gitpointer file,rm -rf .git/worktrees/<name>(and the dir); otherwise write a real commit sha into.git/worktrees/<name>/HEADand remove the staleHEAD.lock.gh: command not foundfrom the Bash tool. The Bash tool's Git Bash PATH omitsC:\Program Files\GitHub CLI(and apowershell.exespawned from it inherits the same stripped PATH). gh IS installed — call it by full path:"/c/Program Files/GitHub CLI/gh.exe" ….- Env vars broke only one environment. Env vars live per-host AND
per-scope (Vercel Production vs Preview; Render separately) — a var set on
the non-serving host does nothing for the live domain. Two recorded traps:
a stray trailing newline in a pasted key breaks every request that embeds it
in a header (
TypeError: Headers.append: … invalid header value→ login returns a generic error), andNEXT_PUBLIC_*values are inlined at build time, so fixing one requires a redeploy, not just saving the var. - Don't treat
tsc --noEmitas the gate if a project's docs say its real gate isnpm run lint(some repos carry a standingtscerror baseline). Respect each project's documented checks.
What to report at the end
A tight summary: PR # merged (merge SHA + method), deploy status (READY +
production URL/alias, or "auto-deploy verified"), and cleanup outcome — branch
and worktree removal, the local <base> fast-forward, and whether a queue
entry (factory queues/<project>.md task ID, or a local TASKS.md entry) was
pruned (or why it was left) — including anything left for the user and why. If
the local <base> wasn't fast-forwarded, a worktree/branch wasn't removed, a
task was left in the queue, or the factory push needed manual resolution after
a failed rebase retry, name it and give the one-liner to finish it.