CI/CD Pipelines

Lesson 1 of 5

Continuous Integration

"Continuous Integration doesn't get rid of bugs, but it does make them dramatically easier to find and remove." — Martin Fowler

You can write flawless code alone. The trouble starts the moment there are two of you. You branch off main on Monday, your teammate branches off the same commit, and for a week you both build in happy isolation. Then you both try to merge on Friday — and discover you renamed the function they call, they changed the shape of the object you return, and a dependency bumped a major version under everyone's feet. The code was never wrong in isolation. It was wrong together, and nobody found out until the integration — the merge — finally happened. That painful Friday has a name: integration hell, and Continuous Integration is the practice invented to abolish it.

The idea is almost aggressively simple: don't let branches drift for a week. Merge small changes into the shared line frequently — many times a day — and have a machine automatically build and test every change the instant it arrives, so integration problems surface in minutes, while they're small, instead of on Friday when they've compounded. This chapter is about that practice and the tooling that runs it. By the end you'll be able to read and write a CI pipeline, reason about what belongs in one, compare the major tools (GitHub Actions, GitLab CI, and the enterprise workhorse Jenkins), and answer the interviewer's favorite follow-up: "okay — but what actually runs when you open a pull request?"

The big question

If every developer's code works on their own machine, why do we need a robot to build and test it again on every single push — and what exactly should that robot do?

What "continuous integration" actually means

Continuous Integration is a discipline first and a tool second. The discipline has two halves, and both matter:

  1. Integrate continuously. Every developer merges their work into the shared main line frequently — ideally at least daily, often several times a day — in small increments. This is the human half, and it's why CI is the natural partner of trunk-based development and the short-lived-branch workflows you met in Git Workflows (this Part). Long-lived feature branches are the enemy of CI: the longer a branch lives, the more the trunk drifts away from it, and the bigger the eventual merge conflict. Small, frequent merges keep everyone close to a moving target instead of chasing a distant one.

  2. Verify automatically. Every time a change is pushed — every commit, every pull request — an automated system checks out the code, builds it, and runs the tests, independently of any developer's laptop. This is the machine half. It answers one question, fast: did this change break the shared build?

The word that ties them together is fast feedback. CI's entire reason for existing is to shrink the time between "I wrote something wrong" and "I found out it's wrong" from days to minutes. A bug caught by a CI run two minutes after you push is trivial to fix — you still have the whole change in your head. The same bug caught in production three weeks later is an incident.

Where it came from The practice was named and popularized by the Extreme Programming (XP) community in the late 1990s — Kent Beck made it one of XP's core practices, and Martin Fowler's 2000 essay "Continuous Integration" became its canonical description. The motivating pain was real and specific: teams using long-lived branches spent days or weeks in "integration hell" at the end of a release, manually reconciling divergent code. The radical proposal was to integrate all the time so the reconciliation never got big. Early teams even wired a physical traffic light or a lava lamp to the build: green meant main was healthy, red meant "stop and fix it, nobody merges until it's green again." That red light is still the emotional heart of CI.

Anatomy of a CI run

When CI triggers on a push or a pull request, it runs a pipeline — an ordered sequence of stages, each a gate the change must pass. The exact stages vary, but a typical run for a backend or web service looks like this:

Rendering diagram…

Walking each stage:

  • Checkout — the CI runner clones the exact commit being tested into a clean, isolated environment. "Clean" is the whole point: no leftover files, no "works because of something installed on my laptop three months ago."
  • Install dependencies — restore packages (npm ci, pip install, go mod download). This is the slowest step for many projects, which is why caching the dependency directory between runs is the single highest-value optimization in most pipelines.
  • Lint & format — static checks (ESLint, gofmt, ruff, type-checking with tsc --noEmit). These are cheap and catch a whole class of problems before you spend money compiling, so they run early.
  • Build / compile — produce the actual buildable output. For a compiled language this is compilation; for a TypeScript app it's the bundle; increasingly it's building a container image (the topic of Docker & Images, Part II) so the exact artifact you test is the exact artifact you ship.
  • Test — run the automated test suite you built in Testing (Backend & APIs, Vol 8): unit tests first (fast, isolated), then integration tests (slower, may spin up a throwaway database in a container). A failure here is CI doing its job.
  • Produce an artifact — on success, package the result: a versioned build, a .jar, or most commonly a tagged container image pushed to a registry (see Container Registries, Part II). That artifact is what the deployment half — CD, the next chapter — picks up and ships.

