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 → PR directly to
main(skips staging and release/beta). Staging is synced back from main automatically.
Developers only open one PR per ticket. The second leg to main is either (a) the aggregated release PR opened by the release manager once per cycle, or (b) the hotfix PR you open yourself.
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.
# 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 need to skip staging and the release cycle:
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 a PR directly to main:
bug/pablo/dev-456-fix-login-crash → main
The linear-uat-passed-create-pr.yml workflow detects the Hotfix deployment label on the Linear ticket and creates this PR automatically when the ticket is moved to "UAT Passed" — but developers can also create it by hand if needed.
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- Feature branch is auto-deleted
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──▶ 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 branch auto-deleted by post-main-merge.yml |
| Hotfix PR merges to main | Feature branch auto-deleted by post-main-merge.yml |
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 |
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, delete feature branches |
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 you open a PR against staging and GitHub flags a merge conflict, that's not a bug — it's the system telling you your change overlaps with a feature currently in UAT. Don't "fix" this by rebasing onto staging. Instead:
- Identify what's conflicting:
git log origin/staging ^origin/release/beta -- path/to/fileshows commits on staging not yet in release/beta. The branch name in the squash commit footer tells you which ticket. - Talk to the other dev. Two cases:
- Their feature is solid (passing UAT, likely to ship): rebase your branch onto their branch (see "Feature depends on another feature" above).
- Their feature is flaky (might be rolled back): resolve the conflict manually on the staging PR, picking whichever version is correct. Your branch stays based on
release/beta/main. When their feature either passes UAT or gets rolled back, re-check for conflicts before the release.
Pre-emptively branching from staging to "avoid" conflicts poisons your integration into release/beta by bringing every other in-UAT feature with you. Take the conflict.
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 - One PR per ticket for features — to
staging, then automation handlesrelease/betaintegration - Hotfixes PR directly to
main— skip staging and release/beta - 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.