Salesforce Development Tutorials

Salesforce DevOps Tutorial: How to Build a CI/CD Pipeline using CircleCI and GitHub

16 min read

Subscribe on YouTube

Why Should You Care About CI/CD?

Let me paint you a picture and you tell me if it sounds familiar. You’re working in a ten year old org. Back in year one, life was good. You could build something and deploy it in an afternoon. You were a wizard. People loved you.

Then year two happened. Then year three. And now? Now you want to add one tiny little thing to the Contact trigger and it takes you four weeks. Four weeks! Because you’ve got to do regression testing by hand, and you’ve got to sift through 3,000 lines of trigger code trying to figure out what the hell it even does, and nobody trusts anything. Something that should take thirty minutes takes a month.

That’s the problem. You don’t have anything you can trust to automatically test your code, and you don’t have anything automatically reviewing your code. So everything becomes manual, and manual is a gigantic pain.

CI/CD (continuous integration and continuous deployment… or delivery, some people call it that, and honestly I’m not entirely sure what the difference is and I’m not going to pretend I’m some kind of guru about it) fixes this. And it is easily the most overlooked thing in the entire Salesforce ecosystem. So today we’re going to fix that, together.


Why CircleCI and GitHub?

First things first, nobody is paying me to suggest these products. As if they would, lol. I picked them for a few very boring but very practical reasons:

1. They’re both FedRAMP’d (or can be), so they work for basically anybody, including all my federal folks out there.
2. They’re relatively easy to set up and the documentation is actually good.
3. They’re cheap compared to the alternatives.

Now, if you can afford Copado or Gearset or Flosum, go for it, have fun, they’re wonderful tools. But they are very expensive, and some of them are nowhere near as configurable as just rolling your own thing like we’re about to do.

One more thing before we dive in: what I’m showing you here is a simple pipeline. It is the groundwork. It is not 100% of the way there for a real production implementation and you will almost certainly need to expand on it. But it’ll get you off the starting line, and getting off the starting line is the hardest part.


Stuff You’ll Need Installed First

Before we start, go grab these if you don’t have them. All the code for this post lives in this GitHub repo if you’d rather just go reverse engineer the whole thing yourself. I respect that. Your call.

1. Git. And look, if you don’t have Git installed, I’m sorry you haven’t been using it, but you need it. There are probably a hundred times I would have crashed and burned and everyone would have hated me if I hadn’t had a local Git repo to save me. You don’t even need GitHub to benefit from it.
2. A GitHub account.
3. A CircleCI account.
4. The Salesforce CLI. Get a little comfortable with it. It’s not that crazy and it’s useful for way more than pipelines.
5. OpenSSL. We’ll use this to generate our own free certs, which is great, because certs normally cost money.


Step 1: Get Your Code Into GitHub

If you’ve never worked with a repo based model for Salesforce development this might feel a little weird, but I promise it’s not that crazy. Create a new repository in GitHub, copy the URL it gives you, and then in your project folder run these:

Shell
git init
git add .
git commit -m "initial commit"
git remote add origin https://github.com/YourName/YourRepo.git
git push -u origin master

Quick breakdown of what just happened. git init creates your local repository. git add . stages every file you care about for the commit. git commit -m saves those staged changes to your local repo (the -m stands for message, and please write a useful one, you will thank yourself later, trust me). git remote add origin connects your local repo to the remote one sitting up on GitHub. And git push shoves your commits up there.

Want to double check the remote actually got hooked up? Run git remote show origin and it’ll list out exactly what repo you’re connected to.

Oh and heads up, these days GitHub calls the default branch main instead of master. Mine was set up back when it was master, so that’s what you’ll see throughout this post. Use whichever one your repo actually has.


This part is almost insultingly easy. Go to circleci.com, click “Go to app” in the top right, and log in with GitHub (or Bitbucket if that’s your thing). You’ll land on the Projects page and see every repo you own staring back at you. Find the one you want and click Set Up Project.

CircleCI will offer to commit a starter config file for you, which creates a .circleci folder with a config.yml inside it. You are going to delete basically everything in that file, but you do need that folder and that file to exist, so let it do its thing.

That’s it. That took all of fifteen seconds and now CircleCI is watching your repo.


Step 3: Generate Your Certificate

Here’s the deal. For CircleCI to log into your Salesforce org, it needs a Connected App, and that Connected App needs a certificate so Salesforce and CircleCI can verify each other. Basically we need a way to tell Salesforce “hey, CircleCI is a cool guy, let him touch your data.”

So we need to generate a cert. I did not write this script, I just modified it a bit to play nicer with Windows and PowerShell. Somebody else did the hard work here.

