Continuous integration
Lint, test, build, scan, and a plan on every pull request.
Part 1: The problem
Sam changed how the delivery fee was calculated. The tests passed on his laptop. The pull request looked small, Noor approved it, and he merged it on a Wednesday afternoon.
On Thursday morning, three other engineers pulled main and nothing worked. Sam’s laptop had an old copy of a file the tests depended on; on a clean checkout, two tests failed. Every branch built on top of Wednesday’s main was now broken too. It took until lunch to find which change had done it.
Later that month, a scan by the security team found a critical vulnerability in the base image of the orders service. It had been in production for six weeks. Nobody had checked.
Part 2: The idea
Continuous integration, or CI, means every change is checked automatically, the same way, before it joins main. Not on someone’s laptop, but on a clean agent, from the exact commit that will be merged.
What runs, and in what order
QuickCart’s pipeline has five gates. They are ordered cheapest first, so the most common mistakes are reported in seconds.
| Stage | Checks | Takes about | Tool |
|---|---|---|---|
| Lint | The Dockerfile follows good practice; the Terraform is formatted | Seconds | hadolint, terraform fmt -check |
| Unit tests | The code does what the tests say it should | A minute | node --test here; whatever your language uses |
| Build image | The image builds from this commit | A couple of minutes | docker build |
| Scan image | No known critical vulnerabilities in the image’s packages | Under a minute | trivy, or ECR’s own scanning |
| Terraform plan | What this change would do to AWS. Pull requests only | Under a minute | terraform plan, posted as a comment |
Fail fast means three things. Put quick checks first. Stop at the first failure; declarative pipelines do this for you. And when someone pushes again to the same pull request, abort the build that’s still running for the old commit, because its answer no longer matters. disableConcurrentBuilds(abortPrevious: true) does that last one.
One job per branch: multibranch pipelines
A multibranch pipeline is a Jenkins job that looks at a repository and makes a job for every branch and every pull request that has a Jenkinsfile. Each one builds with the Jenkinsfile from its own branch. New branch, new job; branch deleted, job removed.
| Jenkins sees | It builds | BRANCH_NAME |
|---|---|---|
The main branch | Every commit on main | main |
| A pull request, number 42 | The pull request merged with the current main, so you test what main would become | PR-42 |
| A branch with an open pull request | Nothing extra. By default, the pull request job covers it | — |
The GitHub Branch Source plugin, part of the suggested plugins, does this for GitHub. It also reports each build’s result back to GitHub as a status check on the commit. For a pull request, the check is called continuous-integration/jenkins/pr-merge.
How Jenkins hears about a push: webhooks
Jenkins can ask GitHub every few minutes whether anything changed. That’s called polling, and it’s slow and wasteful. A webhook is the other way round: the moment someone pushes, GitHub sends a message to Jenkins.
For QuickCart’s Jenkins on EC2, that means GitHub must be able to reach Jenkins. So the controller gets a load balancer with HTTPS in front of it, whose security group only accepts connections from GitHub’s webhook address ranges, which GitHub publishes. Then, in the repository on GitHub:
Settings → Webhooks → Add webhook
| Field | Enter |
|---|---|
| Payload URL | https://jenkins.example.com/github-webhook/ |
| Content type | application/json |
| Secret | a long random value, also entered in Jenkins’ GitHub settings in Manage Jenkins → System |
| Which events | Let me select individual events: Pushes and Pull requests |
The /github-webhook/ path is where the GitHub plugin in Jenkins listens. The secret lets Jenkins check that each message really came from GitHub.
In this lesson’s lab, Jenkins runs on your computer, which GitHub can’t reach. So you’ll use a periodic scan instead: Jenkins checks GitHub every minute. It works the same, just a minute slower.
The gate must be required
A red check does nothing by itself. In GitHub, a ruleset on main makes it binding:
| Rule | What it does |
|---|---|
| Require a pull request before merging | Nobody pushes straight to main |
| Require status checks to pass | The merge button stays grey until the Jenkins check is green |
Terraform plan on every pull request
For infrastructure changes, the most useful thing CI can do is run terraform plan and post the result on the pull request. Reviewers then read what will actually change in AWS, not what the author thinks will change. In a real setup, the plan stage uses a read-only AWS role and the shared S3 state from Terraform state. Nothing is applied until after the merge.
Caching: faster without being wrong
| Cache | How | Saves |
|---|---|---|
| Docker layers | A long-lived agent keeps layers between builds. On short-lived agents, pull the last image and pass --cache-from | Rebuilding unchanged layers. Writing a Dockerfile shows why order matters |
| Dependencies | Keep the package manager’s download folder, like ~/.npm, on the agent between builds | Downloading every library on every build |
| Terraform providers | Set TF_PLUGIN_CACHE_DIR to a folder on the agent | Downloading the AWS provider, which is large, on every init |
| Trivy’s database | It’s kept in ~/.cache/trivy and refreshed when it’s old | Downloading the vulnerability list every time |
Cache things that are downloaded, never things that are checked. A cached test result is a test that didn’t run.
Shared libraries: write it once
With ten services, ten Jenkinsfiles grow the same lint stage, each slightly different. A shared library is a git repository of pipeline code that every Jenkinsfile can load. Every Groovy file in its vars/ folder becomes a step with the file’s name.
def call(Map config = [:]) {
String dockerfile = config.get('dockerfile', 'Dockerfile')
String terraformDir = config.get('terraformDir', 'infra')
sh "hadolint '${dockerfile}'"
sh "terraform fmt -check -recursive '${terraformDir}'"
}| Line | What it means |
|---|---|
def call(Map config = [:]) | What runs when a Jenkinsfile writes quickcartLint(). It takes named settings, and none are required |
config.get('dockerfile', 'Dockerfile') | Use the dockerfile setting if one was given, otherwise Dockerfile |
sh "hadolint '${dockerfile}'" | Double quotes, because Groovy should fill in the file name. It’s not a secret, so that’s safe |
Register the library once, in Manage Jenkins → System → Global Trusted Pipeline Libraries: a name, quickcart-pipeline-lib, a default version, and the repository. Then any Jenkinsfile can use it:
@Library('quickcart-pipeline-lib@v1') _
pipeline {
agent any
stages {
stage('Lint') {
steps {
quickcartLint(terraformDir: 'terraform')
}
}
}
}| Line | What it means |
|---|---|
@Library('quickcart-pipeline-lib@v1') _ | Load the library, at the git tag v1. The _ is required Groovy punctuation when nothing else follows on that line |
quickcartLint(terraformDir: 'terraform') | Call the step, with this repository’s Terraform folder |
Pin a version, like the tag v1, so a change to the library can’t break every service’s build at once. Libraries registered here are trusted: their code runs with full access to Jenkins, so changes to the library repository need the same review as changes to Jenkins itself.
Flaky tests
A flaky test sometimes passes and sometimes fails, with no change to the code. Usually it depends on timing, on the order tests run in, or on something outside the test such as the network. Google has written publicly about how much time flaky tests cost it, even with its tooling. Flaky tests are dangerous because they teach people that red doesn’t mean anything.
| Response | Effect |
|---|---|
retry(3) around the tests | Hides it, and hides real failures with it. Don’t |
| Press Rebuild until green | The same, by hand |
| Quarantine: move it to a separate, non-blocking stage, with an owner and a deadline | The main suite stays trustworthy, and the flaky test is still tracked |
| Fix the cause: wait for a condition instead of sleeping, give each test its own data | The real answer |
Keep retry for things that genuinely fail at random and are safe to repeat, like downloading a file. Never for a test.
Part 3: See it
Below is the pipeline a QuickCart pull request goes through. Push a commit that has no problems. Then choose A failing test and push again, and try the others.
What to notice: a commit stops at the first gate it fails, and nothing after that gate runs. The cheaper the gate, the sooner you know. A Dockerfile mistake is reported 20 seconds after the push; a vulnerability, only after the image is built.
Part 4: Do it
Free — Jenkins and every tool run on your computer, and a public GitHub repository is free. No AWS.
You’ll put QuickCart’s orders service in a GitHub repository, point a multibranch pipeline at it, open a pull request that gets a Terraform plan posted on it, then break a test and watch the merge get blocked. About 60 minutes.
You need the Jenkins on your computer from The Jenkinsfile, a GitHub account, and Docker running.
Install the tools the pipeline uses
The pipeline runs on your computer, so the tools it calls must be installed there:
Terminalbrew install node hadolint trivy ghPart What it means brew installinstall with Homebrew noderuns the orders service and its tests hadolintchecks Dockerfiles for common mistakes trivyscans images for known vulnerabilities Terraform for the plan. You installed it from HashiCorp’s tap in Set up your computer; Homebrew’s plain terraformis an old versionghGitHub’s command-line tool, used to post the plan on the pull request Jenkins only finds tools that are on the
PATHof the terminal it was started from. So stop Jenkins with Ctrl+C in its terminal, and start it again in a new terminal window:Terminaljenkins-lts --httpListenAddress=127.0.0.1 --httpPort=8080Part What it means jenkins-ltsstart Jenkins, which now inherits a PATHthat includes the new tools--httpListenAddress=127.0.0.1only this computer can reach it --httpPort=8080the port for the web page Create the repository and a token
On GitHub, choose New repository:
Field Enter Repository name quickcart-ordersVisibility Public. Required-check rules are free on public repositories Add a README file ticked Choose Create repository. Copy its HTTPS address from the Code button; the next steps call it
REPO_URL.Jenkins needs a token to read the repository, post comments and report checks. On GitHub, go to your profile picture → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token:
Field Enter Token name jenkins-quickcartExpiration 30 days Repository access Only select repositories: quickcart-ordersPermissions → Contents Read-only Permissions → Pull requests Read and write Permissions → Commit statuses Read and write Choose Generate token and copy it. GitHub shows it only once.
Add the code
Clone the repository into your home folder and move into it:
Terminalgit clone REPO_URLPart What it means git clonecopy a repository from GitHub to your computer REPO_URLthe HTTPS address you copied Terminalcd quickcart-ordersPart What it means cd quickcart-ordersmove into the copy Create these files, in these folders. First the code, a tiny function that adds up an order:
src/total.mjsexport function orderTotal(items, deliveryFeeCents) { const subtotal = items.reduce((sum, item) => sum + item.priceCents * item.quantity, 0); return subtotal + deliveryFeeCents; }Its tests, using the test runner built into Node:
test/total.test.mjsimport { test } from "node:test"; import assert from "node:assert/strict"; import { orderTotal } from "../src/total.mjs"; const items = [ { priceCents: 450, quantity: 2 }, { priceCents: 100, quantity: 1 }, ]; test("orderTotal adds up the items", () => { assert.equal(orderTotal(items, 0), 1000); }); test("orderTotal adds the delivery fee", () => { assert.equal(orderTotal(items, 250), 1250); });A small web server around it, which the next lesson deploys:
server.mjsimport { createServer } from "node:http"; import { orderTotal } from "./src/total.mjs"; const version = process.env.APP_VERSION || "dev"; const server = createServer((req, res) => { res.setHeader("Content-Type", "application/json"); if (req.url === "/health") { res.end(JSON.stringify({ status: "ok", version })); return; } const example = orderTotal([{ priceCents: 450, quantity: 2 }], 250); res.end(JSON.stringify({ service: "orders", version, exampleTotalCents: example })); }); server.listen(8080, () => { console.log(`orders ${version} listening on port 8080`); });Its Dockerfile:
DockerfileFROM node:24-alpine WORKDIR /app COPY src ./src COPY server.mjs ./ ENV NODE_ENV=production USER 1000 EXPOSE 8080 CMD ["node", "server.mjs"]USER 1000runs the server as the image’s built-innodeuser, by number rather than name, so any system can resolve it. Writing a Dockerfile explains every other line.And a small piece of Terraform, in a folder called
infra. It usesterraform_data, a resource built into Terraform that stores values and creates nothing in AWS, so the plan runs without an AWS login:main.tfterraform { required_version = ">= 1.10.0" } variable "environment" { description = "Which environment this plan is for" type = string default = "dev" } resource "terraform_data" "orders_service" { input = { environment = var.environment desired_count = 2 cpu = 256 } }Setting What it does If you leave it out When you’d change it required_versionRefuse Terraform older than 1.10 Any version is accepted When you rely on newer features variable "environment"→descriptionA note for whoever sets it No note — type = stringOnly text is accepted Any type is accepted — default = "dev"The value if nobody sets one Terraform asks for it, and the pipeline has nobody to answer — terraform_data→inputValues stored in state, standing in for a real ECS service’s settings Nothing is stored This is what the pull request will change Before you push, run the same checks the pipeline will run:
Terminalnode --testPart What it means node --testfind files named like *.test.mjsand run the tests in themYou should see✔ orderTotal adds up the items (0.79ms) ✔ orderTotal adds the delivery fee (0.14ms) ℹ tests 2 ℹ suites 0 ℹ pass 2 ℹ fail 0Your times will differ.
Add the Jenkinsfile and push
In the root of the repository:
Jenkinsfilepipeline { agent any options { timeout(time: 20, unit: 'MINUTES') timestamps() disableConcurrentBuilds(abortPrevious: true) buildDiscarder(logRotator(numToKeepStr: '30')) } environment { TF_PLUGIN_CACHE_DIR = "${env.HOME}/.terraform.d/plugin-cache" } stages { stage('Lint') { steps { sh 'hadolint Dockerfile' sh 'terraform fmt -check -recursive infra' } } stage('Unit tests') { steps { sh 'node --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=test-results.xml' } post { always { junit 'test-results.xml' } } } stage('Build image') { steps { sh 'docker build -t "quickcart-orders:$GIT_COMMIT" .' } } stage('Scan image') { steps { sh 'trivy image --exit-code 1 --severity CRITICAL --ignore-unfixed --no-progress "quickcart-orders:$GIT_COMMIT"' } } stage('Terraform plan') { when { changeRequest() } steps { dir('infra') { sh 'mkdir -p "$TF_PLUGIN_CACHE_DIR"' sh 'terraform init -input=false -no-color' sh 'terraform plan -input=false -no-color -out=tfplan' sh 'terraform show -no-color tfplan > plan.txt' } withCredentials([usernamePassword(credentialsId: 'github-token', usernameVariable: 'GH_USER', passwordVariable: 'GH_TOKEN')]) { sh 'gh pr comment "$CHANGE_ID" --body-file infra/plan.txt' } } } } post { failure { echo "Build failed: ${env.BUILD_URL}" } cleanup { deleteDir() } } }Line What it means disableConcurrentBuilds(abortPrevious: true)A new push to the same branch aborts the build still running for the old commit TF_PLUGIN_CACHE_DIR = "${env.HOME}/…"Keep downloaded Terraform providers in one folder that every build reuses. Groovy fills in your home folder hadolint DockerfileLint the Dockerfile. Any finding fails the stage terraform fmt -check -recursive infraFail if any .tffile ininfraisn’t formatted.-checkreports without changing filesnode --test --test-reporter=spec --test-reporter-destination=stdoutRun the tests, printing readable results in the log --test-reporter=junit --test-reporter-destination=test-results.xmlAlso write the results as JUnit XML, the format Jenkins understands junit 'test-results.xml'inpost→alwaysRead the results even when tests failed, so the build page lists which ones docker build -t "quickcart-orders:$GIT_COMMIT" .Build the image, tagged with the commit being built. GIT_COMMITis set by Jenkins’ checkouttrivy imageScan the image’s operating system packages and libraries against a list of known vulnerabilities --exit-code 1 --severity CRITICALFail the stage only for critical findings. Start strict and narrow; widen it to HIGH once you’re clean --ignore-unfixedSkip vulnerabilities that have no fix yet, since there’s nothing you could upgrade to --no-progressNo progress bar, which only clutters a build log when { changeRequest() }The plan runs only for pull requests dir('infra') { }Run the steps inside from the infrafolderterraform plan … -out=tfplanWork out the plan and save it to a file terraform show -no-color tfplan > plan.txtTurn the saved plan into readable text. >writes that text intoplan.txtinstead of the logusernamePassword(credentialsId: 'github-token', …)Fetch the GitHub token you’ll store in Jenkins, into GH_TOKEN, whichghreadsgh pr comment "$CHANGE_ID" --body-file infra/plan.txtComment on the pull request whose number is in CHANGE_ID, with the plan as the textCommit and push it all to
main:Terminalgit add .Part What it means git add .include every new file in the next commit Terminalgit commit -m "Orders service with a CI pipeline"Part What it means git commit -m "…"record the files, with this message Terminalgit pushPart What it means git pushsend the commit to GitHub Create the multibranch pipeline
In Jenkins, choose New Item:
Field Enter Enter an item name quickcart-ordersType Multibranch Pipeline Choose OK. Under Branch Sources, choose Add source → GitHub. Next to Credentials, choose Add → Jenkins:
Field Enter Kind Username with password Username your GitHub username Password the token you generated ID github-tokenDescription GitHub token for quickcart-ordersChoose Add, then pick the new credential in the Credentials list and fill in the rest:
Field Enter Repository HTTPS URL your REPO_URLBehaviours Leave Discover branches and Discover pull requests from origin. Delete Discover pull requests from forks Build Configuration → Mode by Jenkinsfile, Script Path JenkinsfileScan Multibranch Pipeline Triggers Tick Periodically if not otherwise run, Interval 1 minute Choose Save.
You should seeThe Scan Repository Log finds the
mainbranch and its Jenkinsfile, and schedules a build. After a minute or two, the job shows amainjob with build #1. Its stages all pass except Terraform plan, which is skipped, becausemainisn’t a pull request. The first run is slow while Trivy downloads its vulnerability database.Open a pull request and get a plan
Make a branch:
Terminalgit switch -c more-orders-capacityPart What it means git switch -ccreate a branch and move to it more-orders-capacityits name In
infra/main.tf, changedesired_count = 2todesired_count = 3. Commit and push the branch:Terminalgit commit -am "Run three orders tasks"Part What it means git commitrecord a change -ainclude every file git already knows that you’ve changed -m "…"the message Terminalgit push -u origin more-orders-capacityPart What it means git pushsend it to GitHub -u origin more-orders-capacitycreate the branch on GitHub too, and remember the link On GitHub, choose Compare & pull request, then Create pull request.
You should seeWithin a minute or two, Jenkins shows a Pull Requests tab with
PR-1. On GitHub, the pull request gets a comment from your account containing the plan, and a check calledcontinuous-integration/jenkins/pr-mergethat turns green.The plan comment includes# terraform_data.orders_service will be created + resource "terraform_data" "orders_service" { + id = (known after apply) + input = { + cpu = 256 + desired_count = 3 + environment = "dev" } + output = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy.It says create because this lab’s Terraform has no shared state, so every plan starts from nothing. With the S3 state from Terraform state, the same change would show
~ update in-placeand only thedesired_countline.Make the check required
On GitHub, in the repository, go to Settings → Rules → Rulesets → New ruleset → New branch ruleset:
Field Enter Ruleset Name protect-mainEnforcement status Active Target branches Add target → Include default branch Require a pull request before merging ticked Require status checks to pass ticked. Add checks → continuous-integration/jenkins/pr-mergeChoose Create.
Break a test
On the same branch, in
src/total.mjs, change the last line of the function to forget the fee:src/total.mjsexport function orderTotal(items, deliveryFeeCents) { const subtotal = items.reduce((sum, item) => sum + item.priceCents * item.quantity, 0); return subtotal; }Terminalgit commit -am "Simplify the total"Part What it means git commit -am "…"record the change to a file git already knows, with this message Terminalgit pushPart What it means git pushsend it. The branch already knows where to go Build #2 of PR-1 includes✖ orderTotal adds the delivery fee AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: 1000 !== 1250 Stage "Build image" skipped due to earlier failure(s) Stage "Scan image" skipped due to earlier failure(s) Stage "Terraform plan" skipped due to earlier failure(s) Finished: FAILUREYou should seeOn GitHub, the check is red and the merge button says merging is blocked. On the build page in Jenkins, Test Result lists the one failing test by name, because
junitread the report.Put the
+ deliveryFeeCentsback, commit and push. The check goes green and the pull request can be merged. Merge it.Clean-up step: Clean up
Nothing here costs money. Keep the repository, the token and the
quickcart-ordersjob: Continuous delivery builds on them.When you’ve finished with them, delete the job in Jenkins (quickcart-orders → Delete Multibranch Pipeline), delete the token on GitHub (Settings → Developer settings → Fine-grained tokens → Delete), and free the disk space the test images use:
Terminaldocker image prune -aPart What it means docker image prunedelete images no container is using -aall unused images, not only untagged ones. Docker asks you to confirm
Part 5: Check yourself
A pull request's build fails at Lint. What happens to Unit tests, Build image and Scan image?
CI is red on a pull request, but someone merges it anyway. What was missing?
Why does QuickCart's pipeline run hadolint and terraform fmt before the unit tests and the image build?
A test fails about one run in twenty, for no clear reason. What should the team do?
Where does the code in a shared library's vars/quickcartLint.groovy get used?
Why are Terraform plans posted on pull requests, rather than applied?
Part 6: Cheat sheet
| Thing | Remember |
|---|---|
| CI | Every change checked the same way, on a clean agent, before it joins main |
| Order | Cheapest first: lint, tests, build, scan, plan |
| Fail fast | First failure stops the rest. disableConcurrentBuilds(abortPrevious: true) |
| Multibranch | A job per branch and pull request. PR builds test the merge with main |
| Webhook | https://jenkins.example.com/github-webhook/, with a secret. Polling is the slow fallback |
| Required check | GitHub ruleset on main: continuous-integration/jenkins/pr-merge must pass |
| Lint | hadolint Dockerfile, terraform fmt -check -recursive |
| Tests | Write JUnit XML; junit in post → always |
| Scan | trivy image --exit-code 1 --severity CRITICAL --ignore-unfixed |
| Plan on PR | Read-only role, shared state, gh pr comment "$CHANGE_ID" --body-file plan.txt |
| Cache | Downloads (layers, packages, providers). Never results |
| Shared library | vars/name.groovy → step name(). Load with @Library('lib@v1') _ |
| Flaky test | Quarantine with an owner. Never retry a test |