Git workflow rules for Tzurot v3. Use when committing, creating PRs, or rebasing. Covers rebase-only strategy, commit format, and safety checks.
Invoke with /tzurot-git-workflow for step-by-step git operations.
Safety rules are in .claude/rules/00-critical.md - they apply automatically.
git status # Review what's changed
git add <specific-files> # Stage specific files (preferred)
# Or: git add -p # Interactive staging
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 <noreply@anthropic.com>
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):
&&, 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.timeout: 600000 on 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).cd into a package, run the git step as git -C <repo-root> β¦ (or with absolute paths) β repo-relative pathspecs break after the cd, failing AFTER the tests already passed.git checkout -b <branch> && git add β¦ && git commit β¦. The develop-code-commit-guard PreToolUse hook evaluates the CURRENT branch before the command runs, so on a compound "branch-then-commit" it still sees develop/main and blocks the commit as an on-long-lived-branch code commit. Run git checkout -b first, confirm the branch, then stage + commit in the next call.- parses as an option flag). Use this ls-remote form when you need a scriptable boolean; the -> branch ref-update line / git status -sb check 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
# is dropped when git cleans the message in editor/strip mode (-m and -F keep it). Start such lines with a word ("PR #2466 β¦"); same for #TASK prefixes.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.
# 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
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.
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 -C "$(git rev-parse --show-toplevel)" 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).
dynamic/github-code-scanning/codeql, named PR #N; only on PRs targeting main) can't be rerun: gh run rerun says "cannot be rerun" and both REST rerun endpoints return 403. On an infra flake (e.g. getaddrinfo EAI_AGAIN github.com in init), refresh the SHA: git commit --amend --no-edit on the single unmerged commit, git push --force-with-lease, then re-arm the gate. Budget one amend cycle before merging a main-targeted PR.--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 HEAD --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 then deletes the only ref holding those commits, leaving them reachable solely through a local reflog (the resumed-worker scenario in /tzurot-orchestration Β§ Resuming a worktree-isolated worker). The unpushed-commit check is unconditional, not a --force caveat.
--not --remotes, not @{u}.. β @{u} dies with fatal: no upstream configured on a branch that was created but never pushed, which is precisely the state being checked. The explicit HEAD is load-bearing: with no positive revision there is no tip to walk from, so the command prints nothing even when commits are unpushed.
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
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 proves a push landed at a specific commit; this one asks only whether the ref exists, which is why it wants --exit-code.
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 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.
main, not developGitHub 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):
main, sync just the affected workflow file(s) to develop's state (git checkout origin/develop -- .github/workflows/<file>), commit, PR against main.main (needs explicit approval β main always does).develop onto main so the two don't diverge on the workflow file (pnpm ops release:finalize, or manual git rebase origin/main + --force-with-lease).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 for gh run rerun: it re-runs the old commit's checkout, whose workflow bytes still mismatch main, so it keeps skipping. The rebase-push is the only reliable trigger (the PR's review validates the PR branch's own HEAD workflow against main).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
Answer these before being asked, as part of proposing the cut:
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./tzurot-testing Β§ Human-Verification Requests) β offer the owner only the
needs-smoke tier, never the high tier CI + review already cover.# 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"
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/PRIVACY_POLICY.md / TERMS_OF_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.
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).
The same guard checks every main-required status check against the job ids in origin/main's ci.yml and prints a re-add WARNING for a develop-only context whose job this release carries to main. Apply that re-add to the main ruleset after the release merges, re-run the guard, and refresh .github/rulesets/branch-protection.json from live.
security:advisories is the primary check. The ride-along candidate is a transitive or direct+transitive advisory with a fix β 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).
New user-content tables need a data-rights disposition before the cut. Enumerate models added in the range β git diff v<prev>..HEAD -- prisma/schema.prisma | grep '^+model ' β and for each one that stores user-derived content, confirm both an export path (services/ai-worker/src/jobs/AccountExportAssembler.ts) and an erasure path (AccountEraserService.ts, a retention sweep, or the /history clear cascade), or state in the release notes why it is excluded.
gh pr create --base main --head develop --title "Release v3.0.0-beta.XX: Description" --assignee @me
Get a fresh yes first. The prod premigrate writes to prod, so it runs only on an explicit in-session approval from the owner for THIS release β a standing release approval, or a "go" given earlier in the session, does not carry. Ask through AskUserQuestion (09-interaction-style.md Β§ Blocking Questions): the owner can answer that remotely without running anything, so the default is get the yes and then run it β not hand the command back to their terminal.
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.
β οΈ 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
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:
gh pr merge <N> --rebase FIRST, even when you expect it to fail. That command fires the pr-merge-review-check.sh PreToolUse gate (00-critical.md), which forces the latest claude-review into 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 the can't be rebased error above, the merge has failed mechanically and you proceed to the FF. A bare git push to main does not trigger that gate, so the FF is only safe after the gate has been satisfied by a real gh pr merge attempt in the same session. (If the session restarts between the failed attempt and the FF push, re-attempt gh pr merge --rebase once 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.)main is an ancestor of develop β git merge --ff-only refuses (loudly, no side effects) if main has 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.
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
pnpm ops release:publishGit 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):
main (idempotent β reuses an existing tag).latest badge (never --prerelease
β the newest release always holds latest, stable or beta).-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.)
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.
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):
active-epic.md, cold/queue.md, and the digest's oldest-20.release:range.mainThree 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.
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/OPS_CLI_REFERENCE.md.
docs/reference/GITHUB_CLI_REFERENCE.md.claude/rules/00-critical.md