Notice the shape: cheap, fast checks first; expensive checks later. You lint before you build and build before you run the slow integration suite, so a trivial mistake fails in ten seconds instead of ten minutes. This is called failing fast, and it's a design principle, not an accident.

Notebook — the test pyramid inside CI Not all tests are equal citizens of a pipeline. Lean on the test pyramid: a broad base of unit tests (milliseconds each, no I/O, run on every push), a thinner layer of integration tests (seconds each, hit a real database or queue — often a disposable container the pipeline starts and throws away), and a tiny cap of end-to-end tests (slow, brittle, run less often — maybe only on merge to main, not on every push). Put the fast, reliable tests on the hot path so every push gets quick feedback; push the slow, flaky ones to a less frequent stage. A pipeline that runs a 40-minute Selenium suite on every keystroke will simply be ignored — developers route around slow CI, and a bypassed gate protects nothing.

CI as a quality gate on pull requests

The place CI earns its keep most visibly is the pull request. Modern platforms let you mark CI runs as required status checks and turn on branch protection on main: the PR literally cannot be merged until the pipeline is green. The build becomes a gate, and the gate is enforced by the platform, not by hoping people remember.

Rendering diagram…

This is a profound shift in who guarantees quality. Without it, "did you run the tests?" is a question asked in code review, answered by a human, and often answered wrong. With it, the tests ran, on a clean machine, on exactly the code being merged, and the answer is a fact on the PR page. Reviewers get to spend their attention on design and intent instead of playing "did this compile."

And it protects the thing everything else depends on: a green main. When main is always green — always building, always passing tests — anyone can branch off it with confidence, and a release can be cut at any moment. When main is red, everyone is blocked: nobody can trust the base they're branching from. That's why a broken build is treated as a stop-the-line emergency. There are two ways out, and mature teams pick deliberately: revert (back the bad change out immediately, restore green, investigate at leisure) or fix-forward (push a quick corrective commit). Revert is usually right — it restores everyone's productivity in seconds, and a revert is far less risky than a rushed fix written under pressure.

In production At large companies the "keep main green" rule is enforced by machinery beyond a simple required check. Because hundreds of PRs may be merging an hour, two changes can each pass CI independently but conflict once both land — a "logical merge conflict" no per-PR check catches. So Google, Meta, and others use a merge queue (submit queue): changes line up, and the queue re-runs CI on each change as if already merged on top of the ones ahead of it, admitting it to main only if it's still green. It's CI applied not just to your branch but to the future state of main — the logical endpoint of "integrate continuously, verify automatically."

Fast, reliable, isolated — and the war on flaky tests

A CI pipeline is only as valuable as it is trusted, and trust rests on three properties of its tests:

  • Fast — feedback in minutes, not hours. Slow CI doesn't just waste time; it changes behavior. Developers batch up changes to avoid waiting, which makes each change bigger, which defeats the entire point of continuous small integration.
  • Reliable (deterministic) — the same code produces the same result every time. The opposite is a flaky test: one that passes and fails at random on identical code, usually because it depends on timing, test-execution order, network calls, or shared mutable state. Flaky tests are corrosive out of proportion to their number. The first time a red build turns out to be "just the flaky one, hit re-run," developers learn to distrust red — and a CI system nobody trusts to be right is worse than no CI at all, because now failures get ignored. Treat flakes as bugs: quarantine them out of the required path, then fix or delete them.
  • Isolated — each run starts from a clean, reproducible environment with no dependence on state left by a previous run or on a specific machine. This is exactly why CI runners are ephemeral and why containers (Part II) and CI are such natural partners: the container is the clean, identical environment.

Three techniques keep pipelines fast as a codebase grows:

Technique What it does Watch out for
Caching Reuse expensive-to-produce state (downloaded dependencies, compiled build layers) between runs, keyed on a lockfile hash A stale or poisoned cache can hide real breakage — key it precisely and bust it when inputs change
Parallelization Split the test suite across many runners at once (sharding), or run independent jobs (lint, unit, integration) concurrently Tests must be independent — parallelism exposes hidden ordering dependencies
Matrix builds Run the same job across a grid of variations — Node 18/20/22 × Ubuntu/macOS/Windows — in parallel Cost grows with the product of the axes; test the combinations that matter, not every cell

