DEV Community

Cover image for CI/CD for Salesforce Using GitHub Actions
PRANAV BHARTI
PRANAV BHARTI

Posted on

CI/CD for Salesforce Using GitHub Actions

"Continuous Integration doesn't get rid of bugs, but it does make them dramatically easier to find and remove." - Martin Fowler

If you've ever deployed to Salesforce by manually clicking through change sets and praying nothing breaks in production, this post is for you. You'll learn how to build a real CI/CD pipeline using GitHub Actions that validates every pull request and deploys automatically on merge - no clicking required.


Why Manual Salesforce Deployments Are Slowly Killing Your Team

You know the drill. Someone builds a feature in their sandbox, pulls together a change set, and deploys it on a Friday afternoon. Then the on-call phone rings.

The problems with manual deployments aren't just about risk:

  • No repeatability - what worked on your sandbox may not match what's in the change set.
  • No audit trail - "who deployed what and when?" becomes a detective game.
  • Fear culture - teams avoid deploying often because each deployment is a high-stakes event.
  • Slow feedback - bugs found in prod cost 10x more to fix than bugs caught in a PR.

CI/CD flips this. Small changes, deployed frequently, validated automatically. The pipeline is your safety net.


The Stack You'll Need

Before writing a single line of YAML, here's what powers the whole thing:

Tool Purpose
Salesforce CLI (sf v2) Authenticate, deploy, and run tests from the command line
GitHub Actions Orchestrate the workflow on push/PR events
JWT Bearer Flow Passwordless authentication to your Salesforce org
sfdx-git-delta Generate a delta package - deploy only changed metadata
Salesforce Code Analyzer v5 Static analysis to catch bugs before they hit the org

⚠️ Use sf (v2), NOT the deprecated sfdx CLI. Salesforce released sf v2 in July 2023 and sfdx is no longer maintained. Most examples online still use sfdx - don't follow them blindly.


One-Time Setup: Connected App and JWT Auth

GitHub Actions runs on a server with no browser. That means no OAuth redirects, no username/password. JWT Bearer Flow is the right approach - it's headless, secure, and what Salesforce recommends for CI/CD.

Step 1 - Generate a key pair on your local machine:

openssl genrsa -out server.key 2048
openssl req -new -x509 -nodes -sha256 -days 365 \
    -key server.key -out server.crt
Enter fullscreen mode Exit fullscreen mode

This gives you server.key (the private key - guard it fiercely) and server.crt (the certificate you upload to Salesforce).

Step 2 - Create a Connected App in Salesforce:

Go to Setup > App Manager > New Connected App and configure it like this:

  • Check Enable OAuth Settings
  • Callback URL: https://login.salesforce.com/services/oauth2/callback
  • Check Use Digital Signatures and upload server.crt
  • OAuth Scopes: Manage user data via APIs (api), Perform requests at any time (refresh_token, offline_access)
  • Check Require Secret for Web Server Flow = OFF

After saving, note the Consumer Key - you'll need it shortly.

Step 3 - Pre-authorize your deployment user:

In the Connected App, click Manage > Edit Policies. Set Permitted Users to "Admin approved users are pre-authorized". Then go to the user profile and add the Connected App under Connected App Access.

💡 Skipping this step is the 1 reason JWT auth silently fails. The error message won't tell you this is the problem.

Step 4 - Add secrets to GitHub:

Go to your repo Settings > Secrets and variables > Actions and add:

Secret Name Value
SF_USERNAME Your Salesforce deployment user's email
SF_CONSUMER_KEY Consumer Key from the Connected App
SF_JWT_KEY Full contents of server.key
SF_INSTANCE_URL https://login.salesforce.com (or your My Domain URL)

⚠️ Never commit server.key to your repo. Add it to .gitignore immediately.


Your Project Structure

Your Salesforce project needs to be in source format (not metadata format) for this pipeline to work. If you're still using the old metadata API format, run sf project convert source to migrate.

my-salesforce-project/
├── .github/
│   └── workflows/
│       └── salesforce-cicd.yml    ← your pipeline lives here
├── force-app/
│   └── main/
│       └── default/
│           ├── classes/
│           ├── lwc/
│           ├── triggers/
│           └── permissionsets/
├── config/
│   └── project-scratch-def.json
└── sfdx-project.json
Enter fullscreen mode Exit fullscreen mode

The sfdx-project.json at the root tells the CLI where your source lives. Make sure it points to force-app.


The GitHub Actions Workflow

Here's a production-ready workflow that does two things:

  • On pull request - validates the code and runs Apex tests (no deployment)
  • On merge to main - deploys to your target org
