Skip to content

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 into release/beta → release manager opens release/beta → main PR 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: feature or bug (from Linear issue labels, default to feature)
  • 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/beta exists (creates it from main if missing)
  • Merges your feature branch into release/beta
  • Moves the Linear ticket to "Ready for Deployment"
  • Comments on Linear with the release/beta branch 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):

  • head branch must be release/beta — triggers aggregated release mode in the extract-tickets step.
  • base branch must be main.
  • All PRs to main use the "Create a merge commit" strategy (enforced by the main branch ruleset — squash and rebase are disabled). For release PRs specifically, this is non-negotiable: deploy-production.yml walks the Integrate <branch> into release/beta merge 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.yml runs two syncs in parallel:
  • sync-staging → merges main into staging so subsequent fix-forwards have a clean base
  • sync-release-beta → merges main into release/beta if 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:

  1. Resets staging to match main (removes shipped/abandoned features)
  2. Auto-detects features still in UAT (branches not yet merged to main)
  3. 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):

  1. 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>
  1. 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.

  2. When the dependency's PR lands on release/beta (via UAT-passed integration), rebase your branch onto release/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:

  1. Identify what's conflicting: git log origin/staging ^origin/release/beta -- path/to/file shows commits on staging not yet in release/beta. The branch name in the squash commit footer tells you which ticket.
  2. Talk to the other dev. Two cases:
  3. Their feature is solid (passing UAT, likely to ship): rebase your branch onto their branch (see "Feature depends on another feature" above).
  4. 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

  1. Branch from release/beta if it exists, else from main — never from staging
  2. One PR per ticket for features — to staging, then automation handles release/beta integration
  3. Hotfixes PR directly to main — skip staging and release/beta
  4. Never delete feature branches after staging merge — the release automation needs them
  5. The release/beta → main PR is manual — opened by the release manager with head=release/beta, base=main
  6. Merges into main use "Create a merge commit"; merges into staging use "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 deletionsmain cannot 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 passall-checks-passed (from pr-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 deletionsstaging cannot be deleted
  • Block force pushes — with a bypass for the DEPLOY_BOT App (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 passall-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_BOT App with mode Always — needed so reset-staging.yml can 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:

  1. Settings → Rules → Rulesets → staging-protection
  2. Scroll to Bypass listAdd bypass
  3. Switch to the Apps tab
  4. Find the DEPLOY_BOT App (backed by vars.DEPLOY_BOT_CLIENT_ID / secrets.DEPLOY_BOT_PRIVATE_KEY)
  5. Set bypass mode to Always
  6. Save

The DEPLOY_BOT App must have Contents: Read & write permission on the repo. Check/grant at Settings → Integrations → GitHub Apps → (your bot) → Permissions.