Analogy Think of CI as the airport security checkpoint for your codebase, and main as the secure side of the terminal. Every change — no exceptions, no "I'm in a hurry" — goes through the same scanner (checkout → build → lint → test) before it's allowed airside. Fail-fast is the checkpoint pulling the obvious problem at the first sensor instead of at the gate. Caching is the pre-check lane for known-good travelers. And a flaky scanner that beeps randomly is the worst possible outcome: agents start waving people through, and now the whole checkpoint is theater. The value isn't the scanner — it's everyone's justified confidence that whatever's airside was actually checked.

The tooling landscape

The practice is universal; the tools that run it are many. They all do the same core job — run your pipeline on a fresh machine when code changes — but differ in where they run, how you configure them, and how much you operate yourself.

Tool Config Runners Hosting model Best when
GitHub Actions YAML in .github/workflows/ GitHub-hosted or self-hosted SaaS, tightly integrated with GitHub Your code is on GitHub; you want fast setup and a huge marketplace of prebuilt actions
GitLab CI/CD .gitlab-ci.yml GitLab-hosted or self-hosted runners SaaS or self-managed GitLab You use GitLab; want CI, registry, and deploy in one integrated product
Jenkins Jenkinsfile (Groovy) Controller + agents you run Self-hosted (you operate it) Enterprise, on-prem/regulated, need deep customization and a vast plugin ecosystem
CircleCI .circleci/config.yml Cloud or self-hosted SaaS Want a fast, polished cloud CI decoupled from your Git host
Travis CI .travis.yml Cloud SaaS Classic OSS choice; less dominant now than it once was
Buildkite YAML pipeline, agents you run Your own agents, their control plane Hybrid: their UI, your compute You want managed orchestration but must run builds on your own hardware/security boundary

Two axes cut across the whole table and are worth naming explicitly, because interviewers do:

Managed vs. self-hosted runners. A managed (cloud-hosted) runner is a fresh VM the provider spins up for each job — zero maintenance, you just pay per minute. A self-hosted runner (or agent) is a machine you run the CI software on. You choose self-hosted when you need specific hardware (GPUs, lots of RAM), access to a private network or on-prem database, compliance control over where code executes, or cheaper steady-state cost at high volume — at the price of patching, scaling, and securing those machines yourself. Security note: never attach a self-hosted runner to a public repository's untrusted PRs — a malicious PR can run arbitrary code on your machine.

GitHub Actions: YAML, close to the code

GitHub Actions is the default many teams reach for, because the CI lives in the same repo as the code and the ecosystem of reusable actions (actions/checkout, actions/setup-node, actions/cache) removes most boilerplate. A workflow is a YAML file describing events that trigger it, jobs that run in parallel by default, and steps within a job:

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:            # run on every PR — this is the quality gate

jobs:
  build-and-test:
    runs-on: ubuntu-latest          # a managed runner, fresh each run
    strategy:
      matrix:
        node: [18, 20, 22]          # matrix build: test three Node versions in parallel
    steps:
      - uses: actions/checkout@v4    # 1. checkout
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'               # 2. restore cached deps, keyed on package-lock.json
      - run: npm ci                   #    clean, reproducible install
      - run: npm run lint             # 3. lint (cheap — fails fast)
      - run: npm run build            # 4. build
      - run: npm test                 # 5. test → non-zero exit fails the job

The mental model: each step is a shell command or a reusable action; a non-zero exit code fails the step, which fails the job, which turns the PR check red. Mark this workflow a required check in branch-protection settings and you have your gate.

Jenkins: the self-hosted classic you must know

Jenkins is older than most of the SaaS tools and still runs an enormous share of enterprise CI/CD — especially in banks, on-prem shops, and regulated environments where code cannot leave the building. Any DevOps interview at an established company can turn to it, so understand its model, not just its syntax.

