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:
- Spins up a scratch org, installs dependencies, and passes all tests.
- Generates an unlocked package or deployment artifact.
- Promotes the artifact to a staging sandbox with change-set validation.
- 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.
-
Create a Connected App: In your Dev Hub, create a Connected App with OAuth scopes (api, web, refresh_token).
-
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.
- 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.
-
Go to Settings > Branches in GitHub.
-
Add a Branch Protection Rule for main.
-
Check “Require status checks to pass before merging”.
-
Search for and select validate-change (the name of the job in our YAML).
-
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:
-
Compares HEAD (current commit) with HEAD^ (previous commit).
-
Creates a package.xml containing only the new/changed files.
-
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:
- 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.
- 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:
-
JWT Certificate: Rotate the server.key every 12 months. Update the SFDX_JWT_KEY secret in GitHub.
-
Scratch Org Def: If you enable new Salesforce features (e.g., Person Accounts, Multi-Currency), update config/project-scratch-def.json.
-
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.
-
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.