Skip to content

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.mjs and orchestrator-command.mjs.
  • Delta computation: sfdx-git-delta, an exact-pinned devDependency in package.json, linked into sf by ci install-tools.
  • Curated test suites: commands/apex/testsuite/generate.mjs and commands/apex/testsuite/convert.mjs.
  • Toggles: DELTA_DEPLOY_<NAME>, TEST_LEVEL, TESTS, TEST_SUITE_NAMES (see the environment variable reference).