Code
echo "Generating certificates for use with CircleCI, press enter to continue"
read check1
openssl genrsa -des3 -passout pass:MommaJune -out server.pass.key 2048
openssl rsa -passin pass:MommaJune -in server.pass.key -out server.key
rm server.pass.key
echo "We will now generate the server key, when prompted for a password, press enter"
echo "press enter to continue"
read check2
openssl req -new -key server.key -out server.csr
echo "we will now generate the certificates, press enter to continue"
openssl x509 -req -sha256 -days 365 -in server.csr -signkey server.key -out server.crt
echo "the key will now be encoded in BASE64 and displayed, use the output for the value of SFDC_SERVER_KEY environment variable"
read check4
base64 server.key
echo "we will now clean up, keys will be deleted"
read check4
rm server.csr
rm server.key

Run it and it’ll walk you through a series of “press enter” prompts. It’ll ask you for a country name, a state, a city, an organization name. None of it is particularly important. Common name isn’t really important either. Don’t bother with a challenge password.

BUT DO NOT BLINDLY MASH ENTER AT THE END. Right after it base64 encodes your key, it’s going to dump that key out into the terminal. You need that. Copy the whole thing, paste it into a notepad somewhere, and save it. We’re going to use it in Step 5.

Once you’ve grabbed it, hit enter one more time and the script cleans up after itself, leaving you with a server.crt file. Hang onto that one, we need it right now.


Step 4: Create The Connected App In Salesforce

Log into your org and head to Setup > App Manager > New Connected App. Name it whatever you want, I called mine “CircleCI Demo.” Then:

1. Check Enable OAuth Settings. Very important.
2. For the callback URL, use http://localhost:1717/OauthRedirect or http://localhost:8080. If it’s not one of those two you’re probably going to see weird errors pop up.
3. Check Use digital signatures and upload that server.crt file we just made.
4. Add these OAuth scopes: Manage user data via APIs, Perform requests at any time, and Access unique user identifiers.

Hit save. Salesforce will tell you it takes 2 to 10 minutes for the Connected App to actually be usable, which is Salesforce’s way of saying “go get a coffee.”

While you wait, there’s one more thing. Go to Manage on your new Connected App, then Edit Policies, and change Permitted Users to Admin approved users are pre-authorized. Save it, then scroll down and give a profile or permission set access to it. I just gave System Administrator access to mine.

Last thing: go back into App Manager, find your app, and copy the Consumer Key. Save it next to that cert key in your notepad. We need both in about thirty seconds.


Step 5: Store Your Secrets As Environment Variables

Head back to CircleCI, go to your project, click Project Settings in the top right, and find Environment Variables. Add these three:

CIRCLE_TEST_SERVER_KEY – that base64 encoded cert key from Step 3.
CIRCLE_TEST_CLIENT_ID – the Consumer Key from your Connected App.
CIRCLE_TEST_USERNAME – the username of a user in your org that can connect through the Connected App. For me, that’s me.

Now, do you technically have to use environment variables? Nope! You could hardcode all of this straight into the shell scripts. So why bother?

Because if you hardcode them, they go into your GitHub repo, and now you have handed the entire internet everything it needs to completely demolish your org. That’s why.

And on that note, here is the single most important sentence in this entire post: put your server cert in your .gitignore file. Never, ever, ever let server.crt or server.key get pushed to a remote repository. Locally it’s fine. Remotely it’s a disaster waiting to happen.


Step 6: The Shell Scripts

Now, you can write your commands inline directly in the config.yml. But I think that’s messy and weird, and a lot of the time you end up reusing the same script across multiple jobs. So I keep them all in a build folder in the repo. Way cleaner in my opinion.

If you’re not comfortable with bash scripting, don’t panic. echo basically just means print. That’s honestly 80% of what’s happening in these.

First one, install.sh, which just globally installs the Salesforce CLI so our other steps can use it:

Apex
echo "Installing Salesforce CLI"
sudo npm install -global sfdx-cli

Next, create-scratch-org.sh. This one’s a little denser, so let’s walk it:

Code
echo "Setting up DevHub Connection..."
mkdir keys
echo $CIRCLE_TEST_SERVER_KEY | base64 -di > keys/server.key

echo "Authenticating..."
sfdx force:auth:jwt:grant --clientid $CIRCLE_TEST_CLIENT_ID --jwtkeyfile keys/server.key --username $CIRCLE_TEST_USERNAME --setdefaultdevhubusername -a DevHub

echo "Creating the Scratch Org..."
sfdx force:org:create -f config/project-scratch-def.json -a ${CIRCLE_BRANCH} -s

So what’s goin on here? We make a folder called keys, then take that environment variable holding our cert key, decode it out of base64, and drop it into keys/server.key inside our little temporary machine. Then we authenticate to Salesforce using the JWT grant flow with our Connected App’s client id, that key file, and our username, and we alias that connection as DevHub. Remember that alias, it comes back later. Then we spin up a scratch org named after whatever branch we’re building.

