Target: Spring '26, sf CLI. Sources: sf project deploy commands, salesforcecli/plugin-deploy-retrieve, Authorization for CI.
1. Authentication in CI
JWT bearer against an External Client App, with the certificate's private key in the CI secret store:
echo "$SF_JWT_KEY" > server.key
sf org login jwt --client-id "$SF_CLIENT_ID" --jwt-key-file server.key \
--username "$SF_USERNAME" --instance-url "$SF_INSTANCE_URL" --alias prod
One app and one integration user per target org, each with its own permission set. Never a username-password login and never a personal user's token in CI.
2. Pull-request job — cheap gates, then a validation
name: pr
on: pull_request
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # delta deploys need history
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm run lint
- run: npm run test:unit -- --coverage
- run: npx @salesforce-ux/slds-linter@latest lint force-app/main/default/lwc
- run: npm install -g @salesforce/cli && sf plugins install code-analyzer
- run: sf code-analyzer run --workspace force-app --severity-threshold 3 --output-file code-analyzer.csv
- if: always()
uses: actions/upload-artifact@v4
with: { name: code-analyzer, path: code-analyzer.csv }
- name: Authenticate
run: |
echo "$SF_JWT_KEY" > server.key
sf org login jwt --client-id "$SF_CLIENT_ID" --jwt-key-file server.key \
--username "$SF_USERNAME" --instance-url "$SF_INSTANCE_URL" --alias ci
env: { SF_JWT_KEY: "${{ secrets.SF_JWT_KEY }}", SF_CLIENT_ID: "${{ secrets.SF_CLIENT_ID }}", SF_USERNAME: "${{ secrets.SF_USERNAME }}", SF_INSTANCE_URL: "${{ secrets.SF_INSTANCE_URL }}" }
- name: Scratch org build (proves the repo builds from nothing)
run: |
sf org create scratch --definition-file config/project-scratch-def.json \
--alias scratch --duration-days 1 --wait 20
sf project deploy start --target-org scratch --wait 60
sf apex run test --target-org scratch --test-level RunLocalTests --code-coverage \
--result-format junit --output-dir test-results --wait 30
sf flow run test --target-org scratch --test-level RunLocalTests \
--result-format junit --output-dir test-results
- if: always()
run: sf org delete scratch --target-org scratch --no-prompt
3. Release job — validate, then quick deploy
name: release
on:
push: { branches: [main] }
jobs:
validate:
runs-on: ubuntu-latest
outputs: { job-id: "${{ steps.validate.outputs.job-id }}" }
steps:
- uses: actions/checkout@v4
- name: Validate against production
id: validate
run: |
sf project deploy validate --target-org prod --source-dir force-app \
--test-level RunLocalTests --coverage-formatters cobertura --junit \
--results-dir test-results --wait 90 --json > validate.json
echo "job-id=$(jq -r '.result.id' validate.json)" >> "$GITHUB_OUTPUT"
deploy:
needs: validate
environment: production # manual approval gate
runs-on: ubuntu-latest
steps:
- run: sf project deploy quick --target-org prod --job-id "${{ needs.validate.outputs.job-id }}" --wait 30
A validated deployment can be quick-deployed for 10 days, so the approval can wait for the release window without re-running the test suite. If the branch moves, validate again — a quick deploy applies the metadata that was validated, not what is on the branch now.
4. Delta deploys
On a large repo a full-source validation is slow. Two supported ways to narrow it:
# 1. Deploy only what changed, generated from git
sf project generate manifest --source-dir $(git diff --name-only origin/main...HEAD -- force-app | tr '\n' ',') \
--output-dir manifest
sf project deploy validate --target-org prod --manifest manifest/package.xml --test-level RunLocalTests
# 2. Let Salesforce pick the tests (Beta)
sf project deploy validate --target-org prod --source-dir force-app --test-level RunRelevantTests
Delta deploys are an optimisation with a cost: a component that depends on something outside the delta can validate and still fail at runtime. Run a full validation on the release branch at least once before the window, whatever the pull requests do.
5. Destructive changes
<!-- manifest/destructiveChangesPost.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<Package xmlns="http://soap.sforce.com/2006/04/metadata">
<types>
<members>OrderLegacyController</members>
<name>ApexClass</name>
</types>
</Package>
sf project deploy start --target-org prod --source-dir force-app \
--post-destructive-changes manifest/destructiveChangesPost.xml \
--test-level RunLocalTests --wait 60
pre deletes before the new metadata lands, post after — post is the safe default because the replacement exists first. The manifest still needs an (often empty) package.xml alongside it in metadata-format deploys. Deletion and its replacement belong in one deploy, so a rollback restores a consistent state.
6. Rollback
There is no "undo deploy". Plan one of these before the release:
| Strategy | How | Cost |
|---|---|---|
| Reverse deploy | Deploy the previous commit's metadata, plus destructive changes for anything new | Minutes, but data created under the new version may not fit the old schema |
| Package version rollback | Install the previous promoted package version | Clean for packaged code; not available for unpackaged metadata |
| Feature switch | Ship dark behind a custom permission or Custom Metadata flag, then turn it on | The only true instant rollback — prefer it for anything risky |
Fields and objects are not deleted in a rollback: deletion is a separate, deliberate release. Rehearse the rollback in a full sandbox once per major release; a plan nobody has run is a wish.
7. Artefacts a pipeline should publish
- JUnit results from
sf apex run testandsf flow run test(--result-format junit --output-dir test-results). - Coverage from
--coverage-formatters coberturaon the validation, plus Jest's LCOV. - The Code Analyzer output file, kept per build so trends are visible.
- The deploy job id and the validated commit SHA, so the release record answers "what exactly is in production".