Jenkins uses a controller/agent architecture. The controller (historically "master") is the brain: it stores job definitions, schedules work, and serves the web UI. It hands actual build work to agents (historically "nodes"/"slaves") — separate machines or containers that execute the pipeline steps. This lets one Jenkins controller fan work out across a fleet of build machines with different OSes and hardware. Its superpower and its curse is the plugin ecosystem: thousands of plugins integrate it with virtually everything — which means near-infinite flexibility and a real operational burden (you patch the controller, manage plugin versions and their CVEs, and keep agents alive). That's the classic self-hosted trade-off: total control, total ownership.

Modern Jenkins pipelines are defined as code in a Jenkinsfile written in a Groovy-based DSL. The declarative pipeline syntax mirrors the same stages you saw above:

// Jenkinsfile — declarative pipeline
pipeline {
    agent { label 'linux' }        // run on an agent labeled 'linux'
    options { timeout(time: 20, unit: 'MINUTES') }
    stages {
        stage('Install') {
            steps { sh 'npm ci' }              // restore deps
        }
        stage('Lint') {
            steps { sh 'npm run lint' }        // fail fast on cheap checks
        }
        stage('Build') {
            steps { sh 'npm run build' }
        }
        stage('Test') {
            steps { sh 'npm test' }
            post {                             // publish results even on failure
                always { junit 'reports/*.xml' }
            }
        }
    }
    post {
        failure { echo 'Build red — main is blocked. Revert or fix-forward.' }
    }
}

Same anatomy — install, lint, build, test — expressed in Groovy and run on an agent you operate. Learn to read both dialects: the concepts (stages, steps, agents/runners, fail-on-nonzero-exit) transfer directly, and that transfer is exactly what an interviewer is probing when they ask you to compare tools.

Notebook — a fail-fast pipeline is just an ordered list of gates. Under all the YAML and Groovy, a pipeline is a sequence of stages run in order, stopping at the first non-zero exit. You can model that logic in a few lines — it's the same state machine every CI engine implements:

TypeScript — Try it Yourself
Loading editor…
Type check + Console

Run it: the test stage returns a non-zero code, the loop stops, package never runs, and the pipeline reports failure. That early-exit is fail-fast, and it's the entire control flow of CI in eight lines.

Common Mistakes

  • Long-lived feature branches. Branching off main and not merging for two weeks is integration hell — CI can't save you from a merge you refuse to do. Integrate small and often; that's the "continuous" in CI.
  • Tolerating a red main. Letting the build stay broken "we'll fix it later" blocks the whole team and normalizes red. Stop the line: revert or fix-forward immediately.
  • Ignoring flaky tests. "Just hit re-run" is how you train a team to distrust CI. Quarantine flakes off the required path and fix them like the bugs they are.
  • Slow pipelines. A 45-minute CI run gets bypassed. Cache dependencies, parallelize, split slow E2E tests off the per-push path, and put cheap checks first.
  • No caching / rebuilding the world every time. Reinstalling every dependency from scratch on each run wastes minutes and money. Cache on the lockfile hash.
  • Testing an artifact you don't ship. If CI tests source but you deploy a separately-built image, you've tested something else. Build the artifact once, test that, then ship it.
  • Only running CI after merge. CI that runs after code hits main finds problems too late. Run it on the pull request, as a required check, so bad code never reaches main.
  • Secrets in plain YAML. Hard-coded tokens in a workflow file leak the moment the repo does. Use the platform's encrypted secrets store (covered fully in DevSecOps, Part VII).

