CI/CD for Salesforce: GitHub Actions Pipeline That Validates Every PR
Most Salesforce teams deploy by clicking "Deploy" in VS Code or using change sets. Both are manual, error-prone, and impossible to audit. A proper CI/CD pipeline validates every pull request against a scratch org, runs Apex tests, and blocks merge if anything fails. GitHub Actions makes this straightforward.
The pipeline has three stages: authenticate, deploy, and test. Authentication uses a JWT bearer flow with a connected app and server key. The key is stored as a GitHub secret. The deploy step creates a scratch org (or deploys to a sandbox), pushes all metadata, and runs all local tests. If any test fails, the PR gets a red X and cannot merge. After merge to main, a separate workflow deploys to the staging sandbox automatically.
The ROI is immediate. No more "it worked in my org" problems. No more deploying untested code. No more manual validation steps. The first time this pipeline catches a breaking change before it reaches production, it pays for the entire setup time. I run this exact pipeline for Mobilization Funding deployments.
Code Example
# .github/workflows/validate-pr.yml
name: Validate PR
on:
pull_request:
branches: [main]
paths:
- 'force-app/**'
- 'config/**'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Salesforce CLI
run: npm install -g @salesforce/cli
- name: Authenticate Dev Hub
run: |
echo "${{ secrets.SFDX_JWT_KEY }}" > server.key
sf org login jwt \
--client-id ${{ secrets.SFDX_CLIENT_ID }} \
--jwt-key-file server.key \
--username ${{ secrets.SFDX_DEVHUB_USERNAME }} \
--set-default-dev-hub \
--alias devhub
rm server.key
- name: Create Scratch Org
run: |
sf org create scratch \
--definition-file config/project-scratch-def.json \
--alias ci-scratch \
--set-default \
--duration-days 1
- name: Push Source
run: sf project deploy start --target-org ci-scratch
- name: Run Apex Tests
run: |
sf apex run test \
--target-org ci-scratch \
--code-coverage \
--result-format human \
--wait 20 \
--test-level RunLocalTests
- name: Delete Scratch Org
if: always()
run: sf org delete scratch --target-org ci-scratch --no-promptNeed this implemented in your org?
I've shipped these patterns in production for 10+ years.
View Consulting →Enjoyed this? Get more like it.
Glen's Musings — AI, investing, and building things. Occasional. Free.