| name | tzurot-git-workflow |
| description | Git workflow procedures. Invoke with /tzurot-git-workflow for commit, PR, and release procedures. |
| lastUpdated | 2026-07-26 |
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
git add <specific-files>
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 <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: ai-worker, api-gateway, bot-client, common-types, ci, deps
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.
- Never filter commit/push output through
grep/tail/head — hook rejections (commitlint, pre-push gate) get swallowed and cost a blind re-diagnosis cycle. Read the full output; it's short when things work and essential when they don't.
- Pass
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).
- In compound commands that
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.
- Create a new branch in a SEPARATE Bash call, before staging/committing — never chain
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.
- Canonical push-verify (don't improvise greps — a pattern starting with
- 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
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. Backgrounded pushes reporting exit 0 AND
foreground pushes with | tail/| grep-filtered output have both hidden
failed transfers.
PR Procedure
Create PR
git checkout develop && git pull origin develop
git checkout feat/your-feature
git rebase develop
git push -u origin feat/your-feature
gh pr create --base develop --title "feat: description"
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.
The .claude/hooks/pr-monitor-reminder.sh PostToolUse hook auto-fires on git push / gh pr create and injects a reminder with the Monitor invocation pre-filled with the current PR number. Use that when you see it; the template below is the fallback shape if you're arming manually:
Monitor({
description: "CI + reviews for PR <N>",
command: 'sleep 60; gh pr checks <N> --watch --interval=30 > /dev/null 2>&1; sleep 5; echo "CI_COMPLETE"; gh pr checks <N>',
timeout_ms: 900000
})
When the monitor fires, all four of the following must happen — do not stop after #1 even when every check passed:
-
Inspect the final gh pr checks <N> output for pass/fail.
-
Fetch new reviewer feedback via three endpoints (GitHub splits them; the raw /issues/N/comments call silently misses inline line comments):
pnpm ops gh:pr-comments <N> — conversation + inline line-level review comments
pnpm ops gh:pr-reviews <N> — review summaries (Approve / Request Changes)
pnpm ops gh:pr-info <N> — PR-level state
No bot-only filter — human reviewer comments matter too. Dedup by tracking created_at of the last-reported comment.
-
Report CI status and new reviewer feedback in one concise message. Group findings as blocking vs. non-blocking. If no new reviews since the last push, say so explicitly — silence is not a substitute for "no new comments."
Read the body, not just the summary. Reviewer output is tiered: verdict → strengths → major items → minor items → observations → summary. The trailing "Summary" / "Actionable items" section is a reviewer's shortcut; it frequently under-reports items the body flags in detail. When a review is 100+ lines, treat length as a skimming red flag — walk every ### section before calling the report done. Cross-check: if codecov flags missing lines, grep the review body for a corresponding test-gap call-out.
Each claude[bot] entry is a separate review cycle. If multiple exist (pre-rebase + post-rebase, push + re-push), read every one — don't assume only the latest matters. Track created_at of the last-reported comment so future fetches dedup correctly across session boundaries.
-
Apply review feedback per /tzurot-review-response — trivial-shape edits auto-apply (test-gated fixup commits), semantic-shape edits ASK; batch-present all items in one end-of-round message.
The #1-without-#2 failure mode is worth guarding against: all-green CI feels complete, but new review comments can still carry blocking findings or non-blocking observations the user wants to triage.
The #2-without-full-body failure mode is the second trap: fetching comments but extracting only the summary section. A review that ends "Summary: two actionable items" almost always has a body with additional items that weren't promoted to the summary.
If the monitor completes without a CI_COMPLETE line in its output, the 15-min timeout fired first — re-arm rather than assume CI passed. If CI fails or CodeQL flags something, use PushNotification — the user should hear about it before their next turn.
Merge gate is green-only. Per .claude/rules/00-critical.md "Never Merge PRs Without Completed CI": every check must be green before gh pr merge runs, including release PRs. If a check fails for what looks like infrastructure reasons (binary not found, missing secret, action-setup error), gh run rerun <run-id> --failed and re-arm the Monitor — don't merge through the red. The release procedure below assumes a green pipeline.
After PR Merged
git checkout develop
git pull origin develop
git branch -d feat/your-feature
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.
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 (empirically confirmed: a PR with ci.yml drift received a full claude-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. pnpm ops guard:workflow-sync enforces exactly this scope (claude files only).
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.
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 against main. (Pattern: PR #1318 — actions/checkout bump.)
- Merge to
main (needs explicit approval — main always does).
- Rebase
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).
- 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 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).
Rebase Procedure
git checkout develop && git pull origin develop
git checkout feat/your-feature
git rebase develop
git push --force-with-lease origin feat/your-feature
Release Procedure
0. Risk & Coverage Appraisal (produce UNPROMPTED with any release proposal)
The owner opens essentially every release with "any risks? anything that would
raise confidence?" — answer it before being asked, as part of proposing the cut:
- 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?"
(recurred: the ask lived only in chat and scrolled away).
- 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
pnpm changeset
pnpm changeset:version
git add . && git commit -m "chore: version packages"
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.
Using CURRENT.md caused duplicate entries in beta.94 (items from beta.93 re-listed).
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. This
mechanizes 06-backlog's session-end removal gate at the moment it's nearly free
— removal-at-ship keeps being missed, which is what leaves the board stale
enough that the owner's memory has to catch it ("didn't we already do X?").
git fetch --tags origin
git tag --list "v3.0.0-beta.*" --sort=-version:refname | head -1
git log v<previous>..HEAD --no-merges --oneline
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 — feature bullets, slash-command list, project tree.
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 (/feedback + /notifications shipped undocumented until a
manual release-prep sweep caught them; the /notifications default sat wrong
in commands.md for a day after the blast-radius fix).
3. Create Release PR
Security preflight first: enumerate open advisories before cutting the
release — the user has repeatedly been the one to notice new advisories
mid-release, and a fixable vuln is cheaper to ride along than to hotfix after.
pnpm ops security:advisories
gh pr list --author "app/dependabot" --state open
security:advisories is the primary check: it prints each open advisory with
its fix version and — crucially — whether it's a direct dep (Dependabot
will auto-PR it) or transitive-only (Dependabot can't PR it; it needs a
manual pnpm.overrides bump and otherwise lingers open indefinitely). A
transitive-with-fix advisory is the ride-along candidate: widen/add the
override, pnpm install, verify the lockfile resolves the patched version.
(--json for machine output; --strict exits nonzero on an actionable
high/critical.) The same list also appears in pnpm ops health.
gh pr create --base main --head develop --title "Release v3.0.0-beta.XX: Description"
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
pnpm ops release:premigrate
Skip if the release has no migration — release:premigrate detects this and exits cleanly (or check git log v<previous>..HEAD --no-merges -- prisma/migrations/). Safe for additive migrations (old code ignores the new column/table/constraint). Destructive migrations (drop/rename a column, tighten a constraint on existing data) invert the window and need a brief maintenance window — release:premigrate refuses them without --allow-destructive. See .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 per .claude/rules/00-critical.md "Never Merge PRs Without Completed CI". Release PRs are not exempt — claude-review is the second-look on the full release delta and infra failures (binary not found, missing secret) need gh run rerun <run-id> --failed before merge, not "merge through it."
⚠️ 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. (Precedent: beta.174's js/insecure-randomness on a relocated Math.random pick → swapped to crypto.randomInt.)
gh pr merge <number> --rebase
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, beta.126 / PR #1120), 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> --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.)
- Verify
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.
git fetch --all
git checkout main && git pull origin main
git merge --ff-only origin/develop
git push origin 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
pnpm ops release:finalize --yes
pnpm ops release:finalize --dry-run
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:
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
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
latest badge (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.
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. Failing to do this
caused duplicate entries in the beta.94 release notes (items from beta.93
were re-listed because CURRENT.md still tracked them).
GitHub CLI (Use ops instead of broken gh pr edit)
pnpm ops gh:pr-info 478
pnpm ops gh:pr-reviews 478
pnpm ops gh:pr-comments 478
pnpm ops gh:pr-edit 478 --title "New title"
References
- GitHub CLI:
docs/reference/GITHUB_CLI_REFERENCE.md
- Safety rules:
.claude/rules/00-critical.md