← All posts
· 5 min read ·
SalesforceCI/CDGitHub ActionsDevOps

Advanced GitHub Actions Patterns for Salesforce CI/CD

Beyond the basics - reusable workflows, matrix deployments, dynamic environment routing, approval gates, and secrets management patterns for production-grade Salesforce CI/CD pipelines.

Code on a monitor showing CI/CD automation

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:

  1. Create a production environment in GitHub Settings → Environments
  2. Add required reviewers (your release manager or lead dev)
  3. Set a wait timer (I use 5 minutes - gives people time to cancel an accidental trigger)
  4. 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/cache on ~/.local/share/sf reduces 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 --tests flag with only the test classes in the delta - don’t run RunAllTestsInOrg for 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
← All posts