Part 70: Setting Up a CI/CD Pipeline for Salesforce
Welcome back to the Salesforce series. In Part 67, we explored the Salesforce CLI and learned how to interact with orgs from the terminal. In Part 68, we set up Scratch Orgs as disposable development environments. In Part 69, we packaged our metadata into Unlocked Packages with versioning and dependency management. Now we are going to tie all of those pieces together by building an automated pipeline that takes your code from a Git repository, validates it, tests it, and deploys it — without you having to touch a single button in Salesforce Setup.
This is CI/CD — Continuous Integration and Continuous Deployment — and it is the backbone of modern software delivery. If you have been manually deploying change sets, running tests by hand, and hoping that nothing breaks in production, this post is going to change how you think about Salesforce development. By the end, you will have a working GitHub Actions pipeline that spins up a scratch org, installs your unlocked package, runs your Apex tests, and reports the results back to your pull request. Let us get into it.
What is CI/CD?
CI/CD stands for Continuous Integration and Continuous Deployment (sometimes called Continuous Delivery). These are two related but distinct practices.
Continuous Integration
Continuous Integration is the practice of automatically building and testing your code every time a developer pushes changes to a shared repository. The goal is to catch problems early. Instead of waiting until the end of a sprint to merge everyone’s work together and discover conflicts, CI runs on every push or pull request and tells you immediately whether the new code integrates cleanly with the existing codebase.
In a Salesforce context, CI means that every time you push Apex classes, triggers, LWC components, or metadata changes to your Git repository, an automated process validates that code against a Salesforce org. It deploys the metadata, runs your Apex tests, checks for compile errors, and reports the results. If something fails, the developer knows within minutes — not days.
Continuous Deployment
Continuous Deployment takes it a step further. Once your code passes all the automated checks, it is automatically deployed to a target environment. In traditional software development, this might mean deploying to a staging server or even production. In Salesforce, it typically means deploying validated metadata to a sandbox, a scratch org, or — if your pipeline is mature enough — directly to production.
The “continuous” part is the key word. You are not batching up changes and deploying once a week. Every change that passes validation gets deployed automatically. This reduces the risk of large, complex deployments and gives your team faster feedback.
The CI/CD Cycle
Here is how a typical CI/CD cycle works for Salesforce:
- A developer creates a feature branch in Git and writes Apex code, LWC components, or declarative metadata changes.
- The developer pushes the branch to the remote repository and opens a pull request.
- The CI pipeline is triggered automatically. It authenticates with a Salesforce org, deploys the metadata, and runs all Apex tests.
- If the tests pass, the pull request gets a green check mark. If they fail, the developer sees the errors directly in the pull request.
- Once the pull request is reviewed and merged, the CD pipeline kicks in and deploys the validated code to the target environment.
This cycle repeats for every change, every developer, every day. The result is a codebase that is always in a deployable state.
Important Things to Include in a CI/CD Pipeline
Not all pipelines are created equal. A bare-minimum pipeline might just deploy code and check for compile errors. A production-grade pipeline includes much more. Here are the things you should consider including in your Salesforce CI/CD pipeline.
Source Validation
Before running any tests, your pipeline should validate that the source can be successfully deployed. This catches syntax errors in Apex, malformed XML in metadata files, and missing dependencies. Use the sf project deploy start command with the --dry-run flag (or --check-only in older CLI versions) to validate without actually persisting the deployment.
sf project deploy start --source-dir force-app --target-org my-org --dry-run
Apex Test Execution
Running Apex tests is non-negotiable. Your pipeline should execute all local tests (at minimum) and report the results. For production deployments, Salesforce requires 75% code coverage, but you should aim higher. A good pipeline runs tests at multiple levels:
- Local tests — Tests defined in your project, excluding managed package tests.
- Specific test classes — If you want to run only tests relevant to the changed code.
- All tests — For production validation runs.
sf project deploy start --source-dir force-app --target-org my-org --test-level RunLocalTests
Code Coverage Reporting
Running tests is only useful if you actually look at the results. Your pipeline should extract code coverage numbers and either display them in the build output or post them as a comment on the pull request. The Salesforce CLI can output test results in JSON format, which you can parse in your pipeline scripts.
sf project deploy start --source-dir force-app --target-org my-org \
--test-level RunLocalTests --results-dir test-results --coverage-formatters json
Static Code Analysis
Static analysis tools scan your code for common issues without executing it. For Salesforce, the most popular tool is PMD with Salesforce-specific rules (often called the Apex PMD ruleset). It catches things like empty catch blocks, hardcoded IDs, SOQL inside loops, and unused variables.
pmd check --dir force-app --rulesets apex-ruleset.xml --format csv
You can also use Salesforce Code Analyzer (formerly Salesforce Scanner), which wraps PMD along with other engines:
sf scanner run --target force-app --format csv --outfile scanner-results.csv
Scratch Org Provisioning
For a truly isolated test, your pipeline should create a fresh scratch org, push or deploy the source to it, and run tests there. This guarantees that your code works from a clean slate without relying on existing data or configuration in a persistent sandbox.
sf org create scratch --definition-file config/project-scratch-def.json \
--alias pipeline-scratch --duration-days 1 --set-default
Package Version Creation
If you are using Unlocked Packages (as we covered in Part 69), your pipeline should create a new package version and optionally install it in a target org. This validates that your metadata is packageable and that the package installs cleanly.
sf package version create --package "My Package" --installation-key-bypass \
--wait 20 --code-coverage
Notification and Reporting
When the pipeline finishes, your team needs to know the result. At a minimum, the CI status should appear on the pull request in GitHub (or your Git provider). Many teams also send notifications to Slack, Microsoft Teams, or email when a build fails.
Environment Cleanup
If your pipeline creates scratch orgs, it should also delete them when the build finishes. Scratch orgs count against your org’s limits, and leaving them around wastes resources.
sf org delete scratch --target-org pipeline-scratch --no-prompt
Popular Salesforce CI/CD Tools to Consider
There are several tools and platforms that Salesforce teams use for CI/CD. Here is a rundown of the most common ones.
GitHub Actions
GitHub Actions is a CI/CD platform built directly into GitHub. You define your pipeline as a YAML file in your repository, and GitHub runs it on their hosted runners whenever you push code or open a pull request. It is free for public repositories and includes a generous free tier for private repositories. We will use GitHub Actions for the hands-on project later in this post.
Salesforce DevOps Center
Salesforce DevOps Center is Salesforce’s own tool for managing change tracking and deployments. It provides a visual interface for tracking changes across environments and integrates with GitHub for source control. It is a good option for teams that want a point-and-click experience, but it is less flexible than a custom pipeline for advanced workflows.
Gearset
Gearset is a third-party tool built specifically for Salesforce CI/CD. It offers a visual UI for comparing orgs, deploying metadata, and running automated tests. Gearset handles many of the complexities of Salesforce deployments (like dependency ordering and destructive changes) that you would otherwise have to script yourself. It is a paid tool, but many teams find it worth the cost for the time it saves.
Copado
Copado is another Salesforce-native DevOps platform. It runs inside your Salesforce org and provides release management, CI/CD pipelines, and environment management. Copado is popular with larger enterprises that want a managed solution with built-in compliance and governance features.
Jenkins
Jenkins is an open-source automation server that has been around for decades. It is highly configurable and can run Salesforce pipelines using the Salesforce CLI. The trade-off is that Jenkins requires you to host and maintain your own server, and configuration can be complex. If your organization already uses Jenkins for other projects, it makes sense to extend it for Salesforce.
GitLab CI/CD
Similar to GitHub Actions, GitLab CI/CD lets you define pipelines in a YAML file within your repository. If your team uses GitLab instead of GitHub, this is the natural choice. The syntax is slightly different from GitHub Actions, but the concepts are the same.
Azure DevOps
Azure DevOps (formerly VSTS) includes Azure Pipelines, which can run Salesforce CI/CD workflows. It integrates well with Microsoft’s ecosystem and is a strong choice for organizations that are already invested in Azure.
An Introduction to YAML
Before we build our pipeline, you need to understand YAML, because that is the language used to define GitHub Actions workflows (and most other CI/CD tools). YAML stands for “YAML Ain’t Markup Language,” and it is a human-readable data serialization format.
Basic Syntax
YAML uses indentation (spaces, not tabs) to represent structure. Here is a simple example:
name: My Pipeline
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Say hello
run: echo "Hello from the pipeline"
Let us break down the key concepts.
Key-Value Pairs
The most basic YAML structure is a key-value pair, separated by a colon and a space:
name: My Pipeline
version: 1.0
enabled: true
Lists
Lists are represented by dashes. Each item starts with - (dash followed by a space):
fruits:
- apple
- banana
- orange
Nested Objects
You nest objects by increasing the indentation level (typically two spaces):
database:
host: localhost
port: 5432
credentials:
username: admin
password: secret
Multi-line Strings
YAML supports multi-line strings using the pipe | character (preserves newlines) or the greater-than > character (folds newlines into spaces):
description: |
This is a multi-line
description that preserves
each line break.
summary: >
This is a long sentence
that gets folded into
a single line.
Environment Variables and Expressions
In GitHub Actions YAML, you can reference environment variables and use expressions:
env:
SF_ORG_ALIAS: my-scratch-org
steps:
- name: Deploy
run: sf project deploy start --target-org ${{ env.SF_ORG_ALIAS }}
Common YAML Mistakes
A few things that trip people up:
- Tabs vs. spaces — YAML only allows spaces for indentation. A single tab will cause a parse error.
- Inconsistent indentation — Once you pick an indentation level (two spaces is standard), stick with it throughout the file.
- Missing space after colon —
key:valueis not valid. It must bekey: valuewith a space. - Special characters in strings — If your value contains colons, curly braces, or other special characters, wrap it in quotes:
message: "Error: something went wrong".
An Introduction to Shell Scripting
Your CI/CD pipeline will run shell commands — the same Bash commands you use in your terminal. Understanding shell scripting basics will help you write more powerful pipeline steps.
Variables
Shell variables are assigned without spaces around the equals sign:
ORG_ALIAS="pipeline-scratch"
PACKAGE_NAME="My Package"
echo "Creating scratch org: $ORG_ALIAS"
Conditionals
Use if statements to branch your logic:
if [ "$TEST_RESULT" -eq 0 ]; then
echo "All tests passed"
else
echo "Tests failed with exit code $TEST_RESULT"
exit 1
fi
Command Substitution
Capture the output of a command into a variable:
PACKAGE_VERSION_ID=$(sf package version create \
--package "My Package" \
--installation-key-bypass \
--wait 20 \
--json | jq -r '.result.SubscriberPackageVersionId')
echo "Created package version: $PACKAGE_VERSION_ID"
Exit Codes
Every command returns an exit code. Zero means success, and anything else means failure. In a CI/CD pipeline, a non-zero exit code typically causes the pipeline to fail and stop. You can check exit codes explicitly:
sf project deploy start --source-dir force-app --target-org my-org
DEPLOY_STATUS=$?
if [ "$DEPLOY_STATUS" -ne 0 ]; then
echo "Deployment failed"
exit 1
fi
Piping and Redirection
Chain commands together with pipes and redirect output to files:
# Pipe JSON output through jq to extract a value
sf org display --target-org my-org --json | jq -r '.result.instanceUrl'
# Redirect test results to a file
sf apex run test --target-org my-org --result-format json > test-results.json
The set Command
Two flags you will see in CI/CD scripts:
set -e # Exit immediately if any command fails
set -o pipefail # Catch errors in piped commands
These are important in pipelines because without them, a failing command might be silently ignored, and the pipeline would continue as if nothing went wrong.
How to Set Up a Custom CI/CD Pipeline Using GitHub Actions
Now let us put everything together. We will build a GitHub Actions workflow that does the following:
- Triggers on pull requests to the
mainbranch. - Installs the Salesforce CLI.
- Authenticates with a Salesforce Dev Hub.
- Creates a scratch org.
- Deploys the source code.
- Runs all Apex tests.
- Reports the results.
- Cleans up the scratch org.
Step 1: Repository Structure
Your Salesforce DX project should already have this structure from our earlier posts:
my-salesforce-project/
force-app/
main/
default/
classes/
triggers/
lwc/
objects/
config/
project-scratch-def.json
sfdx-project.json
GitHub Actions workflows live in a .github/workflows/ directory at the root of your repository.
Step 2: Set Up Authentication
GitHub Actions needs to authenticate with your Salesforce Dev Hub. The recommended approach is to use a JWT Bearer Flow with a connected app. Here is what you need:
- Create a Connected App in your Dev Hub org. Enable OAuth settings, select the “Use digital signatures” option, and upload the public key from a self-signed certificate.
- Generate a server key and certificate:
openssl genrsa -out server.key 2048
openssl req -new -x509 -key server.key -out server.crt -days 365
- Store the private key as a GitHub Secret. Go to your repository settings, navigate to Secrets and Variables, then Actions, and create a new secret called
SF_JWT_KEYwith the contents ofserver.key. - Store your Dev Hub credentials. Create additional secrets:
SF_CLIENT_ID— The Consumer Key from your Connected App.SF_USERNAME— The username of your Dev Hub admin user.
Step 3: Create the Workflow File
Create the file .github/workflows/salesforce-ci.yml:
name: Salesforce CI
on:
pull_request:
branches:
- main
paths:
- "force-app/**"
- "config/**"
- "sfdx-project.json"
jobs:
validate:
name: Validate and Test
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Install Salesforce CLI
run: |
npm install -g @salesforce/cli
sf --version
- name: Authenticate Dev Hub
run: |
echo "${{ secrets.SF_JWT_KEY }}" > server.key
sf org login jwt \
--client-id ${{ secrets.SF_CLIENT_ID }} \
--jwt-key-file server.key \
--username ${{ secrets.SF_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: Deploy Source to Scratch Org
run: |
sf project deploy start \
--source-dir force-app \
--target-org ci-scratch
- name: Run Apex Tests
run: |
sf apex run test \
--target-org ci-scratch \
--code-coverage \
--result-format human \
--test-level RunLocalTests \
--wait 20
- name: Delete Scratch Org
if: always()
run: |
sf org delete scratch \
--target-org ci-scratch \
--no-prompt
Let us walk through each section of this workflow.
Understanding the Workflow
The on block defines when the pipeline triggers. We trigger on pull requests to the main branch, but only when files in force-app/, config/, or sfdx-project.json change. This prevents the pipeline from running when you edit a README or other non-Salesforce files.
The runs-on field specifies the runner. ubuntu-latest gives you a fresh Linux virtual machine for each run. GitHub provides these for free (with usage limits).
The Checkout step pulls your repository code onto the runner. Without this, the runner has nothing to work with.
The Install Salesforce CLI step installs the sf CLI globally using npm. The runner starts with Node.js pre-installed, so this works out of the box.
The Authenticate Dev Hub step writes the JWT private key to a temporary file, authenticates using the JWT bearer flow, and then deletes the key file. The secrets are injected by GitHub and never appear in the logs.
The Create Scratch Org step provisions a fresh scratch org using the scratch org definition file from your project. The --duration-days 1 flag ensures the org expires quickly, since we only need it for this pipeline run.
The Deploy Source step pushes all the metadata from force-app/ to the scratch org. If there are compile errors or missing dependencies, this step fails.
The Run Apex Tests step executes all local Apex tests and reports code coverage. The --wait 20 flag tells the CLI to poll for up to 20 minutes while tests run.
The Delete Scratch Org step uses if: always() to ensure it runs even if previous steps failed. This prevents orphaned scratch orgs from piling up.
PROJECT: Set Up a CI/CD Pipeline to Generate a Scratch Org and Deploy Your Unlocked Packages to It
Now let us build a more complete pipeline that not only deploys source code but also creates an unlocked package version and installs it in a scratch org. This is the pattern you would use for a real production workflow.
Prerequisites
Before you begin, make sure you have:
- A Salesforce Dev Hub org with scratch org and unlocked package features enabled.
- A Salesforce DX project with an unlocked package already defined in
sfdx-project.json(we did this in Part 69). - A GitHub repository with the connected app and secrets configured as described above.
Step 1: Update Your Project Configuration
Your sfdx-project.json should look something like this:
{
"packageDirectories": [
{
"path": "force-app",
"default": true,
"package": "MyAppPackage",
"versionName": "ver 1.0",
"versionNumber": "1.0.0.NEXT"
}
],
"name": "my-salesforce-project",
"namespace": "",
"sfdcLoginUrl": "https://login.salesforce.com",
"sourceApiVersion": "62.0"
}
The NEXT keyword in the version number tells Salesforce to auto-increment the build number each time you create a new version.
Step 2: Create a Shell Script for the Pipeline
Complex pipelines benefit from moving logic into shell scripts rather than putting everything inline in the YAML file. Create a scripts/ directory and add a file called ci-pipeline.sh:
#!/bin/bash
set -e
set -o pipefail
echo "=== Starting CI Pipeline ==="
# Step 1: Create scratch org
echo "--- Creating scratch org ---"
sf org create scratch \
--definition-file config/project-scratch-def.json \
--alias ci-scratch \
--set-default \
--duration-days 1 \
--wait 10
echo "Scratch org created successfully."
# Step 2: Create a new package version
echo "--- Creating package version ---"
PACKAGE_CREATE_OUTPUT=$(sf package version create \
--package "MyAppPackage" \
--installation-key-bypass \
--wait 30 \
--code-coverage \
--json)
PACKAGE_VERSION_ID=$(echo "$PACKAGE_CREATE_OUTPUT" | jq -r '.result.SubscriberPackageVersionId')
if [ -z "$PACKAGE_VERSION_ID" ] || [ "$PACKAGE_VERSION_ID" = "null" ]; then
echo "ERROR: Failed to create package version"
echo "$PACKAGE_CREATE_OUTPUT" | jq '.'
exit 1
fi
echo "Package version created: $PACKAGE_VERSION_ID"
# Step 3: Install the package in the scratch org
echo "--- Installing package version ---"
sf package install \
--package "$PACKAGE_VERSION_ID" \
--target-org ci-scratch \
--installation-key-bypass \
--wait 15 \
--publish-wait 10
echo "Package installed successfully."
# Step 4: Run Apex tests in the scratch org
echo "--- Running Apex tests ---"
sf apex run test \
--target-org ci-scratch \
--code-coverage \
--result-format human \
--test-level RunLocalTests \
--wait 20
echo "All tests passed."
# Step 5: Extract and display code coverage
echo "--- Checking code coverage ---"
COVERAGE_OUTPUT=$(sf apex run test \
--target-org ci-scratch \
--code-coverage \
--result-format json \
--test-level RunLocalTests \
--wait 20)
COVERAGE_PERCENT=$(echo "$COVERAGE_OUTPUT" | jq -r '.result.summary.orgWideCoverage' | tr -d '%')
echo "Org-wide code coverage: ${COVERAGE_PERCENT}%"
if [ "$COVERAGE_PERCENT" -lt 75 ]; then
echo "ERROR: Code coverage is below 75%"
exit 1
fi
echo "=== CI Pipeline Complete ==="
Make the script executable:
chmod +x scripts/ci-pipeline.sh
Step 3: Create the Full Workflow File
Now create the GitHub Actions workflow at .github/workflows/salesforce-ci.yml:
name: Salesforce CI - Package Validation
on:
pull_request:
branches:
- main
paths:
- "force-app/**"
- "config/**"
- "sfdx-project.json"
- "scripts/**"
workflow_dispatch:
env:
SF_ORG_ALIAS: ci-scratch
DEVHUB_ALIAS: devhub
jobs:
build-and-test:
name: Build Package and Run Tests
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Install Salesforce CLI
run: |
npm install -g @salesforce/cli
sf --version
- name: Install jq
run: sudo apt-get install -y jq
- name: Authenticate Dev Hub
run: |
echo "${{ secrets.SF_JWT_KEY }}" > server.key
sf org login jwt \
--client-id ${{ secrets.SF_CLIENT_ID }} \
--jwt-key-file server.key \
--username ${{ secrets.SF_USERNAME }} \
--set-default-dev-hub \
--alias ${{ env.DEVHUB_ALIAS }}
rm server.key
- name: Run CI Pipeline
run: bash scripts/ci-pipeline.sh
- name: Delete Scratch Org
if: always()
run: |
sf org delete scratch \
--target-org ${{ env.SF_ORG_ALIAS }} \
--no-prompt || true
static-analysis:
name: Static Code Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Install Salesforce CLI
run: |
npm install -g @salesforce/cli
sf plugins install @salesforce/sfdx-scanner
- name: Run Salesforce Code Analyzer
run: |
sf scanner run \
--target "force-app" \
--format csv \
--outfile scanner-results.csv \
--severity-threshold 2
- name: Upload Scanner Results
if: always()
uses: actions/upload-artifact@v4
with:
name: scanner-results
path: scanner-results.csv
Step 4: Understanding the Complete Workflow
This workflow has two jobs that run in parallel:
build-and-test — This is the main job. It authenticates with the Dev Hub, then hands off to the shell script which creates a scratch org, builds a package version, installs it, and runs tests. The timeout-minutes: 30 ensures the job does not run forever if something hangs. The scratch org cleanup step uses || true at the end so that a failure to delete the org does not fail the entire pipeline.
static-analysis — This job runs Salesforce Code Analyzer independently. It does not need a scratch org or Dev Hub authentication because static analysis works on the source code directly. The --severity-threshold 2 flag causes the step to fail if any issues of severity 2 (high) or above are found. The results are uploaded as a build artifact so you can download and review them.
The workflow_dispatch trigger lets you run the pipeline manually from the GitHub Actions tab, which is useful for debugging.
Step 5: Test Your Pipeline
- Commit and push the workflow file and shell script to a new branch.
- Open a pull request against
main. - Watch the Actions tab in GitHub. You should see both jobs start running.
- If the pipeline fails, click into the failed step to see the logs. Common issues include authentication failures (check your secrets), scratch org limits (check your Dev Hub), and test failures (check your Apex code).
Step 6: Add Branch Protection
Once your pipeline is working, go to your repository settings and add a branch protection rule for main:
- Require status checks to pass before merging.
- Select both the “Build Package and Run Tests” and “Static Code Analysis” checks.
- Require branches to be up to date before merging.
This ensures that no code can be merged to main without passing the full CI pipeline. Every pull request must have green checks before the merge button becomes available.
Section Notes
- CI/CD stands for Continuous Integration and Continuous Deployment. CI automatically validates and tests your code on every push. CD automatically deploys validated code to a target environment.
- A production-grade Salesforce pipeline should include source validation, Apex test execution, code coverage reporting, static analysis, scratch org provisioning, and environment cleanup.
- Popular Salesforce CI/CD tools include GitHub Actions, Gearset, Copado, Jenkins, GitLab CI/CD, Azure DevOps, and Salesforce DevOps Center. GitHub Actions is a strong free option for teams already on GitHub.
- YAML is the language used to define GitHub Actions workflows. It is indentation-based, uses key-value pairs and lists, and is strict about spaces (no tabs allowed).
- Shell scripting fundamentals — variables, conditionals, exit codes, piping, and
set -e— are essential for writing robust pipeline scripts. - The JWT Bearer Flow is the recommended way to authenticate the Salesforce CLI in a CI/CD environment. Store your private key and credentials as GitHub Secrets — never commit them to your repository.
- Always use
if: always()on cleanup steps (like deleting scratch orgs) to ensure they run even when earlier steps fail. - Running two parallel jobs — one for build and test, one for static analysis — speeds up your pipeline by doing both checks simultaneously.
- Branch protection rules turn your CI pipeline into a gate that prevents untested code from reaching your main branch.
- The combination of the Salesforce CLI, scratch orgs, unlocked packages, and GitHub Actions gives you a fully automated, repeatable deployment pipeline that eliminates manual deployment errors and gives your team confidence in every release.