Most Salesforce GitHub Actions tutorials cover the basics: install the Salesforce CLI, authenticate, deploy. That gets you 20% of the way to a production-grade pipeline. Here are the patterns that take you the rest of the way.
Reusable Workflows for Multi-Org Estates
If you’re managing more than one Salesforce org, copy-pasting workflows across repos is a maintenance nightmare. GitHub’s reusable workflows solve this.
The shared workflow library - create a salesforce-workflows repository in your org with callable workflows:
# .github/workflows/deploy.yml in salesforce-workflows repo
on:
workflow_call:
inputs:
environment:
required: true
type: string
test_level:
required: false
type: string
default: RunLocalTests
secrets:
SF_AUTH_URL:
required: true
SFDX_JWT_KEY:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- name: Authenticate to Salesforce
run: |
echo "${{ secrets.SFDX_JWT_KEY }}" > server.key
sf org login jwt \
--client-id ${{ vars.SF_CLIENT_ID }} \
--jwt-key-file server.key \
--username ${{ vars.SF_USERNAME }} \
--alias target-org
Consuming the shared workflow from any project repo:
jobs:
deploy-to-uat:
uses: myorg/salesforce-workflows/.github/workflows/deploy.yml@main
with:
environment: uat
test_level: RunLocalTests
secrets:
SF_AUTH_URL: ${{ secrets.UAT_SF_AUTH_URL }}
SFDX_JWT_KEY: ${{ secrets.UAT_SFDX_JWT_KEY }}
Dynamic Environment Routing
The pattern I use most: route deployments to different orgs based on branch name, without maintaining separate workflow files per environment.
jobs:
set-environment:
runs-on: ubuntu-latest
outputs:
environment: ${{ steps.map.outputs.environment }}
username: ${{ steps.map.outputs.username }}
steps:
- id: map
run: |
case "${{ github.ref_name }}" in
"develop") ENV="sit"; USER="svc@sit.company.com" ;;
"release/*") ENV="uat"; USER="svc@uat.company.com" ;;
"main") ENV="prod"; USER="svc@prod.company.com" ;;
*) ENV="scratch"; USER="" ;;
esac
echo "environment=$ENV" >> $GITHUB_OUTPUT
echo "username=$USER" >> $GITHUB_OUTPUT
deploy:
needs: set-environment
environment: ${{ needs.set-environment.outputs.environment }}
steps:
- name: Deploy
run: |
sf project deploy start \
--target-org ${{ needs.set-environment.outputs.username }} \
--manifest ./delta/package.xml
Matrix Deployments for Scratch Org Testing
Test your changes against multiple API versions or org configurations before deploying to a shared environment:
jobs:
scratch-test:
strategy:
matrix:
api-version: ["59.0", "60.0"]
edition: ["developer", "enterprise"]
fail-fast: false # run all combinations even if one fails
steps:
- name: Create scratch org
run: |
sf org create scratch \
--definition-file config/project-scratch-def.json \
--edition ${{ matrix.edition }} \
--api-version ${{ matrix.api-version }} \
--alias test-org-${{ matrix.api-version }}-${{ matrix.edition }} \
--duration-days 1
- name: Deploy and test
run: |
sf project deploy start --target-org test-org-${{ matrix.api-version }}-${{ matrix.edition }}
sf apex run test --target-org test-org-${{ matrix.api-version }}-${{ matrix.edition }} \
--result-format human --code-coverage
- name: Delete scratch org
if: always()
run: sf org delete scratch --target-org test-org-${{ matrix.api-version }}-${{ matrix.edition }} --no-prompt
Approval Gates with Environment Protection Rules
GitHub Environments let you add required reviewers before deploying to sensitive orgs. For production Salesforce:
- Create a
productionenvironment in GitHub Settings → Environments - Add required reviewers (your release manager or lead dev)
- Set a wait timer (I use 5 minutes - gives people time to cancel an accidental trigger)
- Reference in the workflow:
deploy-production:
needs: [test, deploy-uat]
environment:
name: production
url: https://mycompany.my.salesforce.com
steps:
- name: Deploy to Production
run: sf project deploy start --target-org prod --test-level RunAllTestsInOrg
The workflow pauses at this job and sends reviewer notifications. Production doesn’t get touched until a reviewer approves.
Delta Deployments in CI
Full-org deployments in CI are slow and fragile. Generate a delta package from the diff between the current branch and target branch:
- name: Generate delta package
run: |
# Get the merge base with the target branch
git fetch origin ${{ github.base_ref }}
BASE=$(git merge-base HEAD origin/${{ github.base_ref }})
npx sf-metadata-delta \
--from $BASE \
--to HEAD \
--output ./delta \
--destructive
- name: Check if anything to deploy
id: check-delta
run: |
COMPONENTS=$(grep -c "<members>" ./delta/package.xml || echo "0")
echo "component_count=$COMPONENTS" >> $GITHUB_OUTPUT
- name: Deploy delta
if: steps.check-delta.outputs.component_count > 0
run: |
sf project deploy start \
--manifest ./delta/package.xml \
--target-org ${{ vars.SF_USERNAME }}
Test Result Annotations
Surface test failures directly in the PR without reading log output:
- name: Run Apex tests
run: |
sf apex run test \
--target-org target-org \
--result-format tap \
--output-dir ./test-results \
--code-coverage
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: Apex Tests
path: './test-results/*.xml'
reporter: java-junit
Failed tests show as inline PR annotations pointing to the specific test method.
Secrets Management
Avoid storing Salesforce credentials as static secrets where possible. Instead:
SF CLI JWT flow - store the private key as a secret, derive the access token at runtime. The private key + client ID is all you need; no passwords, no session tokens that expire.
Vault integration - for larger teams, use HashiCorp Vault or AWS Secrets Manager via OIDC:
- name: Get Salesforce credentials from Vault
uses: hashicorp/vault-action@v3
with:
url: ${{ secrets.VAULT_ADDR }}
method: jwt
role: salesforce-deployer
secrets: |
secret/salesforce/prod username | SF_USERNAME;
secret/salesforce/prod client_id | SF_CLIENT_ID;
secret/salesforce/prod private_key | SF_PRIVATE_KEY
Secret rotation: rotate JWT private keys and Connected App client secrets every 90 days. GitHub Actions makes this operationally safe - update the secret in GitHub, no workflow changes required.
Performance Optimisation
Fast CI matters - slow pipelines get bypassed:
- Cache the SF CLI:
actions/cacheon~/.local/share/sfreduces install time from 30s to under 5s - Parallelise validation and test runs: validation (deploy check) and test execution can overlap if you’re deploying to a scratch org
- Skip unchanged test classes: use the
--testsflag with only the test classes in the delta - don’t runRunAllTestsInOrgfor every PR
- name: Run only relevant tests
run: |
# Extract test class names from changed files
TESTS=$(git diff --name-only origin/${{ github.base_ref }} HEAD \
| grep "Test\.cls" \
| xargs -I{} basename {} .cls \
| tr '\n' ' ')
if [ -n "$TESTS" ]; then
sf apex run test --tests $TESTS --target-org target-org
else
echo "No test classes changed - skipping test run"
fi