Bitmoq
← All articles
DevOps Jan 28, 2026

CI/CD for Salesforce using GitHub Actions

How to set up DevOps for Salesforce with GitHub Actions and no expensive tools: JWT auth, scratch org validation, delta-based deployment, rollback, and branch protection.

Use Case

I need my Salesforce development workflow fully automated through GitHub Actions CI. The goal is to move from manual deployments to a reliable CI/CD pipeline that covers every step - from code commit to production release - while following Salesforce best practices (SFDX, scratch orgs, unlocked packages).

Here is what I want to accomplish:

  • Pipeline configuration: create or refine .gitlab-ci.yml so that feature branches trigger scratch-org builds, run Apex tests, static code analysis, and package creation.
  • Environment setup: connect GitLab runners to my Salesforce orgs, manage authentication with JWT-based connections, and handle secrets securely in GitLab variables.
  • Automated testing: ensure unit tests run in scratch orgs on every merge request, with code-coverage gates that block merges if thresholds are not met.
    Acceptance is met when a merge to main automatically:
  1. Spins up a scratch org, installs dependencies, and passes all tests.
  2. Generates an unlocked package or deployment artifact.
  3. Promotes the artifact to a staging sandbox with change-set validation.
  4. Provides clear success/failure feedback inside Github.

The solution has to remain maintainable and documented. Detail out every single step so that my team can handle post implementation.

Solution

This setup uses a JWT-based authentication flow, which is the gold standard for secure, headless Salesforce deployments.

Prerequisites & Security Setup

Before the pipeline can run, you must establish a secure “handshake” between GitHub and Salesforce.

  1. Create a Connected App: In your Dev Hub, create a Connected App with OAuth scopes (api, web, refresh_token).

  2. Generate a JWT Certificate: Create a self-signed digital certificate.

  • Upload the .crt to the Connected App.

  • Store the .key (private key) as a GitHub Secret.

  1. GitHub Secrets: Add the following to your repository:
  • SFDX_JWT_KEY: The content of your server.key.

  • SFDX_HUB_URL: The username for your Dev Hub.

  • SFDX_CONSUMER_KEY: The Client ID from your Connected App.

Initial Project & Repo Setup

To start from a clean slate, you need to initialize your local environment and the GitHub repository correctly.

Step 1: Create the Local Project

Run these commands in your terminal:

# Create the project using the standard template
sf project generate --name my-salesforce-app --template standard
cd my-salesforce-app

# Initialize Git
git init
echo "node_modules/\n.sfdx/\n.sf/\nserver.key" >> .gitignore

Step 2: Professional Folder Structure

A professional structure separates configuration, source, and automation scripts.

force-app/: Your main source code (LWC, Apex, Metadata).

config/: Scratch org definition files.

scripts/: Bash or Python scripts for data loading or custom CI steps.

.github/workflows/: Your CI/CD pipeline definitions.

Configure GitHub Branch Protection

This is the “human” side of the automation.

  1. Go to Settings > Branches in GitHub.

  2. Add a Branch Protection Rule for main.

  3. Check “Require status checks to pass before merging”.

  4. Search for and select validate-change (the name of the job in our YAML).

  5. Check “Require branches to be up to date before merging”.

The Delta-Based Pipeline (main.yml)

This pipeline uses sfdx-git-delta to calculate exactly what changed between the current commit and the previous one. This reduces deployment times from minutes to seconds.

Create .github/workflows/main.yml:

name: Production Deployment (Delta)

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v3
        with:
          fetch-depth: 0 # Critical for git-delta to see history

      - name: Install Salesforce CLI & Plugins
        run: |
          npm install @salesforce/cli --global
          echo 'y' | sf plugins install sfdx-git-delta

      - name: Authenticate Production
        run: |
          echo "$" > server.key
          sf org login jwt --username $ \
            --key-file server.key --client-id $ \
            --alias production --set-default

      - name: Generate Delta (Incremental Changes)
        run: |
          mkdir delta
          sf sgd source delta --to HEAD --from HEAD^ --output delta/ --generate-delta

      - name: Deploy Incremental Changes
        run: |
          sf project deploy start --manifest delta/package/package.xml \
            --post-destructive-changes delta/destructiveChanges/destructiveChanges.xml \
            --target-org production --wait 30 --test-level RunLocalTests

      - name: Create Release Artifact (For Rollback)
        if: success()
        uses: actions/upload-artifact@v3
        with:
          name: deployment-package-$
          path: delta/

Managing “what goes in” and “how to undo” is the most critical part of a professional setup.

Incremental Logic (sfdx-git-delta)

Instead of deploying the whole force-app folder, the pipeline above:

  1. Compares HEAD (current commit) with HEAD^ (previous commit).

  2. Creates a package.xml containing only the new/changed files.

  3. Creates a destructiveChanges.xml for any files deleted in the commit.

