Skip to content

B2 - Multi-Team with Environment Branches ​

Several streams, one long-lived Git branch per org, and an explicit release decision. The classic enterprise Salesforce setup - and the one that costs the most to operate, so adopt it only for the stages your approval process actually requires.

The environment branches are the same in every variant. What differs is what travels between them, and that follows from how you release:

  • Release train - the whole state of a branch moves up the chain, stage by stage, on planned release dates. It is the default model of the open-source sfdx-hardis.
  • Story promotion - every user story travels on its own, into each stage through a short-lived promotion branch, and reaches production when it is ready. Gearset Pipelines implements it almost exactly; Copado and DevOps Center follow the same principle with different mechanics. Here it runs on plain GitHub and the kit's workflows.
  • Release bundle - stories travel one by one up to UAT, where they are signed off individually; on the release date, a release branch carries the signed-off stories as one bundle through pre-production into production. DevOps Center builds the same idea in as a "bundling stage".

In short

For several teams sharing one production org with approval gates. One long-lived branch per org - integration, uat, preprod, main - and changes promoted story by story, as a bundle on release dates, or as a whole release train. Switch on PR validation on every environment branch and one deployment per branch, each with its own GitHub Environment. Watch out: merge overhead and drift grow with every stage - keep only the stages your approvals need.

At a glance ​

Team size5-25 developers and admins across several streams
EnvironmentsIntegration + UAT + pre-production + production
Branch modelFour environment branches: integration, uat, preprod, main
Release styleStory promotion: story by story · Release bundle: per story to UAT, bundled into production · Release train: release dates
MaturityLevel 2-3 - at level 1, start with two stages (see below)
PrerequisitesSandbox budget - see the licence warning below

Three ways to promote ​

Story promotionRelease bundleRelease train
What movesOne user story, separately into each stageOne story at a time up to UAT; from there, the signed-off stories as one bundleThe whole state of a branch
Feature branches start atmain - the production statemain - the production stateintegration
Stage-to-stage mergesNever - environment branches are not merged into each otherNever - up to UAT per story, from there one release branch per dateYes, strictly up the chain
Deployment between stagesDelta - only what the story broughtDelta - the story, then the bundleFull
A story that is not readyHolds only itselfStays out of the bundle and waits for the next dateHolds the train, or has to be backed out
Where conflicts are solvedIn the story's promotion branch, once per stageIn promotion branches up to UAT, then once more in the release branchIn the target branch, once per hop
Urgent fixesA story like any other, promoted only as far as it needsA story promoted straight on, or a small bundle of its ownSeparate hotfix path with a hard constraint
FitsContinuous delivery of independent stories, with approval gatesSign-off per story, production on release datesPlanned release dates, scope frozen per release

If stories should reach production as soon as they are signed off, run story promotion. If stories are signed off one by one but production gets them on dates, run the release bundle. If whole releases are planned and tested as one, run the train. Switching later does not touch the orgs or the environment branches - only how changes travel between them.

Target picture ​

Release train ​

Story promotion ​

Release bundle ​

Org & sandbox landscape ​

One sandbox per environment branch:

BranchOrgSandbox typeWhy
integrationIntegrationDeveloper ProWhere streams collide first; metadata only is enough
uatUATPartial Copy or FullBusiness sign-off needs realistic data
preprodPre-productionFullLast rehearsal - as close to production as you can get
mainProduction--

Plus Developer sandboxes or scratch orgs per developer for building.

Starting out? Begin with two stages

At maturity level 1, four long-lived branches collapse under their own weight. Start with uat and main only - the pull request into uat is where streams collide, and the UAT sign-off is your gate. Add integration once CI runs on every pull request, and preprod only when a release rehearsal on production-like data is genuinely required.

This is the expensive blueprint

On Enterprise Edition, Full and Developer Pro sandboxes are a paid add-on. Before committing to four stages, price the entitlement - and ask whether UAT on a Partial Copy would be enough, which is often the case. A stage nobody signs off in is pure overhead. See Background.

Remember the refresh intervals: a Full sandbox can only be refreshed every 29 days, so preprod drifts from production between refreshes. Schedule the refresh, and treat "it worked in preprod" as evidence, not proof.

