Git Workflow¶
This document describes the git branching strategy and deployment flow for the Delta project.
Overview¶
We ship to production through two separate paths:
- Feature flow — feature/bug branch → PR to
staging→ UAT → automation integrates intorelease/beta→ release manager opensrelease/beta → mainPR when ready to ship a batch. - Hotfix flow — bug branch based on
main→ PR tostaging→ UAT → automation opens a dedicatedbranch → mainPR when the ticket reaches "UAT Passed". Hotfixes skiprelease/betaas a base, and the fix is synced back into staging (and release/beta, if active) after the main merge.
Developers only open one PR per ticket — always to staging. The second leg to main is never the developer's: for normal work the release manager manually opens one aggregated release/beta → main PR per cycle; for hotfixes linear-uat-passed-create-pr.yml opens the dedicated branch → main PR automatically.
The One-Branch Rule: keep your ticket branch release-clean¶
This is the single most important invariant in the whole workflow. Every ticket lives on exactly one branch, and that same branch merges twice:
- Into
staging(squash merge) — so QA can UAT it. - Into
release/beta(truegit merge --no-ff, done bylinear-uat-passed-create-pr.ymlafter "UAT Passed") — so it ships in the next release.
Merge #2 is a real merge of your branch tip: everything reachable from your branch's history lands in release/beta — and therefore in production — under your ticket. Staging, on the other hand, contains every in-UAT feature, none of which is approved to ship yet.
Put together:
NEVER merge
origin/staginginto a ticket branch. Never rebase a ticket branch onto staging. Never branch from staging. Not to fix a PR conflict, not to "catch up with staging", not for any reason. Onegit merge origin/stagingsnapshots every unapproved in-UAT feature into your branch's history; if any of them later fails UAT, the automation still ships that code to production under your ticket.
What you MAY bring into a ticket branch:
| Merge into your ticket branch | Allowed? | Why |
|---|---|---|
origin/main |
✅ Always | Already in production — ships regardless |
origin/release/beta |
✅ Always | Everything there passed UAT and ships next release regardless |
| One specific feature branch you depend on | ⚠️ With coordination | See "Feature depends on another feature" — their ticket must integrate into release/beta before yours |
origin/staging |
❌ NEVER | Contains every unapproved in-UAT feature — poisons your release/beta integration |
When your staging PR shows a merge conflict, there is a correct resolution for every case that keeps the ticket branch clean — see "Conflict on your staging PR is your collision detector" under Edge Cases. The conflict banner is never a justification to break this rule.
Branching Strategy¶
Branch Naming¶
[type]/[developer-name]/[linear-ticket-id]-[description]
- type:
featureorbug(from Linear issue labels, default tofeature) - developer-name: Your name (lowercase)
- linear-ticket-id: e.g.,
dev-123 - description: Kebab-case summary
Example: feature/pablo/dev-123-add-payments
Pick the Right Base Branch¶
Branch from release/beta if it exists (an open release cycle is in progress), otherwise from main. Never from staging. Exception: a Hotfix-labelled ticket always branches from main — the fix must apply cleanly to production (see Hotfix Flow below).
# Pick the correct base
if git ls-remote --exit-code --heads origin release/beta >/dev/null 2>&1; then
git fetch origin release/beta
git checkout -B release/beta origin/release/beta
else
git checkout main && git pull origin main
fi
git checkout -b feature/pablo/dev-123-add-payments
Why: linear-uat-passed-create-pr.yml auto-merges every UAT-passed feature into release/beta. Basing new work on release/beta means your feature's later integration into release/beta won't conflict with tickets already merged there. When no release cycle is active, release/beta doesn't exist and you base on main.
Feature Flow (Default)¶
Step 1: Develop¶
# Pick base (release/beta if it exists, else main)
if git ls-remote --exit-code --heads origin release/beta >/dev/null 2>&1; then
git fetch origin release/beta
git checkout -B release/beta origin/release/beta
else
git checkout main && git pull origin main
fi
git checkout -b feature/pablo/dev-123-add-payments
# ... develop ...
git push origin feature/pablo/dev-123-add-payments
Step 2: PR to Staging (UAT)¶
Open a pull request targeting staging:
feature/pablo/dev-123-add-payments → staging
Merge strategy: use "Squash and merge" (the only option the staging ruleset allows). Keeps staging's log clean and readable for UAT reviewers; staging is periodically reset anyway so full history isn't needed.
What happens automatically:
- PR checks run (lint, typecheck, tests)
- On merge, staging environment deploys
- Linear ticket moves to "Testing" status
- QA is assigned to the ticket
Step 3: UAT¶
QA tests the feature on staging (staging.farmcove.co.uk).
If UAT fails: fix on the same branch, push — staging redeploys. QA re-tests.
If UAT passes: QA moves the Linear ticket to "UAT Passed" and the developer's job is done for this ticket.
Step 4: Automated Integration into release/beta¶
When the ticket moves to "UAT Passed", linear-uat-passed-create-pr.yml automatically:
- Ensures
release/betaexists (creates it frommainif missing) - Merges your feature branch into
release/beta - Moves the Linear ticket to "Ready for Deployment"
- Comments on Linear with the
release/betabranch URL
If the merge conflicts: the workflow posts a comment on the Linear ticket asking you to pull release/beta, rebase/merge it into your branch, resolve conflicts, push, and move the ticket back to "UAT Passed" to retry.
Developers do not open a second PR.
Step 5: Release (done by the release manager)¶
When ready to ship a batch to production, the release manager manually opens a single PR:
release/beta → main
Requirements for this PR (for deploy-production.yml to pick up all the right tickets):
headbranch must berelease/beta— triggers aggregated release mode in the extract-tickets step.basebranch must bemain.- All PRs to
mainuse the "Create a merge commit" strategy (enforced by themainbranch ruleset — squash and rebase are disabled). For release PRs specifically, this is non-negotiable:deploy-production.ymlwalks theIntegrate <branch> into release/betamerge commits to extract DEV-XXX ticket IDs and discover which feature branches to auto-delete. Squashing would lose both.
On merge:
- Production deploys
- Release notes are auto-generated with all tickets aggregated from the merge commits
- Each ticket in the batch moves to "Done" in Linear
- Feature branches included in the release are auto-deleted by
post-main-merge.yml
Feature Flow Diagram¶
feature/dev-123 ──PR──▶ staging (UAT)
│
│ (UAT passed → automation)
▼
release/beta (aggregates all UAT-passed tickets)
│
│ (release manager, manual PR)
▼
main (Production)
Hotfix Flow¶
For urgent production bugs that must ship ahead of the release cycle. The branch is based on main, but it still goes through staging UAT like any other ticket:
git checkout main && git pull origin main
git checkout -b bug/pablo/dev-456-fix-login-crash
# ... fix ...
git push origin bug/pablo/dev-456-fix-login-crash
Open the developer PR to staging (same as any feature), with the Hotfix deployment label on the Linear ticket. After the staging merge, QA runs UAT. When the ticket is moved to "UAT Passed", linear-uat-passed-create-pr.yml detects the Hotfix label and automatically opens the dedicated PR:
bug/pablo/dev-456-fix-login-crash → main
Do not open the main PR by hand — the automation creates it, mirrors the Linear labels onto it, and requests reviewers from the team.
Merge the PR with the "Create a merge commit" strategy (same rule as release PRs — squash and rebase are disabled on main). Hotfix history is preserved on main as a single merge commit pointing at the fix branch.
After merge:
- Production deploys immediately
post-main-merge.ymlruns two syncs in parallel:sync-staging→ merges main intostagingso subsequent fix-forwards have a clean basesync-release-beta→ merges main intorelease/betaif a release cycle is active, so the next batched release contains the hotfix instead of silently missing it- The hotfix branch is deleted by
deploy-production.ymlonce the production deploy succeeds
Hotfixes skip release/beta as a base, but the fix is still propagated into it so a release that ships after a hotfix includes the fix. If the sync conflicts (hotfix and a queued feature touched the same code), the workflow comments on both the hotfix PR and the open release/beta → main PR; resolve manually by merging main into release/beta locally. The Linear ticket must have the Hotfix deployment label for the automation to route it correctly.
Hotfix Flow Diagram¶
bug/dev-456 ──PR──▶ staging (UAT)
│
│ (ticket → "UAT Passed": linear-uat-passed-create-pr.yml)
│
└──auto PR──▶ main (Production)
│
│ (post-main-merge.yml, two parallel jobs)
├──────────────▶ staging (always synced)
│
└──────────────▶ release/beta (synced only if it exists)
Branch Lifecycle¶
| Event | Branch status |
|---|---|
| Developer creates branch | Branch exists, based on release/beta or main |
| PR merges to staging | Branch survives (needed by release automation) |
| Ticket moved to UAT Passed, auto-integrated into release/beta | Branch still survives |
| release/beta → main merges | Feature branches deleted by deploy-production.yml after the deploy succeeds |
| Hotfix PR merges to main | Hotfix branch deleted by deploy-production.yml after the deploy succeeds |
Never manually delete a feature branch after it merges to staging — the release automation needs it to integrate into release/beta.
Staging Hygiene¶
Over time, staging accumulates features — some shipped to production, some abandoned, some still in UAT. The reset-staging.yml workflow cleans this up:
- Resets staging to match
main(removes shipped/abandoned features) - Auto-detects features still in UAT (branches not yet merged to main)
- Re-merges those in-progress features back into staging
To run: Go to GitHub Actions → "Reset Staging Branch" → "Run workflow"
Run this periodically (e.g., after a batch of features ships to production) to keep staging clean.
Automation Workflows¶
| Workflow | Trigger | What it does |
|---|---|---|
pr-checks.yml |
PR to staging or main |
Lint, typecheck, tests |
deploy-staging.yml |
Push to staging |
Orchestrates deploys to staging, moves Linear ticket to "Testing" |
deploy-production.yml |
Push to main |
Orchestrates deploys to production, walks merge commits for ticket IDs, release notes, deletes shipped branches on success |
linear-uat-passed-create-pr.yml |
Linear ticket → "UAT Passed" | Integrates feature into release/beta (or creates hotfix PR if Hotfix-labelled) |
post-main-merge.yml |
PR merged to main |
Sync main into staging (and release/beta when a cycle is active) |
reset-staging.yml |
Manual trigger | Reset staging, re-merge in-progress features |
Edge Cases¶
Merge conflicts when auto-integrating into release/beta¶
The automation posts a Linear comment when it can't auto-merge your branch:
git checkout feature/pablo/dev-123-add-payments
git fetch origin release/beta
git merge origin/release/beta
# resolve conflicts in your editor
git add .
git commit
git push origin feature/pablo/dev-123-add-payments
Then move the Linear ticket back to "UAT Passed" to retry the workflow.
Branch is stale and can't be rebased cleanly¶
If your feature branch diverged a lot from release/beta and has no unique work (e.g. it was based on a long-dead snapshot), hard-reset it to release/beta:
git checkout feature/pablo/dev-123-add-payments
git fetch origin release/beta
git reset --hard origin/release/beta
git push --force-with-lease
Only do this if the branch truly has no unique commits (verify with git log --no-merges origin/release/beta..HEAD).
Merge conflicts on a hotfix PR to main¶
If main moved while your hotfix was being prepared:
git checkout bug/pablo/dev-456-fix-login-crash
git fetch origin main
git rebase origin/main
git push --force-with-lease
The PR to main will update automatically.
Feature depends on another feature¶
Both features must go through UAT and reach release/beta. Sequence matters.
If the dependency is already merged to staging (in UAT):
- Branch your feature from the dependency's feature branch directly — not from staging, and not from release/beta (which doesn't have it yet).
git fetch origin <dependency-branch>
git checkout -b feature/pablo/dev-456-my-feature origin/<dependency-branch>
-
Develop, open your PR against staging as normal. Your squash-merge to staging will not conflict with the dependency since it's already on staging.
-
When the dependency's PR lands on
release/beta(via UAT-passed integration), rebase your branch ontorelease/beta— the dependency's commits will disappear because they're now upstream.
git fetch origin release/beta
git rebase origin/release/beta
git push --force-with-lease
If the dependency is still a local draft (no PR yet): wait for it to hit staging, or coordinate with the other dev to merge their PR first. Don't build on top of unpublished work.
Why not branch from staging directly? See "Branched from staging by mistake" below — staging contains every in-UAT feature, not just the one you depend on. Branching from staging would drag all of them into your release/beta integration.
Conflict on your staging PR is your collision detector¶
When GitHub flags a merge conflict on your PR to staging, that's not a bug — it's the system telling you your change overlaps with something already on staging. The one forbidden "fix" is git merge origin/staging (or a rebase onto staging) on your ticket branch — it clears the banner but poisons your later release/beta integration (see The One-Branch Rule).
Instead, find out where the conflicting change came from, then apply the matching case:
git fetch origin staging main
# Which commits on staging touch the conflicted paths, and which ticket made them?
git log origin/staging --oneline -- path/to/conflicted-file
# Is that commit's content already shipping anyway?
git merge-base --is-ancestor <sha> origin/main && echo "on main"
git ls-remote --exit-code --heads origin release/beta >/dev/null 2>&1 \
&& git fetch origin release/beta \
&& git merge-base --is-ancestor <sha> origin/release/beta && echo "on release/beta"
Case 1 — the conflicting change is on main or release/beta (a hotfix, or a feature that already passed UAT). Merge that branch — it's safe because that code ships regardless, and it clears the conflict because staging contains main/release-beta too:
git merge origin/main # or: git merge origin/release/beta
# resolve conflicts, run full checks on the merged tree, commit, push
Case 2 — the conflicting change is an in-UAT feature yours depends on (and it's solid). Merge that feature's branch only — never staging — and coordinate ordering: their ticket must reach "UAT Passed" and integrate into release/beta before yours, so your later integration only adds your own work. See "Feature depends on another feature" above.
git fetch origin <their-branch>
git merge origin/<their-branch>
Case 3 — the conflicting change is an unrelated or shaky in-UAT feature. Your branch must NOT absorb it. In order of preference:
- Coordinate and wait. If their feature passes UAT it integrates into
release/betaand you're in Case 1; if it gets rolled back the conflict disappears (after a staging reset). - If the conflicting feature is abandoned or already shipped, ask the release manager to run
reset-staging.yml— staging resets to main + live in-UAT branches, which usually removes the conflict. - If you must reach UAT now, use a throwaway staging-integration branch so the conflict resolution lives outside your ticket branch:
# Retain the canonical name (including ticket ID), then add the suffix.
git checkout -b feature/pablo/dev-123-add-payments-staging-integration feature/pablo/dev-123-add-payments
git merge origin/staging # resolve conflicts HERE, run full checks
git push origin feature/pablo/dev-123-add-payments-staging-integration
Then open (or retarget) the staging PR from the integration branch, and follow these rules:
- Retain the ticket ID (
DEV-123) in both the integration branch name and PR title. Linear's GitHub integration relies on the branch name to attach the staging PR to the ticket; a title/body reference alone is not sufficient. Without that attachment, moving the ticket to UAT Passed emits no repository dispatch. - The UAT workflow canonicalises the attached branch. If Linear sends a
*-staging-integrationbranch,linear-uat-passed-create-pr.ymlfinds the single non-integration branch carrying the same ticket ID and integrates that clean canonical branch intorelease/beta. It fails rather than guessing if none or more than one exist. - UAT fixes go on the canonical branch, then merge the canonical branch into the integration branch and push — staging redeploys with the fix.
- After "UAT Passed", verify the
Integrate <branch> into release/betacommit onrelease/betanames the canonical branch. If it names the integration branch, stop and fix before the release ships. - The integration branch is disposable — delete it once the ticket ships (staging resets orphan it anyway).
Pre-emptively branching from staging (or merging staging in) to "avoid" conflicts is exactly the failure this section prevents. Take the conflict — it's doing its job.
Main-to-staging sync conflict¶
After a PR merges to main, post-main-merge.yml auto-merges main into staging. If this fails due to a conflict, the workflow posts a warning comment on the PR. Resolve manually:
git checkout staging
git pull origin staging
git merge origin/main
# resolve the conflicts in your editor
git add .
git commit
git push origin staging
This is rare. If conflicts become frequent, run reset-staging.yml to reset staging to main and start clean.
Branched from staging by mistake¶
If your branch was created from staging instead of main or release/beta, rebase it onto the correct base:
git rebase --onto origin/release/beta staging feature/pablo/dev-123-add-payments
# or onto origin/main if no release cycle is active
git push --force-with-lease
Pull Request Description Format¶
Every PR should follow this structure:
## Summary
- Brief bullet points describing the changes
## Test plan
- [x] Linting passed
- [x] TypeScript typecheck passed
- [x] Unit tests passed
- [x] Database tests passed (if migration included)
- [x] Locally tested by developer — <what was verified>
All items in the Test plan section must be pre-checked (completed) before the PR is created. This is not a TODO list — it confirms the developer has already verified everything locally.
Key Rules¶
- Branch from
release/betaif it exists, else frommain— never fromstaging;Hotfix-labelled tickets always branch frommain - Never merge
origin/staginginto a ticket branch (and never rebase onto staging) — the same branch later merges intorelease/beta, so absorbed staging content ships unreviewed. Staging-PR conflicts have clean resolutions: see The One-Branch Rule + the collision-detector edge case - One PR per ticket for features — to
staging, then automation handlesrelease/betaintegration - Hotfixes also PR to
stagingfirst — after UAT passes, automation opens the dedicatedbranch → mainPR (never open it manually) - Never delete feature branches after staging merge — the release automation needs them
- The release/beta → main PR is manual — opened by the release manager with
head=release/beta,base=main - Merges into
mainuse "Create a merge commit"; merges intostaginguse "Squash and merge" — enforced by branch rulesets. Release PRs need merge commits on main for ticket extraction and branch auto-delete; staging keeps a clean linear log for UAT reviewers and is periodically reset anyway.
Branch Rulesets¶
Two separate rulesets — one per long-lived branch.
main ruleset¶
The main branch is protected by a GitHub ruleset with these settings:
- Restrict deletions —
maincannot be deleted - Block force pushes — history is append-only
- Require a pull request before merging — direct pushes are blocked
- 1 required approval
- Dismiss stale reviews when new commits are pushed
- Require status checks to pass —
all-checks-passed(frompr-checks.yml) must be green - Require branches to be up to date before merging
- Allowed merge methods: Merge commit only — "Squash and merge" and "Rebase and merge" are unchecked
- Require linear history: OFF — merge commits are non-linear by definition, so this must stay off
- Bypass list: empty — no overrides on
main
To edit: GitHub → Settings → Rules → Rulesets → main-protection (or whatever you name it).
staging ruleset¶
The staging branch needs looser merge rules (faster UAT throughput) but must still allow the reset-staging.yml workflow to force-push periodically.
- Restrict deletions —
stagingcannot be deleted - Block force pushes — with a bypass for the
DEPLOY_BOTApp (see below) - Require a pull request before merging
- 0 required approvals — features will be reviewed again on the release PR to main; UAT is the real check here
- Require status checks to pass —
all-checks-passed - Require branches to be up to date before merging: Off (staging moves too fast; forcing up-to-date would constantly invalidate approved PRs)
- Allowed merge methods: Squash and merge only — "Merge commit" and "Rebase and merge" are unchecked
- Require linear history: On — compatible with squash-only; prevents anyone from manually pushing a merge commit
- Bypass list:
DEPLOY_BOTApp with mode Always — needed soreset-staging.ymlcan force-push the branch when resetting it to main and re-merging in-progress features
Why the DEPLOY_BOT bypass?¶
reset-staging.yml resets staging to match main and re-merges any in-progress UAT branches that aren't yet in main. That requires git push --force, which the "Block force pushes" rule otherwise blocks. Rather than disable force-push protection entirely (which would let any developer with write access force-push staging manually), we allow only the DEPLOY_BOT App identity to bypass this specific rule.
The DEPLOY_BOT App is the same GitHub App used across deploy-staging.yml, deploy-production.yml, and linear-uat-passed-create-pr.yml. reset-staging.yml was migrated onto this identity (previously used the default GITHUB_TOKEN) specifically so the bypass could be granted to a targetable App rather than the un-targetable github-actions[bot].
Setting it up in GitHub:
- Settings → Rules → Rulesets →
staging-protection - Scroll to Bypass list → Add bypass
- Switch to the Apps tab
- Find the DEPLOY_BOT App (backed by
vars.DEPLOY_BOT_CLIENT_ID/secrets.DEPLOY_BOT_PRIVATE_KEY) - Set bypass mode to Always
- Save
The DEPLOY_BOT App must have Contents: Read & write permission on the repo. Check/grant at Settings → Integrations → GitHub Apps → (your bot) → Permissions.