name: Salesforce CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  salesforce-cicd:
    name: Validate & Deploy
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Required for sfdx-git-delta to compare commits

      - name: Install Salesforce CLI
        run: npm install --global @salesforce/cli@latest

      - name: Authenticate to Salesforce
        env:
          SF_JWT_KEY: ${{ secrets.SF_JWT_KEY }}
        run: |
          echo "$SF_JWT_KEY" > server.key
          sf org login jwt \
            --client-id "${{ secrets.SF_CONSUMER_KEY }}" \
            --jwt-key-file server.key \
            --username "${{ secrets.SF_USERNAME }}" \
            --instance-url "${{ secrets.SF_INSTANCE_URL }}" \
            --set-default-org
          rm -f server.key

      - name: Validate on Pull Request
        if: github.event_name == 'pull_request'
        run: |
          sf project deploy validate \
            --source-dir force-app \
            --test-level RunLocalTests \
            --wait 30 \
            --verbose

      - name: Deploy on Merge to Main
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: |
          sf project deploy start \
            --source-dir force-app \
            --test-level RunLocalTests \
            --wait 30 \
            --verbose
Enter fullscreen mode Exit fullscreen mode

The key difference: deploy validate does a check-only deployment (no actual changes). deploy start is the real thing.


Deploy Only What Changed: sfdx-git-delta

Deploying your entire force-app on every merge is slow and risky. If you have 500 Apex classes, why redeploy 499 that didn't change?

sfdx-git-delta (SGD) compares your current commit against the previous one and builds a package.xml containing only the changed metadata. This can slash deployment times from 20+ minutes to under 2.

Here's how to add it to your workflow, replacing the deploy step:

      - name: Install sfdx-git-delta
        run: echo y | sf plugins install sfdx-git-delta

      - name: Generate Delta Package
        run: |
          mkdir -p delta
          sf sgd source delta \
            --to "HEAD" \
            --from "HEAD~1" \
            --output delta \
            --generate-delta \
            --source-dir force-app

      - name: Deploy Delta (on merge)
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: |
          if [ -s delta/package/package.xml ]; then
            sf project deploy start \
              --manifest delta/package/package.xml \
              --test-level RunLocalTests \
              --wait 30
          else
            echo "No metadata changes detected - skipping deployment"
          fi

      - name: Deploy Destructive Changes
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: |
          if [ -s delta/destructiveChanges/destructiveChanges.xml ]; then
            sf project deploy start \
              --manifest delta/package/package.xml \
              --pre-destructive-changes delta/destructiveChanges/destructiveChanges.xml \
              --test-level NoTestRun \
              --wait 30
          fi
Enter fullscreen mode Exit fullscreen mode

SGD also handles destructive changes automatically - if you deleted or renamed a class in git, it builds the destructiveChanges.xml for you.


Add Static Analysis with Salesforce Code Analyzer

Catching bugs before they reach the org is cheaper than fixing them after. Salesforce Code Analyzer v5 (released in 2025) runs PMD and other engines against your Apex, and has an official GitHub Action.

Add this step before your deploy/validate step:

      - name: Run Salesforce Code Analyzer
        uses: forcedotcom/run-code-analyzer@v2
        with:
          run-command: run
          run-arguments: >
            --target force-app
            --engine pmd
            --severity-threshold 2
          results-artifact-name: salesforce-code-analyzer-results
Enter fullscreen mode Exit fullscreen mode

--severity-threshold 2 fails the build on any high or critical PMD violations. Set it to 3 if you want a softer start and work your way down.


Common Pitfalls (Save Yourself the Headache)

Using sfdx instead of sf
The old sfdx CLI is deprecated. Its commands look similar but behave differently. Always use sf org login jwt, not sfdx force:auth:jwt:grant.

Forgetting fetch-depth: 0
By default, actions/checkout only pulls the latest commit (a shallow clone). sfdx-git-delta needs the full git history to calculate the diff. Without fetch-depth: 0, it'll fail silently or compare against nothing.

Connecting App not pre-authorized
JWT auth will return a cryptic invalid_grant error. The fix is in the Connected App's Permitted Users policy - set it to admin-approved and explicitly grant access to the deployment user.

Using RunAllTests instead of RunLocalTests
RunAllTests runs managed package tests too, which can add 30+ minutes to your pipeline for no benefit. Use RunLocalTests unless you have a specific reason not to.

Leaving server.key on disk
Always rm -f server.key after authenticating. The example workflow above does this, but if you write your own steps, don't forget it.


Final Thoughts

CI/CD for Salesforce is not harder than any other platform - it just has more one-time setup. Once your Connected App is configured and your secrets are in place, the YAML almost writes itself. Start with a simple validate-on-PR workflow, prove the value to your team, then layer in delta deployments and static analysis as your pipeline matures.

The best deployment is one your team doesn't even have to think about.


Hope this helps you ship Salesforce changes with confidence!
Written by Pranav Bharti

Top comments (0)