Branch model ​

Environment branches are long-lived, protected, and each bound to one org: integration, uat, preprod, main. Each one describes what its org should contain.

Short-lived branches carry the work and are deleted once merged. Which ones you use depends on the variant.

Release train ​

feature/* and hotfix/* are the short-lived branches. Make integration the repository's default branch - it is where feature work targets.

Merge direction is strictly one way, up the chain. Deployment mode differs by hop:

  • Feature → environment (feature/* → integration): delta deployment. Only what the branch changed.
  • Environment → environment (integration → uat → …): full deployment. The promoted state is the whole branch, and a delta here would silently skip anything a previous hop missed.

Two streams share the chain:

  • BUILD - normal features, entering at integration and traversing all four stages.
  • RUN - urgent fixes, entering at preprod and reaching main in two hops.

The RUN stream has a hard constraint

While a release is being validated in preprod, the RUN stream cannot deploy to production without shipping that unvalidated release with it. Either your pre-production validation window is short, or your hotfix path is blocked for its duration. Decide which, in advance, and write it down.

After a hotfix reaches main, retrofit it back into the BUILD stream: branch from main, open a pull request into integration. Do this immediately - a retrofit deferred by a week is a merge conflict on a flow you no longer remember. Where retrofits are routine, a Salesforce-aware merge driver is worth the setup.

Story promotion ​

feature/* branches start from main, so a story carries the production state plus its own change - nothing that is still in flight in a lower stage. Keep main as the default branch.

Environment branches are never merged into each other. A story reaches each stage on its own, through a promotion branch: a short-lived branch cut from the target environment branch, holding exactly one story for exactly one stage. (Some tools use the term differently - sfdx-hardis, for example, calls a cherry-picked partial release a promotion branch.)

For each stage:

  1. Create a promotion branch from the target environment branch, e.g. promotion/story-123-uat from uat.
  2. Merge the feature branch into it. Resolve conflicts here, so the feature branch stays clean for the next stage.
  3. Open a pull request into the environment branch. Once merged, the stage deploys the delta - exactly what the merge brought in.
  4. Delete the promotion branch.
bash
git switch -c promotion/story-123-uat origin/uat
git merge --no-ff origin/feature/story-123   # resolve conflicts on this branch
git push -u origin promotion/story-123-uat
gh pr create --base uat --title "Promote story-123 to uat"

To production, the feature branch itself goes into main - through a promotion branch only if it conflicts. An urgent fix is simply a story that is promoted only as far as it has to go, typically uat and then main, without waiting for anything else.

Sync after every release. Each stage holds a different set of stories, so the environment branches drift apart by design. After each production deployment, merge main back into every environment branch through a pull request. It keeps each stage at "production plus the stories in flight", and it stops the same conflict from coming back at every stage. Do not replace it with story-by-story back-merges: those carry a story's own commits, but not the merge commits on main, which then resurface later as unexplained differences.

Release bundle ​

Up to uat, stories travel exactly as in story promotion: feature branches from main, one promotion branch per story and stage. On the release date the bundle takes over:

  1. Cut release/<date> from main.
  2. Merge every story that passed its UAT sign-off into it. Conflicts between the stories of this release are solved here, once.
  3. Open a pull request into preprod. Once the rehearsal passes, open the next one from the same release branch into main.
  4. Delete the release branch, and sync main back into every environment branch as in story promotion.

What reaches preprod is byte for byte what reaches main - the gap story promotion leaves between test stage and production closes at the last two stages. A story that misses its sign-off simply stays out of the bundle and waits for the next date.

Promotion path & roles ​

RoleRelease trainStory promotion
DeveloperBuilds in a dev sandbox, commits to feature/*, opens a PR into integrationBuilds, then promotes the story into integration and the test stages
Tech leadReviews PRs into integration, keeps the branch greenReviews the story's first promotion and its PR into main
Release managerOwns the stage-to-stage promotions, decides what enters the train, runs the approval gatesDecides which stories enter preprod and main, runs the approval gates
Business ownerSigns off in UAT - the gate that justifies the stage existingSigns off each story in UAT

The release bundle follows the story promotion roles up to uat; from there, the release manager owns the release branch and decides which signed-off stories go into it.

Grant merge rights on uat, preprod and main to the release manager role only. integration stays open to the team - that is the point of having it.

Strengths ​

  • Clear stage gates with a named owner per gate.
  • Maps onto approval and segregation-of-duties requirements without inventing process.
  • Release train: conflicts surface in integration, early and cheaply, rather than during a release. BUILD and RUN separate cleanly, so an urgent fix does not have to wait for the train.
  • Story promotion: stories move independently - one that fails UAT holds nobody else back. main only ever receives stories, never the leftovers of a lower stage. Urgent fixes need no special path.
  • Release bundle: sign-off stays per story, yet production receives exactly the bundle that was rehearsed in preprod. A story that is not ready never blocks the date.

Trade-offs & anti-patterns ​

  • Four long-lived branches is real, permanent overhead. Every promotion is a merge, and XML metadata merges badly. This is the cost you are paying for the gates - make sure you are getting them.
  • Do not degrade into branch-per-org as backup. If promotions stop being reviewed pull requests and become "sync the branch to what is in the org", you have lost the model's only real benefit. See the warning in Dimensions.
  • Do not add a fifth stage. If something needs another environment, it usually needs feature flags instead.
  • Drift is the silent failure mode. Orgs receive changes outside the pipeline; each one makes the next promotion less predictable.
  • Release train: do not test large features directly in preprod. It blocks the RUN stream and skips the UAT gate.
  • Story promotion: what you tested is not byte for byte what you ship. Each stage resolves its own conflicts, so uat and main can differ in more than the stories they hold. The sync after every release keeps that gap small; skipping it makes it grow.
  • Story promotion: dependent stories need a rule. If story B builds on story A, promoting B alone breaks the stage. Once a dependency is known, combine the two into one feature branch and promote them together - "promote A first" fails as soon as A is held back.
  • Story promotion and release bundle: deltas hide gaps. Every stage only ever receives deltas, and a delta can pass where a full deployment of the same branch would fail - a deleted picklist value that is still referenced, for example. Run a full check-only deployment of every environment branch into its org regularly - weekly is enough. The weekly full validation under Kit mapping does exactly that.
  • Release bundle: stories are merged twice. Once per stage up to uat, then again into the release branch - conflicts can need solving at both points. Small, independent stories keep that cheap.
  • Story promotion: do not promote by hand at scale. Four promotion branches per story is toil. Let promote.yml do it - see Kit mapping.

Kit mapping ​

Validation on every environment branch - pr-validation.yml as a required check for pull requests into all four, with delta validation against the org behind the target branch. In story promotion, these are the pull requests from the promotion branches - each one validates the story merged with that stage's state, in that stage's org:

yaml
name: PR Validation

on:
  pull_request:
    branches: [integration, uat, preprod, main]

permissions:
  contents: read
  security-events: write

jobs:
  validate:
    uses: svierk/salesforce-devops-starter-kit/.github/workflows/pr-validation.yml@v1.5.0
    with:
      source-dir: force-app
      test-level: RunLocalTests
      delta: true
      severity-threshold: '3'
      # integration-validation | uat-validation | preprod-validation |
      # main-validation - the org behind the target branch, see below
      environment: ${{ format('{0}-validation', github.base_ref) }}
    secrets: inherit

One deployment per environment branch - deployment.yml, triggered on push, with a GitHub Environment per org. The environment carries both the approval rules and the org's credentials, so the same reusable workflow serves all four stages:

yaml
name: Deployment

on:
  push:
    branches: [integration, uat, preprod, main]

permissions:
  contents: read

jobs:
  deploy:
    uses: svierk/salesforce-devops-starter-kit/.github/workflows/deployment.yml@v1.5.0
    with:
      source-dir: force-app
      target-org-alias: ${{ github.ref_name }}
      test-level: RunLocalTests
      # Release train: stage-to-stage promotions deploy the full branch state
      delta: false
      # integration | uat | preprod | production - each with its own secrets
      # and, for preprod and production, required reviewers
      environment: ${{ github.ref_name == 'main' && 'production' || github.ref_name }}
    secrets: inherit

For story promotion and the release bundle, deploy the delta of each merge instead - the story or the bundle, plus any conflict resolution from its promotion or release branch. github.event.before is the state of the environment branch before the push:

yaml
      delta: true
      delta-from: ${{ github.event.before }}

One secret set per org

Create four GitHub Environments and store SFDX_CONSUMER_KEY, SFDX_JWT_SECRET_KEY and SFDX_USERNAME as environment secrets in each. secrets: inherit then resolves the right org automatically, and adding a stage never means editing the caller. Add required reviewers to preprod and production for the approval gate.

Validation needs a twin per branch - integration-validation, uat-validation, preprod-validation, main-validation - with the same three secrets but no required reviewers and no branch restrictions. A protected environment would hold every pull request until someone approves it, or reject it outright.

Promotions as a form in the Actions tab - promote.yml runs the steps above: it cuts the promotion branch, merges the story and opens the pull request into the stage. The same workflow cuts a release bundle and opens the sync pull requests after a release - the example caller shows all three. A conflict stops the run, and its summary lists the files and the commands to finish the promotion locally.

yaml
jobs:
  promote:
    uses: svierk/salesforce-devops-starter-kit/.github/workflows/promote.yml@v1.5.0
    with:
      target: uat
      sources: feature/story-123
    secrets:
      PROMOTION_APP_ID: ${{ secrets.PROMOTION_APP_ID }}
      PROMOTION_APP_PRIVATE_KEY: ${{ secrets.PROMOTION_APP_PRIVATE_KEY }}

Install a GitHub App with contents and pull request write access and pass its credentials: a pull request opened with the default GITHUB_TOKEN does not trigger other workflows, so the PR validation would never run on it.

Weekly full validation (story promotion and release bundle) - deployment.yml as a check-only, full deployment of every environment branch into its org. The ref input checks out each branch, so one scheduled caller covers all of them:

yaml
name: Weekly Full Validation

on:
  schedule:
    - cron: '0 3 * * 1'
  workflow_dispatch:

permissions:
  contents: read

jobs:
  validate:
    strategy:
      fail-fast: false
      matrix:
        branch: [integration, uat, preprod, main]
    uses: svierk/salesforce-devops-starter-kit/.github/workflows/deployment.yml@v1.5.0
    with:
      ref: ${{ matrix.branch }}
      target-org-alias: ${{ matrix.branch }}
      dry-run: true
      delta: false
      environment: ${{ format('{0}-validation', matrix.branch) }}
    secrets: inherit

Branch protection on all four environment branches:

  • Require a pull request before merging - no direct pushes, including for the release manager
  • Require the PR Validation check to pass
  • Require branches to be up to date before merging
  • Restrict who can merge into uat, preprod and main to the release manager role
  • Block force pushes and deletions
  • Release train: merge environment-to-environment pull requests with a merge commit - never squash, and do not delete the source branch. The next promotion's scope is computed from that history.
  • Default branch: integration for the release train, main for story promotion and the release bundle

Optional: scratch-org-ci.yml on feature/* pushes as fast CI before the first promotion - it creates a clean org, deploys, tests and deletes it, so a story is tested in isolation before it reaches shared integration.

Migration path ​

Between the variants - in either direction, at any time. The orgs and the environment branches stay; only the way changes travel between them changes. Teams often start on the train, move to the release bundle once stories are signed off individually, and to story promotion once they are small and independent enough to ship one by one.

To B3 - the usual destination. Cut the first module out of the happy soup - as source, or as an org-dependent unlocked package if it is tightly coupled - and as modules and feature flags take over, collapse the stages: uat usually goes first, then preprod.

Signals you are ready: deployment frequency, lead time and change fail rate improving over several quarters, measured against your own baseline, and metadata that has proven separable in practice - not just on a whiteboard.

Signals you over-built: a stage where nothing is ever rejected, a UAT sign-off that is a formality, or a promotion chain that takes longer than the development it gates. Remove the stage. Moving back toward B1 is a legitimate outcome.

If compliance boundaries or data residency appear → B4, which can run this same model once per production org.

Released under the MIT License. · llms.txt for AI agents