Delta deployments and quick deploys
Salesforce deploys at its own pace — the toolchain cannot make the platform faster. What it can do is ask the platform to do less, and not do the same work twice. Two mechanisms do exactly that: delta deployments deploy only what changed instead of the whole project, and quick deploys reuse a validation that already passed instead of running it again. Both are opt-in, both fall back safely, and neither helps when the change itself is large — a big diff is a big deploy.
Delta: deploy only the diff
By default a deploy is full — the whole force-app goes to the org. A stage opts into delta
per variable (DELTA_DEPLOY_<NAME>; see
how to enable delta deployments for a stage). When delta is on, the
toolchain uses sfdx-git-delta to compute a package
containing only the components that changed between a base ref and HEAD, and deploys that.
The base ref depends on the run:
- Pull request validation diffs against the PR target branch, so the delta covers the whole PR.
- Stage deploys diff against the latest git tag on the default branch; with no tag the base is
HEAD^, which for a merge commit is the merged PR — so per-merge deltas work with no tagging.
Deleting a tracked file becomes a destructive change: sfdx-git-delta turns the deletion into a destructive manifest that rides along with the delta. (Deploys are otherwise additive — they never remove anything on their own.)
The delta respects the active .forceignore context (see
forceignore contexts): excluded metadata never enters the
delta manifest or its destructive companion. Without that filter, a git-derived manifest would
demand components the deploy cannot resolve from source — a hard failure — whereas a full push
merely skips them.
The destructive companion additionally honours .forceignoreDestructive, for files that deploy
normally but must never be deleted from the org: the orchestrator unions that file with the active
.forceignore and hands the result to sfdx-git-delta's destructive filter only. Two boundaries
follow from where the filter sits. It guards the generated destructive set alone — manually
authored destructive manifests under deployment-steps/destructive/ are deliberate decisions and
stay unfiltered. And the org keeps the component; only the automated deletion is withheld, so
removing it for real means a manual destructive manifest or an org-side deletion.
The same filter carries the workaround removals. A workaround that moves a component aside —
an already-deployed queue, an active bot version — registers the path in .forceignore as an
owner-tagged block, because sfdx-git-delta reads the committed refs and would otherwise still put
the component into the manifest. The blocks outlive the .forceignore context swap and disappear
when the workaround reverts. Block format and owners:
workaround reference.
Quick deploy: validate once, deploy the same result
Salesforce lets you validate a deployment (run it without committing) and then, if it passed, quick-deploy that validated result without re-running the tests. The toolchain uses this across the PR → merge boundary:
sequenceDiagram
participant PR as PR validation
participant BB as Bitbucket build status
participant Merge as Merge / deploy step
PR->>PR: validate deployment (runs tests)
PR->>BB: publish the deploy id and strategy (delta or full) on the commit
Merge->>BB: read the build status for this commit
alt id found and strategy matches
Merge->>Merge: quick-deploy from the id (no re-test)
else no usable id
Merge->>Merge: full deploy (runs tests)
end
A successful deploy validate publishes the deployment id as a commit build status, tagged
with the strategy it used (strategy=delta or strategy=full). The deploy step reads that status
and quick-deploys from the id — skipping a second test run. A delta-scoped validation is never
quick-deployed onto a stage that has not opted into delta, so a narrow validation cannot sneak a
partial deploy onto a full-deploy stage.
Scratch targets skip the whole handoff: a pooled or temporary org's name is unique per claim, so a published validation id could never be looked up again — deploys against scratch orgs neither publish nor read build statuses.
Build statuses need credentials
Publishing and reading build statuses uses the Bitbucket REST API, which needs
a BITBUCKET_ACCESS_TOKEN repository variable (a repository access token). If it is missing or the
runner's IP is not allow-listed, the calls fail — but the design is fail-open: you simply
lose the quick-deploy optimisation and the deploy runs in full. Nothing breaks.
The fallback ladder
Delta is an optimisation, not a promise, so the deploy orchestrator degrades gracefully:
graph TD
Start["Deploy"] --> Delta{"Delta enabled
and computable?"}
Delta -->|yes| DeltaDeploy["Delta deploy
(RunRelevantTests)"]
Delta -->|no| Full["Full deploy"]
DeltaDeploy -->|fails, persistent org| Full
DeltaDeploy -->|fails, scratch target| Caller["Verdict returns to
org scratch-create
(it owns the full push)"]
Full -->|fails| Classify{"Failure = Apex
test failures only?"}
Classify -->|yes| Stop21["Stop, exit 21
(deterministic test failure)"]
Classify -->|no, component errors| Fail["Fail, non-zero
(delta already fell back to full)"]
When a delta deploy fails, the orchestrator falls back to a full deploy — the self-heal for
delta-packaging gaps. The one deliberate exception to that recovery is exit code 21: a failure
that is Apex test failures alone — no component errors — ends the run with the failing test
names, because a test verdict is about the code, not the deploy mechanics, and re-running cannot
change it. The delta failure is classified immediately: if the delta failed on tests alone, that
verdict stands even when the full fallback afterwards deploys cleanly — the fallback's test scope
differs from the delta's RunRelevantTests, so its green does not overrule the red. Any other
failure ends the run non-zero without a special code. The classification is fail-open — if the
deploy result cannot be read unambiguously, exit 21 is not used.
Against a scratch target the orchestrator does not climb the ladder itself: the "fails → Full
deploy" edge above belongs to org scratch-create. It already owns the one full push, and that push
carries the complete scratch workaround set; a second full deploy inside the orchestrator would cost
a whole deploy cycle to land the same result. So a failed delta against a scratch org surfaces its
verdict and returns to org scratch-create, which runs the full push — unless the delta failed on
Apex tests alone, where exit 21 ends the run before any push.
Curated test suites vs RunRelevantTests
A curated test suite is a control instrument, not a platform feature: a fixed, named list of
test classes that you decide on, stored as an ApexTestSuite metadata file. apex testsuite
generate builds one by scanning a source directory for @isTest classes and writing their names,
sorted, into a *.testSuite-meta.xml; an ignore file can drop specific classes from the list. You
would curate one when running every local test on every deploy is not what you want — for example a
class that only fails in one environment, a split between fast and slow test tiers, or excluding
tests that are not safe to run in parallel. Because you choose the contents, the suite stays the
same regardless of which components a given deploy touches, which is exactly the point: it is a
deliberate, reviewable, versioned decision about what "the tests" means for this project, not a
per-deploy computation. See how to control which Apex tests run on a full
deploy for the steps.
A curated suite works anywhere a test-level parameter accepts one — on a full deploy / deploy
validate, where the orchestrator resolves it to a concrete class list before invoking sf, and on
a standalone test apex run. The flags are in the
CLI reference.
RunRelevantTests sits at the opposite end: it is a test level the Salesforce platform computes
at deploy time — given the components being deployed, the platform infers which Apex tests cover
them and runs only those, with no list to curate or commit. It exists only as a --test-level value
on a deployment — there is no standalone equivalent. Apex has no analogue of Jest's
--findRelatedTests; a standalone test apex run always runs every deployed local test (or a
named suite), never a platform-inferred subset.
The delta leg of deploy always sets --test-level RunRelevantTests (buildDeltaArgs in the
orchestrator) — it cannot use a curated suite, because it has no way to know ahead of time which
tests are relevant to an arbitrary diff. A full deploy, by contrast, takes whatever test level
--test-level / TEST_LEVEL resolves to, including a curated suite via RunSpecifiedTestSuites.
No suite ships by default
The template ships no committed test suite; apex testsuite generate is a capability, not an
active default. The pre-commit step that would regenerate one is opted out by default (see
what happens when you commit).
Where this lives
- Orchestration:
commands/deploy/deploy-orchestrator.mjsandorchestrator-command.mjs. - Delta computation: sfdx-git-delta, an exact-pinned devDependency in
package.json, linked intosfbyci install-tools. - Curated test suites:
commands/apex/testsuite/generate.mjsandcommands/apex/testsuite/convert.mjs. - Toggles:
DELTA_DEPLOY_<NAME>,TEST_LEVEL,TESTS,TEST_SUITE_NAMES(see the environment variable reference).