Think about it It's 4:57 p.m. Your PR is green, approved, and merged. Two minutes later the main build goes red — your change passed on its own branch but conflicts with a change that merged while yours was in review. The team's Friday deploy is at 5:15. What do you do in the next sixty seconds: revert your merge to restore green, or push a fix-forward you think is right? What information would change your answer — and what does the very existence of this situation tell you about why big teams run merge queues?

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
What is Continuous Integration, and why do teams do it?
Model answer — CI is the practice of merging every developer's changes into the shared main line frequently (many times a day) and automatically building and testing every change as it arrives. The point is fast feedback: integration problems surface in minutes, while they're small, instead of piling up into "integration hell" at release time. It keeps main always green and releasable, and lets reviewers focus on design instead of "does it even build."
Intermediate
Walk me through what actually runs when I open a pull request.
Model answer — A CI runner triggers on the PR: it checks out the exact commit into a clean, isolated environment, restores cached dependencies and installs them, runs cheap static checks (lint, type-check) first, compiles/builds, then runs tests (unit first, then integration — often against a throwaway database in a container), and on success produces an artifact, usually a tagged container image pushed to a registry. Any non-zero exit fails the run and, via branch protection, blocks the merge. The stage order is deliberate — cheap checks first so trivial mistakes fail fast.
Advanced
GitHub Actions vs. Jenkins — and when would you choose self-hosted runners over managed?
Model answer — Actions is SaaS: YAML workflows living beside the code, managed runners spun fresh per job, and a large marketplace — minimal ops, ideal when you're on GitHub. Jenkins is self-hosted: a controller/agent architecture with Groovy Jenkinsfile pipelines and a vast plugin ecosystem — maximal flexibility and control at the cost of operating, patching, and securing it yourself, which is why it dominates on-prem and regulated enterprises. You pick self-hosted runners (on either) when you need special hardware (GPUs), access to a private network, compliance control over where code executes, or lower steady-state cost at high volume — accepting that you now own their maintenance and security. Critically, never expose self-hosted runners to untrusted PRs on public repos: a malicious PR can execute arbitrary code on your machine.
Company lens — how the same topic gets asked
micro1the AI screen likes the "what happens when you open a PR" walk-through and a crisp CI-vs-CD distinction — narrate the stages in order and name the quality gate.
Googlepioneered CI at massive scale with a giant monorepo, a submit/merge queue, and hermetic, cached builds (Blaze/Bazel) — expect questions on keeping a huge trunk green and making builds fast and reproducible.
Metatrunk-based development with heavy internal tooling and a merge queue; expect "how do you ship safely at high commit volume" and how CI enables continuous, small changes.
Amazonthe managed-service angle — CodeBuild runs your build/test in the cloud and CodePipeline orchestrates it (with CodeCommit/CodeArtifact around it); be ready to describe a CI stage as a CodeBuild project producing an artifact for the deploy stage.

Hands-on Take any project you have with tests and write a real CI pipeline for it. Create .github/workflows/ci.yml that triggers on pull_request, checks out the code, installs dependencies with caching, then runs lint → build → test as separate steps in fail-fast order. Push it on a branch and open a PR — watch the checks run on the PR page. Then make one test fail on purpose and confirm the PR check goes red and (with branch protection on) the merge button is disabled. Finally, translate the same three stages into a declarative Jenkinsfile on paper. Producing both dialects from one mental model is exactly the fluency interviews reward.

Summary

  • Continuous Integration = integrate small changes into main frequently (the human half — partner to trunk-based development) and build + test every change automatically (the machine half). Its purpose is fast feedback that kills "integration hell."
  • A CI run is an ordered pipeline: checkout → install (cached) → lint → build → test → produce an artifact/image, arranged cheap-checks-first so mistakes fail fast.
  • CI is the quality gate on pull requests: required status checks plus branch protection mean bad code can't merge, and main stays green so anyone can branch and release with confidence — a broken build is a stop-the-line event you fix by revert or fix-forward.
  • Tests must be fast, reliable, and isolated; flaky tests destroy trust, and caching, parallelization, and matrix builds keep pipelines fast as the code grows.
  • Know the tooling: GitHub Actions (YAML, integrated, marketplace), GitLab CI, and the self-hosted enterprise classic Jenkins (controller/agent, Groovy Jenkinsfile, huge plugin ecosystem) — plus managed vs. self-hosted runners and when each fits.

CI answers "is this change good?" It stops at a tested artifact sitting in a registry. The next chapter picks that artifact up and answers the harder question — how do we get it safely into production, automatically and repeatably? That's Continuous Delivery and Deployment.

Editorial reflection Continuous Integration is, underneath the YAML, a statement about humility. It assumes that you — competent, careful, experienced you — will still break the build, because everyone does, and it builds a machine to catch that at 3:02 p.m. instead of in production at 3:02 a.m. The best engineers don't resent that machine or route around it; they make it fast and trustworthy precisely because they know they're fallible. A green build isn't a bureaucratic hurdle. It's a small, automated act of respect — for your teammates who'll branch off your work, and for the future you who'll be grateful the mistake never made it out the door.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.