Now test.sh, which is where the actual value is:

Shell
sfdx force:source:push -u ${CIRCLE_BRANCH}
sfdx force:apex:test:run --testlevel RunLocalTests --outputdir test-results --resultformat tap --targetusername ${CIRCLE_BRANCH}

Push our source up to that scratch org, then run all the local tests against it, dumping the results into a folder called test-results in TAP format. Remember that folder name, the config.yml is going to look for it.

Then the two PMD scripts, installpmd.sh and runpmd.sh, which handle our automated code review:

Code
echo "Installing PMD"
npm install node-jre
npm install pmd-bin
Code
echo "Running PMD"
echo $PWD
npx pmd -d . -f csv -R $RULESET -r analysis.csv

PMD is a static code analyzer, and it’s what’s under the hood of the SFDX Scanner plugin I covered in another post. It reads through your Apex looking for violations of a rule set and spits out a report. That $RULESET variable points at your rules file (there’s an apexrules.xml in the repo), and the results land in analysis.csv, which is another name the config.yml is going to care about.

And finally, deploy-prod.sh, the one that actually ships:

Code
echo "Converting to MDAPI format..."
sfdx force:source:convert -d deploy_prod -r force-app

echo "Deploying to production & running all tests..."
sfdx force:mdapi:deploy -u DevHub -d deploy_prod/ -w -1 -l RunAllTestsInOrg

First we convert our source out of SFDX format and into the metadata format the Metadata API actually understands, reading from force-app and writing into a new deploy_prod folder. Then we deploy it, logging in as that DevHub alias we set up earlier. That -w -1 means “wait as long as it takes,” and -l RunAllTestsInOrg is our test level.

Quick note for anyone reading this in the future: these are the older sfdx force: style commands. The Salesforce CLI has since moved to the sf command structure, so if you’re building this fresh today you’ll want to translate them. The concepts are all identical though, only the words changed.


Step 7: The config.yml

Alright, the big one. If you’ve never seen YAML before and it makes you nervous, just think of it as another way to represent XML or JSON. That’s genuinely all it is. There isn’t a lot to it, it’s just confusing the first time you stare at it, exactly like XML and JSON were.

Here’s the whole file, then we’ll break it down:

Code
version: 2.1
jobs:
  run-tests:
    docker:
      - image: circleci/node:latest
    steps:
      - checkout
      - restore_cache:
          keys:
            - dependency-cache-{{ checksum "package.json" }}
            - dependency-cache-
      - run:
          name: Install Dependencies
          command: . build/install.sh
      - save_cache:
          key: dependency-cache-{{ checksum "package.json" }}
          paths:
            - node_modules
      - run:
          name: Create Scratch Org
          command: . build/create-scratch-org.sh
      - run:
          name: Validate Components & Run Tests
          command: . build/test.sh
      - store_test_results:
          path: test-results

  code-review:
    docker:
      - image: circleci/node:latest
    steps:
      - checkout
      - restore_cache:
          keys:
            - dependency-cache-{{ checksum "package.json" }}
            - dependency-cache-
      - run:
          name: Install PMD
          command: . build/installpmd.sh
      - save_cache:
          key: dependency-cache-{{ checksum "package.json" }}
          paths:
            - node_modules
      - run:
          name: Scan /src folder with PMD
          command: . build/runpmd.sh
      - store_artifacts:
          path: analysis.csv

  deploy-prod:
    docker:
      - image: circleci/node:latest
    steps:
      - checkout
      - run:
          name: Install Dependencies
          command: . build/install.sh
      - run:
          name: Login to Production
          command: . build/setup-prod.sh
      - run:
          name: Deploy to Production
          command: . build/deploy-prod.sh

workflows:
  version: 2
  validate:
    jobs:
      - code-review:
          requires:
            - run-tests
          filters:
            branches:
              ignore:
                - master
      - run-tests:
          filters:
            branches:
              ignore:
                - master
      - deploy-prod:
          filters:
            branches:
              only:
                - master

Let’s take it from the top.

version declares which version of CircleCI you’re on. It has to be formatted exactly like that, version: then a space then the number. YAML is picky about this stuff.

jobs are the most important thing in this file. You can name a job literally anything you want. Mine is called run-tests but it could just as easily be cool test bro or im-the-test-master or your-moms-a-test. It doesn’t matter. What matters is that you reference that exact name down in workflows later.

