SKILL.md
Git Workflow Procedures
Invoke with /tzurot-git-workflow for step-by-step git operations.
Safety rules are in .claude/rules/00-critical.md - they apply automatically.
Commit Procedure
1. Stage Changes
git status # Review what's changed
git add <specific-files> # Stage specific files (preferred)
# Or: git add -p # Interactive staging
2. Create Commit
git commit -m "$(cat <<'EOF'
feat(scope): short description
Longer explanation of what and why.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <[email protected]>
EOF
)"
Types: feat, fix, docs, refactor, test, chore, perf, debug (debug = temporary diagnostic instrumentation, added then removed; see .claude/rules/05-tooling.md § "The debug type" for when to use it vs. chore/feat.) Scopes: generated from every packages/+services/ directory, plus tests and a static root set (backlog, ci, deps, docs, hooks, husky, legal, prisma, repo, rules, skills) — source of truth is allScopes in commitlint.config.cjs.
Command-shape rules for commit/push (each class cost multiple cycles in practice):
- Chain with
&&, never;— a hook-rejected commit must halt the chain; with;the dead commit flows into a push that no-ops as "Everything up-to-date" and the rejection reason scrolls away. - Pass
timeout: 600000on Bash calls that commit or push — lint-staged + the pre-push gate run the full local pipeline (minutes); default timeouts kill mid-hook and leave ambiguous state (commit landed, push didn't). - In compound commands that
cdinto a package, run the git step asgit -C <repo-root> …(or with absolute paths) — repo-relative pathspecs break after the cd, failing AFTER the tests already passed. - Create a new branch in a SEPARATE Bash call, before staging/committing — never chain
git checkout -b <branch> && git add … && git commit …. Thedevelop-code-commit-guardPreToolUse hook evaluates the CURRENT branch before the command runs, so on a compound "branch-then-commit" it still seesdevelop/mainand blocks the commit as an on-long-lived-branch code commit. Rungit checkout -bfirst, confirm the branch, then stage + commit in the next call. - Canonical push-verify (don't improvise greps — a pattern starting with
-parses as an option flag). Use thisls-remoteform when you need a scriptable boolean; the-> branchref-update line /git status -sbcheck below is the quick visual form — same goal, pick by context:
test "$(git rev-parse HEAD)" = "$(git ls-remote origin "refs/heads/<branch>" | cut -f1)" && echo PUSH_LANDED
3. Push
pnpm test && git push -u origin <branch>
Verify every push actually landed before proceeding (and before arming the CI Monitor): confirm the -> branch ref-update line in the push output, or git status -sb showing in-sync.
PR Procedure
Create PR
# 1. Ensure on feature branch, up-to-date with develop
git checkout develop && git pull origin develop
git checkout feat/your-feature
git rebase develop
# 2. Push and create PR (--assignee @me is owner policy: every human-authored
# PR carries its creator as assignee, bots stay unassigned;
# pr-monitor-reminder.sh backfills the PR author if missed)
git push -u origin feat/your-feature
gh pr create --base develop --title "feat: description" --assignee @me
Before writing a closing reference in the PR body
The moment before typing Closes TASK-N (or "completes doc-N", "finishes the X sweep") is the trigger — re-open the referenced task file and QUOTE its acceptance line verbatim into the PR body, then state per clause whether it is met. Recalling the acceptance from memory is what fails: an overclaim survives paraphrase easily and rarely survives being placed next to the words it contradicts. If any clause is unmet, the PR says partial and names the task that carries the remainder. .claude/hooks/pr-body-ref-gate.sh's claim scan backstops this: it blocks a PR create/body edit once, naming any claim-shaped body line that carries no cite and no hedge.
The same check applies to any exhaustiveness claim in the body ("every call site", "all N modules", "the whole module"): name the enumeration command whose output backs it, or scope the sentence to what was actually swept.
Cite by grep token, not file:line, while the file is still under review. A line number into a file later rounds will edit is stale by construction — five cites on one PR moved three times before merge. grep -n '<distinctive token>' <file> names the same place at every round, and pr-body-ref-gate.sh accepts either form.
A FORWARD reference — "filed as TASK-N", "tracked in doc-N" — is the same claim pointing the other way, and it needs git ls-files, not existsSync. A task file that exists only in the working tree does not exist from any other vantage point: not git log, not another checkout, not the reviewer, not the next session. Before the body claims something was filed:
git ls-files --error-unmatch 'tracker/tasks/task-N*.md' # non-zero = not tracked
git ls-files answers for the CURRENT branch only. Tracker tasks are committed straight to develop, so one filed mid-PR is absent from a feature branch cut before it — the command reports "not tracked" for a task that is perfectly well tracked on the branch the PR merges into. Check the base ref when the two differ, or the check produces a false alarm exactly when it is being used properly:
git ls-tree -r --name-only origin/develop -- tracker/tasks/ | grep task-N
Commit it first, then write the sentence.
Arm CI monitor (required)
Immediately after gh pr create — and after any subsequent git push to an open PR — start a Monitor that waits for CI to complete and reports new review comments back. Do not skip this step and do not wait for the user to ask about CI status.
One monitor per PR — TaskStop the previous one first (05-tooling.md § PR Monitoring).
Arm it from the template below every time:
Arm a Monitor with description: "CI + reviews for PR <N>", timeout_ms: 1800000, persistent: false (05-tooling.md § PR Monitoring explains the false), and this as its command — verbatim, as plain bash:
pnpm ops gh:ci-gate <N> --sha $(git rev-parse HEAD)
Copy the substitution verbatim — never resolve the SHA and paste the result. The gate rejects an abbreviated SHA and a well-formed one naming no local commit, but the substitution removes the step entirely.
When the monitor fires, run the four-step procedure in 05-tooling.md § PR Monitoring in full — CI state plus the SHA-pinned run-list query, the three review endpoints (gh:pr-comments / gh:pr-reviews / gh:pr-info), one consolidated report that reads every ### section of every claude[bot] entry, then /tzurot-review-response. Do not stop after the CI check: only CI_COMPLETE means CI finished, and a green check list is not proof CI ran.
Merge gate is green-only — every check green before gh pr merge, release PRs included; infrastructure-shaped failures get gh run rerun <run-id> --failed and a re-armed Monitor, never a merge through the red (00-critical.md § Never Merge PRs Without Completed CI).
Before merging: the head branch must be checked out NOWHERE
--delete-branch fails silently when the head branch is checked out anywhere — git refuses to delete a checked-out branch, gh reports the LOCAL failure, and the merge itself still succeeds. The PR closes as merged and nothing says a branch is still there; it surfaces later as a repo-state-sweep finding, or when someone notices the pile.
"Anywhere" is the whole rule, and the easy-to-forget instance is the checkout you are not looking at: a worktree (the orchestration skill mandates one for every file-mutating worker, so every delegated unit lands in this state) or the main checkout still sitting on the branch after a local review.
Before the merge:
git status --short # check the main tree BEFORE moving off the branch
git worktree list # find any worktree holding the branch
git checkout develop # get the main checkout off it
Check every worktree before removing it — --force or not. Run this against each worktree the previous step listed, and do not skip it because you are using plain remove:
git fetch -p # --remotes reads LOCAL tracking refs; refresh them first
git -C <path> status --short # empty = no modified or untracked files
git -C <path> log --oneline --not --remotes # empty = every commit is on a remote
git worktree remove <path> # only once BOTH are empty
Plain git worktree remove does not protect you here. It refuses only on a DIRTY worktree — modified or untracked files. A worktree whose work is committed but never pushed is clean by that definition, so plain removal takes it with exit 0, and --delete-branch at the merge step below then deletes the only ref holding those commits, leaving them reachable solely through a local reflog. That is precisely the resumed-worker scenario in § Resuming a worktree-isolated worker in /tzurot-orchestration, which is why the check is unconditional rather than a --force caveat.
--not --remotes, not @{u}.. — measured: @{u} dies with fatal: no upstream configured (exit 128) on a branch that was created but never pushed, which is precisely the state you are checking for, so the check would abort exactly when it matters.
If either is non-empty, do NOT remove the worktree yet — get the work to safety first, then re-run the check and remove:
git -C <path> add -A && git -C <path> commit -m "chore: snapshot before worktree cleanup"
git -C <path> push -u origin HEAD # if commits were unpushed
(chore:, not wip: — wip is not in commitlint's type-enum, so the hook rejects it at exactly the moment you need the commit to land.)
If that push happened, you are no longer ready to merge. It put a commit on the PR's head branch that CI has never seen, so re-arm the Monitor and wait for a fresh green run before the merge below — 00-critical.md § Never Merge PRs Without Completed CI requires green on the LATEST commit, and the green-only gate a few sections above applies here with no exception for a recovery commit. Then merge — this is the feature-PR invocation the sections above build up to, and --delete-branch is correct here precisely because the branch is disposable:
gh pr merge <N> --rebase --delete-branch
After PR Merged
git checkout develop
git pull origin develop
git branch -d feat/your-feature
Then verify the remote branch is actually gone — the ordering step above makes the delete possible, not certain, and this is what turns any other silent-delete failure into a report instead of a discovery:
git ls-remote --exit-code --heads origin "<branch>"; case $? in
0) echo "SURVIVED — re-delete" ;;
2) echo "deleted ✓" ;;
*) echo "UNKNOWN — ls-remote itself failed; the branch's state was NOT checked" ;;
esac
This is not the push-verify ls-remote from the Commit Procedure above. That one reads the ref's SHA out of stdout to prove a push landed at a specific commit; this one asks only whether the ref exists at all, which is why it wants --exit-code and ignores stdout. Same command, opposite questions — don't swap one form for the other.
Use the case, not && … || …. --exit-code distinguishes the cases (0 found, 2 no match, anything else an error), so read the status rather than its truthiness.
git push origin --delete <branch> re-deletes a survivor. Never for develop or main (00-critical.md § Long-Lived Branch Protection) — the develop→main release PR merges without --delete-branch, so nothing here applies to it.
Dependabot PR Recovery
Dependabot PRs have three distinct cleanup paths — using the wrong one wastes a cycle or produces a forbidden merge-commit state.
| Situation | Command | Effect |
|---|---|---|
| Branch is behind develop, dependabot is the only committer | @dependabot rebase (PR comment) |
Dependabot rebases its own branch onto develop and regenerates the lockfile. PR number preserved, CI reruns. |
| Branch has a non-dependabot commit (e.g., GitHub's "Update branch" button added a merge commit) | @dependabot recreate (PR comment) |
Dependabot closes the existing PR and opens a new one against current develop. PR number changes; any prior review comments are lost. |
| Need to abandon the bump entirely | gh pr close or let it age out |
Dependabot will re-open on next schedule unless the dep is added to ignore: in dependabot.yml. |
Key constraint: @dependabot rebase refuses to run if any commit on the branch is authored by someone other than dependabot. GitHub's "Update branch" UI button appears to rebase, but it actually adds a merge commit authored by github-actions[bot] — which poisons the branch for rebase. Once that happens, recreate is the only in-band recovery.
Rule of thumb: don't hit "Update branch" on dependabot PRs. Use the chat command. If you do hit it by accident, don't waste time on rebase — go straight to recreate.
dependabot.yml is read from the DEFAULT branch (main) — same trap as the claude workflow files below. An ignore: entry merged to develop does nothing until the next release lands it on main; dependabot will keep opening PRs with the "ignored" dep in the meantime. For immediate effect, comment @dependabot ignore <dep-name> major version on the open PR — a server-side ignore, persistent until @dependabot unignore <dep-name> major version, and it works per-dependency inside grouped PRs. Config entry = durable documentation; chat command = the thing that actually stops the PRs. Removing an ignore later requires BOTH the config-entry removal and the unignore comment.
After a dev-tooling bump merges (eslint/tsc/prettier/vitest — especially a major), rebase every open PR before merging it. A green PR's CI ran against its OLD base, so the new tooling never saw its code, and merging on that stale base can redden develop. gh pr merge --rebase rebases-and-merges WITHOUT re-running CI, so it does not protect against this — only a manual pass does: git rebase origin/develop → pnpm install → re-run the affected gate (pnpm focus:lint for eslint/prettier, pnpm typecheck for tsc; changed-package scope is enough, since the bump's own merge proved existing code passes) → push for a confirming CI round → merge.
Claude workflow changes target main, not develop
GitHub Actions that validate against the default branch (main) — notably claude-review and the @claude responder — refuse to run on a PR unless their own workflow file is byte-identical to the version on main. A "security skip": it stops an untrusted PR from altering the very workflow that reviews it.
Scope — the validation is file-scoped. Only the self-validating claude workflow files (claude-code-review.yml, claude.yml) trigger the skip; a PR carrying drift in any OTHER workflow file still gets a real review. Non-claude workflows (ci.yml) also execute from the PR's own branch, so routine ci.yml edits ride normal develop PRs like any code change — no main-cut ceremony.
Consequence: a change to one of the claude workflow files that lands on develop first silently disables those reviews on every PR — they pass as a green ~10-15s no-op ("Skipping action due to workflow validation", no review posted) — until the change reaches main. Under the normal flow that's only at the next release, and the release PR's own review skips too, so it compounds across the whole cycle.
Local runs before the PR exists need --base main. The guard reads the branch's real merge target from GitHub (gh pr view) rather than guessing it from git shape — the shape is genuinely ambiguous, because release:finalize puts main's HEAD on develop's history and a stale develop-cut branch then looks identical to a main-cut one. Consequence: on a main-cut branch with no PR open yet, there is nothing to ask, and the guard fails CLOSED. That is the correct direction for a guard, but it means pnpm quality / pre-push goes red until the PR exists. Pass pnpm ops guard:workflow-sync --base main (or just open the PR first).
Rule: For any change to claude-code-review.yml or claude.yml, open a PR cut from main and targeting main — never branch from develop for this (a develop-based branch targeting main drags all of develop's unmerged commits into the diff). The moment it merges, run pnpm ops release:finalize to resync develop onto main — do this before other work piles onto develop, since every commit added there (and every open feature branch) then needs rebasing onto the resynced develop. Do NOT let a claude-workflow change reach main via the routine develop→main release merge.
This bites most often with dependabot bumps that touch the claude workflow files (e.g. an actions/checkout major bump usually edits every workflow, claude ones included) — dependabot opens them against develop. When a dependabot PR (or any PR) touches claude-code-review.yml/claude.yml, cherry-pick just those workflow hunks into a fresh main-cut PR and merge that first, rather than letting the change reach develop; the ci.yml hunk of the same bump can ride develop normally. (There's no @dependabot retarget command; re-pointing a develop-based PR's base at main via the GitHub UI would drag all of develop's unmerged commits into the diff, so cherry-picking the hunk is the clean path.)
Recovery — a claude-workflow change already landed on develop (the disruptive case; infrequent but real):
- Branch off
main, sync just the affected workflow file(s) to develop's state (git checkout origin/develop -- .github/workflows/<file>), commit, PR againstmain. - Merge to
main(needs explicit approval —mainalways does). - Rebase
developontomainso the two don't diverge on the workflow file (pnpm ops release:finalize, or manualgit rebase origin/main+--force-with-lease). - Order matters — do step 3 first. For each open PR: rebase the feature branch onto the updated
develop(git rebase develop) and push. The push itself re-triggers the review on the new HEAD, which now carries the updated workflow in its ancestry — so the validation passes. Do not reach forgh run rerun: it re-runs the old commit's checkout, whose workflow bytes still mismatchmain, so it keeps skipping. The rebase-push is the only reliable trigger (the PR's review validates the PR branch's own HEAD workflow againstmain).
Rebase Procedure
git checkout develop && git pull origin develop
git checkout feat/your-feature
git rebase develop
# If conflicts:
# 1. Edit files to resolve
# 2. git add <resolved>
# 3. git rebase --continue
# Repeat until done
git push --force-with-lease origin feat/your-feature
Release Procedure
0. Risk & Coverage Appraisal (produce UNPROMPTED with any release proposal)
Answer these before being asked, as part of proposing the cut:
- The proposal itself is three parts, always (owner directive):
a full summary of everything included (every PR, grouped by theme), an explicit justification judgment ("is there enough here to justify a release?"), and a riders check ("any other small items worth including?"). A bare "ready to cut?" prompt forces the owner to reconstruct the contents themselves.
- Cite the release plan — the 🚢 Next Release section in
backlog/now.md
is the primary cut trigger (10-working-posture.md § Ship in bounded units): state that its waiting-on list is empty, or name the backstop that fired instead and what the plan still lists as waiting. A cut that diverges from the plan is fine — but the divergence is stated, not silent.
- Risk level (low/medium/high) with the one-line basis (what's runtime-unverified,
what's blast-radius-bounded).
- Smoke scope derived from the release diff — risk-scoped and minimal, not a
fixed checklist; name the specific user-visible paths this release touched.
- Coverage gaps that would raise confidence — name them and offer to close
before cutting (or explicitly note why post-hoc observability suffices).
- For minor features, offer observability-instead-of-smoke ("logging is in
place to check the first real use") as a first-class alternative to another manual round.
- The smoke request IS the CURRENT.md write — never ask the owner to
smoke-test anything that isn't already a CURRENT.md checklist item with its own status line. Asking in chat shorthand forces "remind me what to test?"
- Each smoke item carries a confidence tier (canonical definition in
/tzurot-testing § Human-Verification Requests) — offer the owner only the needs-smoke tier, never the high tier CI + review already cover.
- Never cite "soaked in dev" as safety evidence (see /tzurot-deployment).
1. Version Bump
# Option A: Changesets (recommended)
pnpm changeset
pnpm changeset:version
git add . && git commit -m "chore: version packages"
# Option B: Manual
pnpm bump-version 3.0.0-beta.XX
git commit -am "chore: bump version to 3.0.0-beta.XX"
2. Write Release Notes
Write release notes following the Conventional Changelog format defined in .claude/rules/05-tooling.md.
Source of truth: git log v<previous-tag>..HEAD --no-merges — NOT CURRENT.md. CURRENT.md tracks session work; release notes track what shipped between tags.
Any count or list of "PRs merged since the last release" — in a release proposal, a PR body, or a status message — comes from pnpm ops release:range, never a hand-rolled gh pr list/date-window query. It classifies each PR runtime/non-runtime and prints both cut triggers (runtime-PR count ~10, range diff size ~250 files); read both, per 10-working-posture.md § Ship in bounded units. If the size cannot be measured the command says so on stderr; a SKIPPED check is not a passing one.
Backlog sweep — same pass, same commit as the notes. The release range enumerates every shipped PR anyway, which is the one deterministic moment the full shipped-list exists. For each PR in the range, grep backlog/ (recursive, incl. cold/) for the item's topic and strike/remove the shipped entries.
# 0. Sync tags FIRST — a stale local tag store silently spans two releases
git fetch --tags origin
# 1. Find the previous release tag
git tag --list "v3.0.0-beta.*" --sort=-version:refname | head -1
# 2. List actual commits for this release
git log v<previous>..HEAD --no-merges --oneline
# 3. Cross-check: every release note item must map to a commit in that range
# 4. Cross-check: no item should appear in the previous release's notes
User-facing doc sweep (required before the release PR): the drafted notes enumerate exactly what shipped — walk each Breaking Changes, Features, and Improvements item (breaking renames/removals are the stalest-doc risk) against every user-facing doc surface, and fix what's stale in the same sitting:
README.md— the derivable half (project tree, prerequisites, fenced
scripts, slash-command list, links) is gated by pnpm ops guard:readme; this sweep is the Highlights and Features prose: with the drafted notes in hand, ask whether they still describe what the range shipped, and fix in the same cut.
docs/commands.md— command table. Rendered live at tzurot.org/docs/commands.docs/guides/*.md— the getting-started guide (and future guides).
Rendered live under tzurot.org/docs. A new user-visible feature or a changed command flow belongs here, not just in the command table.
docs/legal/PRIVACYPOLICY.md/TERMSOF_SERVICE.md— check whenever the
release changes data collection, retention windows, notification behavior, or third-party processors; the retention table and behavior claims must match the shipped code. Rendered live at /privacy and /terms.
The website glob-loads these files, so staleness is now PUBLIC the moment the release deploys — and conversely the fix ships itself with the release. New website-rendered markdown sources must ALSO be COPY'd in services/website/Dockerfile (a missing base dir fails the docker build loudly via the pages' getEntry throw — by design). Prose docs have no mechanical drift guard; the release-notes draft is the one moment the full delta is already enumerated, so the sweep is nearly free here and expensive anywhere else.
3. Create Release PR
Security preflight first — a fixable vuln is cheaper to ride along than to hotfix after:
pnpm ops security:advisories # each advisory + severity + fix version + direct/transitive + action
pnpm ops guard:repo-settings # deletion of main/develop must be UNREACHABLE (see below)
gh pr list --author "app/dependabot" --state open # any auto-PRs to ride along
guard:repo-settings belongs in the preflight specifically, because the release merge is the one merge whose head branch is develop. A CRITICAL finding means the next release merge will delete develop — fix it before cutting (00-critical.md § Long-Lived Branch Protection).
security:advisories is the primary check. The ride-along candidate is a transitive-with-fix advisory — Dependabot can never PR one, so widen/add the pnpm.overrides entry, pnpm install, and verify the lockfile resolves the patched version (05-tooling.md § Security Advisories).
gh pr create --base main --head develop --title "Release v3.0.0-beta.XX: Description" --assignee @me
4. Pre-Merge Migration (if release includes one)
Run migrations before merging — Railway auto-deploys every service the moment the release PR merges to main, so migrating after leaves new code on the old schema for the deploy window (the beta.140 column ... does not exist incident). Migrate first, while prod still runs the old code:
pnpm ops release:premigrate --dry-run # preview the new migrations in the release range
pnpm ops release:premigrate # apply to prod, THEN proceed to merge
Skip if the release has no migration — release:premigrate detects this and exits cleanly. It refuses a destructive migration without --allow-destructive and an -- tzurot:apply-after-deploy one without --allow-marked; both cases, and the maintenance-window sequence, are in .claude/rules/03-database.md § Deployment.
5. Merge Release PR
⚠️ NEVER use --delete-branch for release PRs. develop is a long-lived branch.
⚠️ Wait for every CI check to be green — release PRs are not exempt (00-critical.md § Never Merge PRs Without Completed CI); claude-review is the second look on the full release delta.
⚠️ When assessing release safety, do NOT cite "soaked in dev". Dev has no organic traffic — a dev deploy proves boot, not behavior (see /tzurot-deployment § "What a dev deploy proves"). The honest safety basis is per-PR CI + reviews, the holistic release review, and blast-radius analysis of runtime-unverified paths.
⚠️ CodeQL "new alert" on a large release PR is usually a re-surfaced dismissed alert, not a real one. The release PR's diff is huge (hundreds of files); CodeQL's PR-diff analysis can't diff it cleanly and the check's own summary says so verbatim: "Alerts not introduced by this pull request might have been detected because the code changes were too large." A constituent PR that relocated code (e.g. a file/function move) carries any previously-dismissed alert to the new path, where the release PR re-flags it as "new." Before treating it as a blocker: (1) read the alert's rule + file/line from the failed check-run's annotations (gh api repos/{owner}/{repo}/check-runs/<id>/annotations); (2) check the repo's open-alert count (gh api …/code-scanning/alerts?state=open) — 0 open means it's not a real default-branch alert; (3) find the matching dismissed alert (…/code-scanning/alerts?state=dismissed) and confirm same rule + relocated code. If it's a confirmed re-surface, the durable fix is to make the code stop tripping the rule (so it can't re-surface on the next relocation) rather than re-dismissing — then the release CodeQL greens on the fixed tree.
# ✅ CORRECT - Merge without deleting develop (only after all checks green)
gh pr merge <number> --rebase
# ❌ FORBIDDEN - Would delete develop!
gh pr merge <number> --rebase --delete-branch
Fallback for large PRs: fast-forward when rebase-merge chokes
GitHub's "Rebase and merge" replays every PR commit onto main as new commits. On a release PR with a large commit range (observed failing at ~200 commits), the API rejects the merge and the web UI falsely reports merge conflicts — even though gh pr view <N> --json mergeable,mergeStateStatus returns MERGEABLE / CLEAN. --admin does not help; this is a mechanical rebase failure, not a branch-protection block. The error to grep this skill for when you hit it:
GraphQL: This branch can't be rebased (mergePullRequest)
When this happens, fast-forward main to develop instead. Because every release leaves main an ancestor of develop (step 6 rebases develop onto main, and all new work piles onto develop), this is a clean fast-forward — and it's actually cleaner than the button: it keeps develop's original SHAs, so main and develop end byte-identical and step 6's release:finalize becomes a no-op (no SHA divergence to repair).
Two guardrails are mandatory — do not skip either:
- Attempt
gh pr merge <N> --rebaseFIRST, even when you expect it to fail. That command fires thepr-merge-review-check.shPreToolUse gate (00-critical.md), which forces the latestclaude-reviewinto context before any merge. Distinguish the two failure modes: the gate blocks once by injecting the review into stderr and exiting non-zero — engage with the review and retry the same command; if that retry also fails with thecan't be rebasederror above, the merge has failed mechanically and you proceed to the FF. A baregit pushtomaindoes not trigger that gate, so the FF is only safe after the gate has been satisfied by a realgh pr mergeattempt in the same session. (If the session restarts between the failed attempt and the FF push, re-attemptgh pr merge --rebaseonce more first — the acked comment-id persists, so the hook won't re-block, but the re-attempt re-establishes that the review is in context.) - Verify
mainis an ancestor ofdevelop—git merge --ff-onlyrefuses (loudly, no side effects) ifmainhas diverged (e.g. a hotfix landed directly on main). If it refuses, do NOT force anything: rebase develop onto main first (git checkout develop && git rebase origin/main && git push --force-with-lease), then retry the FF.
# Only after `gh pr merge --rebase` has fired the review gate AND failed mechanically:
git fetch --all # REQUIRED: refresh origin/develop — `git pull origin main`
# below does NOT fetch it, so the FF could land a stale develop
git checkout main && git pull origin main
git merge --ff-only origin/develop # fast-forward; refuses if main diverged
git push origin main # FF push — NOT a force-push
# GitHub auto-closes the PR as MERGED once its head commits land on main.
This is a permitted, documented merge path for the large-PR case — not a workaround to reach for casually. For normal-sized release PRs, gh pr merge --rebase remains the default (it's contributor-agnostic and fires the gate directly). Reserve the FF for when rebase-merge mechanically fails.
6. After Merge to Main
Rebase develop onto main so their SHAs stay aligned. Skipping this step causes the next release PR to show apparent "conflicts with main" that aren't real (content is identical, just different SHAs).
Preferred — automated:
pnpm ops release:finalize # Interactive: prompts before force-push
pnpm ops release:finalize --yes # Skip the prompt (non-TTY safe)
pnpm ops release:finalize --dry-run # Preview the steps without executing
The command runs the full fetch → checkout main → pull → checkout develop → pull → rebase origin/main → push --force-with-lease sequence with safety rails: refuses on dirty working tree, no-op exit when already aligned, aborts rebase cleanly on conflicts.
Manual fallback (if the tool is broken or you need step-by-step debugging):
git fetch --all
git checkout main && git pull origin main
git checkout develop && git pull origin develop
git rebase origin/main
git push origin develop --force-with-lease
7–8. Tag + Create the GitHub Release — pnpm ops release:publish
Git tag and GitHub Release are separate things and the merge does neither. The flag dance around them (newest holds latest; a prerelease-channel version also demotes the previous tag) is error-prone from memory, so it's automated — use the command, not the raw steps:
# Prepare notes first (step 2 output), then one shot:
pnpm ops release:publish 3.0.0-beta.XX --notes-file /tmp/notes.md
pnpm ops release:publish 3.0.0-beta.XX --notes-file /tmp/notes.md --dry-run # preview
What it does (release-flow steps 7–8):
- Creates + pushes an annotated tag on
main(idempotent — reuses an existing tag). - Creates the GitHub Release holding the
latestbadge (never--prerelease
— the newest release always holds latest, stable or beta).
- Only for a prerelease-channel version (
-alpha/-beta/-rc): demotes the
immediately-previous release to --prerelease (found via gh release list, the authoritative GitHub state — NOT local git tag, which drifts because gh release create mints the tag server-side). A stable X.Y.Z release skips the demote — a GA release doesn't demote its predecessor.
Release-channel convention (what the command enforces): the newest release holds latest (prerelease=false); every older beta is prerelease=true. Do NOT mark the newest tag --prerelease — that's mutually exclusive with latest.
Known-benign race — publish right after the merge. Publishing while Railway is still swapping prod containers can 502 the release webhooks at Railway's edge (GitHub delivers once, never retries). This is expected and self-healing: the hourly reconcile sweep picks the release up and sends the DM blast with ≤1h lag. Don't re-publish, don't debug the webhook delivery, and don't wait for the deploy to settle before publishing — the lag is the designed absorption path.
Verify: gh release list --limit 5 --json tagName,isPrerelease,isLatest --jq '.[] | {tagName, isPrerelease, isLatest}' — the newest must read prerelease=false / latest=true, every older beta prerelease=true / latest=false. (If gh/tooling is unavailable, the raw fallback is git tag -a vXX -m … && git push origin vXX, then gh release create vXX --title vXX --latest --notes-file …, then — betas only — gh release edit v<PREV> --prerelease.)
9. Reset CURRENT.md Unreleased Section
After a release merges to main, reset the "Unreleased on Develop" section in CURRENT.md to only track items since the new release tag.
Also flag that a fresh session is available as an alternative to compacting onward when the session has spanned one or more releases — long-lived sessions accumulate compaction churn ("again" re-asks, re-explained context) and the owner has named session-per-release as the preferred cadence. This is a technical-breakpoint flag per 09-interaction-style.md § Don't Suggest Stopping (a fresh session continues the work — it is not a stop); one sentence at close-out is enough, and the call is theirs.
10. Draft the Next Release Plan
Close the release by planning the next one (10-working-posture.md § Ship in bounded units). Rewrite the 🚢 Next Release section in backlog/now.md for vNext (~10 minutes, direct-commit doc change):
- Theme — what this release IS, in a phrase. Pull candidates from Current
Focus, active-epic.md, cold/queue.md, and the digest's oldest-20.
- In already — empty at cut time; grows as PRs merge.
- Waiting on — the named items whose landing defines the cut.
- Explicitly NOT in — what's deliberately deferred to the next train,
with the gating reason.
- Deploy notes — migration timing, maintenance windows, owner decisions
that must survive until cut time (CURRENT.md is session state; this is the durable home for release-scoped facts).
- Cut when — the criterion, plus the standing backstops (~10 runtime PRs /
~250 files) with current values from release:range.
- vNext+1 sketch — one line, so the horizon extends past a single train.
Hotfix release cut from main
Three gotchas the standard develop→main flow never exercises: (1) the pre-push branch-name pattern has no release type — use a valid prefix such as chore/release-vX.Y.Z-beta.N; (2) gh pr merge --rebase --delete-branch switches the local checkout to the default branch and tries to fast-forward it, so expect "not possible to fast-forward" noise and a stale local main — git pull origin main after; (3) pnpm ops release:finalize --yes rebases develop cleanly (duplicate cherry-picks drop via patch-id), but its force-push can still fail the pre-push gate on semantic divergence the textual rebase resolved (a symbol that moved between packages, a develop-side manifest route the conformance gate wants fixtures for) — budget one fix-commit riding the rebased push, and note the force-push to develop needs explicit per-instance user approval.
GitHub CLI
gh pr edit is broken — use pnpm ops gh:pr-edit. The read commands (gh:pr-info, gh:pr-reviews, gh:pr-comments) and their flags are in 05-tooling.md § GitHub and docs/reference/tooling/OPSCLIREFERENCE.md.
References
- GitHub CLI:
docs/reference/GITHUBCLIREFERENCE.md - Safety rules:
.claude/rules/00-critical.md