Provisions for Rollback

Salesforce does not have a “native” one-click rollback for metadata. To handle rollbacks professionally, follow this dual-layered approach:

  1. Git Revert (Recommended):
  • If a deployment fails or causes bugs, perform a git revert on your main branch.

  • The pipeline will treat the revert as a “new change” and deploy the previous state of the metadata back to the org.

  1. Artifact Storage:
  • The pipeline step Create Release Artifact saves the exact package.xml and source files deployed for every successful run.

  • If GitHub is down or you need to see exactly what touched the org, you can download these from the Actions tab.

The PR Validation Workflow (pr_validation.yml)

This Pull Request (PR) Validation pipeline is the most critical gate in your workflow. It ensures that no “broken” code—whether it’s a syntax error, a failing test, or poor code coverage - ever touches your main branch.

Create this file in .github/workflows/pr_validation.yml. This pipeline triggers on every PR targeting main. It provisions a fresh, isolated environment (Scratch Org), deploys the changes, and runs the full battery of tests.

name: PR Validation & Quality Gate

on:
  pull_request:
    branches: [ main ]
    types: [opened, synchronize, reopened]

jobs:
  static-analysis:
    runs-on:ubuntu-latest
    steps:
      - name: Checkout Code
        uses:actions/checkout@v4

      - name: Install Salesforce CLI & Scanner
        run: |
          npm install @salesforce/cli --global
          echo 'y' | sf plugins install @salesforce/sfdx-scanner

      - name: Run Salesforce Code Analyzer
        run: |
	  # Scans for security, performance, and best practices​
          # --severity-threshold 3 will fail the build for any 'High' or 'Medium' issues
          sf scanner run --target "force-app" --engine "pmd,eslint" --severity-threshold 3

  validate-change:
    needs: static-analysis # This ensures tests only run if code is clean
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

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

      - name: Authenticate Dev Hub
        run: |
          echo "$" > server.key
          sf org login jwt --username $ \
            --key-file server.key --client-id $ \
            --alias devhub --set-default-dev-hub

      - name: Create Ephemeral Scratch Org
        run: |
          sf org create scratch --definition-file config/project-scratch-def.json \
            --alias temp-org --set-default --duration-days 1 --wait 20

      - name: Deploy Changes to Scratch Org
        run: sf project deploy start --target-org temp-org

      - name: Run Apex Tests & Check Coverage
        id: run-tests
        run: |
          # Generate JSON results to allow for logic-based gating
          sf apex run test --target-org temp-org --wait 20 \
            --result-format json --code-coverage --detailed-coverage > test-results.json

      - name: Enforce Coverage Gate (75%)
        run: |
          # Professional gate using jq to parse coverage
          COVERAGE=$(jq '.result.summary.testRunCoverage' test-results.json | tr -d '"%')
          echo "Current Code Coverage: $COVERAGE%"
          if (( $(echo "$COVERAGE < 75" | bc -l) )); then
            echo "Error: Code coverage is below the 75% threshold."
            exit 1
          fi

      - name: Clean Up Scratch Org
        if: always()
        run: sf org delete scratch --target-org temp-org --no-prompt

Guidelines for Salesforce Team to Follow

Repository Setup

To ensure your team can maintain this, include these steps in your repo’s README.md:

  1. JWT Certificate: Rotate the server.key every 12 months. Update the SFDX_JWT_KEY secret in GitHub.

  2. Scratch Org Def: If you enable new Salesforce features (e.g., Person Accounts, Multi-Currency), update config/project-scratch-def.json.

  3. Bypass Analysis: If you have legacy code that fails the scanner, you can ignore files by creating a .eslintignore or adding // nopmd to Apex classes, though this should be a last resort.

  4. Rollback: To undo a release, use the “Revert” button on the merged Pull Request in GitHub. The Delta pipeline will automatically handle the “inverse” deployment.

Best Practices

  • Never deploy manually to Production. All changes must pass through a Pull Request.

  • Feature Branches: Name your branches feature/JIRA-123. Pushing to these will trigger a scratch org validation.

  • Fixing a Release: If main is broken, do not push a “fix” directly. Revert the PR using the GitHub “Revert” button to trigger an automated rollback deployment.

  • Secrets: If the JWT certificate expires, update the SFDX_JWT_KEY in GitHub Settings > Secrets.

Handling Unlocked Packages

To incorporate those into this flow:

  • The main.yml would use sf package version create instead of sf project deploy start.

  • You would then use sf package version promote once the package is validated in your Staging sandbox.

This is a very powerful setup for a Managed Services Provider (MSP) or a professional Salesforce practice, as it guarantees a consistent, documented trail for every client change.