docker tells CircleCI what image to spin up for that job. If you’ve never heard of Docker, here’s the simplest explanation I’ve got: Docker spins up a tiny little virtual machine for a very brief moment in time with the stuff you need pre-installed on it. That’s it. So what we’re saying here is “hey CircleCI, when you run this job, spin up a little machine that already has the latest Node on it.” Nothing more complicated than that. That image isn’t special or something I built, CircleCI just provides it.

steps are the things that actually happen. checkout pulls down your branch. restore_cache and save_cache are optional and just speed things up a bit by not re-downloading all your package.json dependencies every single run. If that confuses you, ignore it, you don’t need it. Then each run block just fires one of our shell scripts.

Notice store_test_results points at test-results, and store_artifacts points at analysis.csv. Those are the exact folder and file names our shell scripts wrote to. That’s how the results end up as clickable things in the CircleCI UI.

One really important gotcha: each job runs in its own Docker container and knows absolutely nothing about the other jobs. Nothing. That’s why code-review and deploy-prod both have to check out the repo and install their own dependencies all over again. They’re each living in their own little world for their own brief lifespan.


Workflows: Deciding What Runs And When

Workflows are where this gets fun. A workflow decides which jobs run, when they run, and what has to succeed first.

Look at the requires keyword under code-review. That’s saying “only run the code review job if run-tests finished successfully.” That’s the whole magic trick. You chain jobs together, and one failure stops the whole train.

Then there are filters. The run-tests and code-review jobs both ignore the master branch, and deploy-prod only runs on master. So in plain English: when you push to a feature branch, you get tested and code reviewed. When something lands on master, it deploys. Pretty kewl.

And this is just the tip of the iceberg. Once you’re comfortable, you can chain this out as far as you want. Commit to a feature branch, and if it passes everything, automatically deploy to integration. If integration passes, deploy to QA. If QA passes, deploy to UAT. If UAT passes, go on ahead and ship to production. It’ll save you an enormous amount of time.


Watching The Whole Thing Actually Run

So let’s see it go. Make any tiny change, commit it, push it, and flip over to your CircleCI dashboard.

You’ll watch run-tests kick off first. It spins up the Node container, checks out your code from GitHub, restores the node_modules cache, and starts installing the Salesforce CLI (that install can take up to about a minute every run, so be patient). Then it authenticates into the org, which takes all of a couple seconds, pushes your source, and runs your Apex tests. Head over to the Tests tab and there they are. If any had failed, you’d see the exact test methods that blew up.

Because run-tests passed, code-review is now allowed to go. Same deal, brand new container, installs everything again, then runs PMD across your code.

Mine failed, and honestly that’s great, because it lets me show you the good part. Click the Artifacts tab, grab that analysis.csv, and open it up. It’s a full list of your code violations. Missing ApexDoc comments, unvalidated CRUD permissions, empty block statements, blah blah blah. It even links you to the specific PMD rules it ran to find them.

And here’s the important part: because code-review failed, deploy-prod never ran. The bad code did not reach production. That’s the entire point of this whole exercise, right there in one sentence.


So Was That Worth It?

Think about what you just built. From now on, with a single commit, you automatically find out two things: did my change break anything anybody didn’t anticipate, and does my code follow the standards we agreed on? Instantly. Without you doing a thing.

And you can keep bolting stuff onto this. Got Jest tests for your LWCs? Add them. Got Selenium or some other UI testing suite? Add it. Every one of those can run on every single commit, and each one has to pass before the next step is even allowed to start.

Now I want to be straight with you, because most tutorials aren’t: the pipeline is the easy part. The hard part is writing test classes good enough that you actually trust them, building a branching strategy that fits your team, and then maintaining all of it going forward. That’s real work and it’s ongoing.

But look at what you just did. Setting up the pipeline itself wasn’t crazy hard, was it? Confusing in places, sure. Not hard. And when you’ve got a pipeline and a test suite that everybody trusts, you code more, you code faster, and you get more to your users quicker.

Pretty important stuff. I made this one because CI/CD is so undervalued in this ecosystem and I got tired of every tutorial skimming over all the little details that took me forever to figure out. Hopefully this one didn’t. If you’ve got questions, let me know!


Get Coding With The Force Merch!!

We now have a redbubble store setup so you can buy cool Coding With The Force merchandise! Please check it out! Every purchase goes to supporting the blog and YouTube channel.

Get Shirts Here!
Get Cups, Artwork, Coffee Cups, Bags, Masks and more here!


Check Out More Coding With The Force Stuff!

If you liked this post make sure to follow us on all our social media outlets to stay as up to date as possible with everything!

Youtube
Patreon
Github
Facebook
Twitter
Instagram


Salesforce Development Books I Recommend

Advanced Apex Programming
Salesforce Lightning Platform Enterprise Architecture
Mastering Salesforce DevOps

Good Non-SF Specific Development Books:

Clean Code
Clean Architecture