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 "${{ secrets.SFDX_JWT_KEY }}" > server.key
sf org login jwt --username ${{ secrets.SFDX_PROD_USER }} \
--key-file server.key --client-id ${{ secrets.SFDX_CONSUMER_KEY }} \
--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-${{ github.sha }}
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 <commit_id> 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 "${{ secrets.SFDX_JWT_KEY }}" > server.key
sf org login jwt --username ${{ secrets.SFDX_HUB_URL }} \
--key-file server.key --client-id ${{ secrets.SFDX_CONSUMER_KEY }} \
--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.