1. Courses
  2. Engineering Roles
  3. DevOps: CI/CD, Containers and Infrastructure as Code
  4. Stop 1

35 minIntermediate

Continuous integration that people trust

A pipeline only protects you if people wait for it and believe it. This lesson takes Mangrove Market's order service from a 24-minute pipeline that developers work around to one that answers in about 8 minutes and can be required on every merge.

By the end you can

  • Explain the three things that make developers trust or bypass CI: speed, flakiness and clear failures
  • Lay out a pipeline as parallel jobs, with npm ci, dependency caching and a real Postgres for integration tests
  • Split a slow test suite across shards by recorded time, and write the greedy algorithm that does it
  • Measure flaky tests, quarantine them with an owner, and avoid silent retries
  • Gate merges with one required check and a merge queue, without the "skipped means passed" trap

The everyday picture

Think of the smoke alarm in a busy kitchen. If it goes off every time someone makes toast, the cooks take the battery out within a week. Then the day there is a real fire, nothing warns them.

A useful alarm has three qualities. It reacts fast, so the cook can act before the pan burns. It almost never goes off for nothing. And when it does go off, it tells you where the trouble is: "kitchen, stove two".

Continuous integration is the alarm for your code. When it is slow, people stop waiting for it. When it fails for no reason, people learn to ignore red. When a failure is hard to read, people press "re-run" instead of reading it. Each of these is a battery taken out. This lesson is about building the alarm the kitchen keeps switched on.

Why people bypass CI, and what trust is made of

(CI) means every change is built and tested automatically before it joins main. You met the workflow syntax in GitHub Actions: continuous integration, and the overview of the whole delivery path in Shipping safely: containers, CI/CD and releases. This lesson is about the part those lessons leave open: making the pipeline good enough that people choose to rely on it.

Trust breaks in three predictable ways.

  • Slow feedback. If a run takes 25 minutes, a developer starts another task, forgets the PR, and comes back an hour later. Changes pile up in big batches, and big batches break more. DORA's guidance on CI puts the target at feedback within about ten minutes; past that point people stop waiting.
  • Flaky results. A fails on code that is fine. After a few false alarms, developers re-run red builds without reading them, and a real failure slips through on the second try.
  • Unclear failures. If the log is 4,000 lines and the error is somewhere in the middle, people guess. A trusted pipeline fails in a job whose name says what broke (lint, integration (3)) with the failing test near the bottom of the log.

The cost of losing trust is concrete. Someone with admin rights merges past a red check "because it is always red". A release goes out on a Friday without anyone noticing that the integration tests did not run. You can measure trust, too: count how often PRs are merged with a bypass, how often a failed run is re-run with no new commit, and the median time from push to result. Those three numbers are the health check for your CI.

Quick check

On the order service, about one run in eight fails and then passes when re-run with no change. What is the most damaging effect?

The shape of a fast pipeline

The order service's old pipeline was one job doing six things in a row: install, lint, typecheck, unit tests, integration tests, build. Every step waited for the one before, so the run took the sum of all steps. The fix is to turn independent checks into separate jobs that run at the same time on separate . Then the run takes as long as the slowest job, plus a little overhead.

Four ideas make each job fast and repeatable.

  • Reproducible installs. npm ci installs exactly what package-lock.json says, deletes any existing node_modules first, and fails if the and package.json disagree. npm install may update the lockfile instead, so two runs of the same commit can test different code.
  • Caching downloads, not results. actions/setup-node with cache: npm restores npm's download folder, keyed by the lockfile hash (the mechanism is in GitHub Actions: continuous integration). Do not cache node_modules itself: it holds compiled native modules tied to one OS and Node version, and npm ci deletes it anyway.
  • Real dependencies for integration tests. A gives each job its own Postgres and Redis. With a health check, the runner waits until Postgres accepts connections before your steps start. You then run the same migrations as production, so a broken migration fails in CI and not at 2 a.m.
  • Cancel what nobody needs. A concurrency group per PR with cancel-in-progress stops the run for an old commit when a newer one is pushed. Never cancel runs on main.

Order the work so the cheapest, most common failures appear first. Lint and typecheck finish in a few minutes and catch a large share of mistakes; the developer sees them before the slow integration jobs end. One detail trips many NestJS projects: the generated lint script runs ESLint with --fix. In CI that silently rewrites files on the runner and reports success. Give CI its own lint:ci script without --fix.

Before: one job, 24 min install lint unit integration 13 min build steps run one by one After: parallel, 8 min lint + typecheck, 4 min unit tests, 4 min integration (1) integration (2) integration (3) integration (4) each about 6.5 min, Postgres + Redis build, 2.5 min ci-ok: needs all jobs
The same work, laid out on a time scale. Before, the run lasts as long as all steps added together. After, independent jobs run side by side, the integration tests are split into four shards, and the run lasts as long as the slowest shard plus the small ci-ok job. Times are approximate.

Quick check

A teammate proposes caching node_modules with actions/cache and replacing npm ci with "skip install on a cache hit". What is the main risk?

Splitting tests by time, not by count

After the jobs were parallel, the order service still had one long pole: about 13 minutes of integration tests in ten files. The answer is to run them as a matrix: four copies of the integration job, each running a different quarter of the files.

The question is how to choose the quarters. Jest has a built-in --shard=2/4 option, but its default sequencer divides the files by count, ordered by a hash of their paths, with no idea how long each file takes. The order service's files range from 28 seconds (riders) to almost two and a half minutes (checkout). Dealt out by count, one shard got 327 seconds of work and another 127. The whole run waits for the slowest shard, so the split was barely better than two shards.

Splitting by time needs two things.

  1. A record of how long each file takes. Jest's --json --outputFile=report.json writes a start and end time for every test file. A small script turns the reports from a recent run on main into test/timings.json. Durations change slowly, so refreshing it every week or two is enough.
  2. A balancing rule. Sort the files from longest to shortest. Take each file in turn and give it to the shard with the least total time so far. This is the classic rule. A new file with no timing yet gets the average of the known files, so it is not ignored.

Every shard runs the same script with the same input, so each one computes the same split and picks its own part. No coordination between shards is needed. With the order service's timings the slowest shard dropped from 327 to 219 seconds, against an ideal of about 212.

Split by file count shard 1 249 s shard 2 327 s shard 3 146 s shard 4 127 s run waits 327 s Split by recorded time shard 1 199 s shard 2 219 s shard 3 217 s shard 4 215 s run waits 219 s
The order service's eleven integration files on four shards. Dealing files out by count leaves one shard with far more work than the others. Handing out the longest files first, each to the least loaded shard, makes the four shards almost equal. The new file with no timing counts as the average.

Quick check

Four shards, each already balanced at about 200 seconds. Someone adds a new file that takes 300 seconds on its own. What does the run's time become, at best?

Flaky tests: measure, quarantine, fix

A flaky test is one that gives different results on the same code. The usual causes in an API like the order service are:

  • Shared data. Jest runs test files in parallel workers. If two files use the same Postgres database and the same product row, one can change the stock the other is checking.
  • Time. A test that builds a delivery slot from the real clock passes all day and fails after 10 p.m., when the 30-minute "Now" delivery closes.
  • Waiting by guessing. A test that sleeps 500 ms and hopes the job queue has finished. On a busy runner it has not.
  • Order. A test that only passes when another test ran first and left data behind.

Measure first. A test is flaky when it both passed and failed on the same commit. Every re-run of a failed job in GitHub Actions tests the same commit again, so your Jest reports already hold the evidence. Keep the reports as artifacts, collect them, and list the tests with mixed results per commit. That list, sorted by how many commits each test flaked on, is your work queue.

Then quarantine, with an owner. takes a flaky test out of the checks that block merging, without deleting it. Each entry in the quarantine list names an owner, a ticket and the date it was added. A nightly job still runs the quarantined tests, so you see when a fix works. A team rule such as "fixed or deleted within 14 days" keeps the list short; without a deadline it becomes a graveyard of tests nobody runs.

Be careful with retries. Jest's jest.retryTimes(n) re-runs failing tests, and GitHub has a "Re-run failed jobs" button. A retry that turns red into green hides exactly the signal you need. If you use retries at all, make them visible: log every retried test and count them, the same way you count flakes. Never let a suite retry silently forever.

Quick check

Which of these runs proves that inventory-hold.e2e-spec.ts is flaky?

Gates that people cannot quietly skip

A green tick that is not required is a suggestion. A in the ruleset on main (you built one in Branching strategies, releases and versions) makes the merge button stay grey until it passes. Two details decide whether the gate holds.

Require one aggregate check. With a shard matrix, the job names are integration (1) to integration (4). If you list them one by one and later change to six shards, the ruleset waits forever for checks that no longer exist, or misses new ones. Instead, add a final job, ci-ok, that needs every other job, and require only that one.

Watch the skipped-job trap. GitHub reports a skipped job as "Success", and a skipped required check does not block a merge. A job with needs: is skipped when a job it needs fails. So a plain ci-ok job would be skipped, reported as success, and let a broken PR merge. The fix: give ci-ok if: always(), so it always runs, and make it fail when any needed job did not succeed.

Add a merge queue when the team is busy. Each PR was tested against the main it started from. Two PRs can each be green and still break main together. The ruleset option "Require branches to be up to date" forces each PR to re-run after every merge, which gets slow with many PRs. A does it better: approved PRs join a queue, GitHub builds temporary branches with main plus the queued PRs ahead, runs the required checks on them, and merges only what passes. A PR that fails leaves the queue, and the branches behind it are rebuilt without it. Your workflow must listen to the merge_group event, or the required check is never reported in the queue.

main PR 41 PR 42 PR 43 approved, join in order queue builds and tests main + 41 ci-ok passes main + 41 + 42 fails: 42 leaves rebuild without 42 main + 41 + 43 ci-ok passes merge 41, then 43 main stays green 42's author is told
A merge queue tests each PR on top of the PRs ahead of it, so main only ever receives combinations that passed. When PR 42 fails, it is removed and PR 43 is tested again without it.

Build it

We will build the order service's pipeline in mangrove/order-service: CI-safe scripts, a timing-based shard split, the workflow, a quarantine list and the ruleset. The service is NestJS on Node 22 with Jest; action versions and Jest options are version dependent, so check them against your project. The split algorithm is also the browser exercise in the Practice section.

  1. Make the scripts safe for CI

    CI calls npm scripts, so developers can run exactly the same checks locally. The NestJS starter's lint script fixes files in place; CI gets a version that only reports. --ci makes Jest fail on a missing snapshot instead of writing a new one.

    JSON
    {
      "scripts": {
        "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
        "lint:ci": "eslint \"{src,apps,libs,test}/**/*.ts\" --max-warnings 0",
        "typecheck": "tsc --noEmit -p tsconfig.json",
        "test:ci": "jest --ci",
        "test:e2e": "jest --config test/jest-e2e.json",
        "build": "nest build"
      }
    }

    Commit package-lock.json. Without it, npm ci refuses to run and the npm cache has nothing to hash.

  2. Record how long each test file takes

    Every integration shard saves a Jest JSON report as an artifact (step 4). Every week or two, download the reports from a green run on main and turn them into test/timings.json:

    JavaScript
    // scripts/timings.mjs: turn Jest --json reports into test/timings.json
    // usage: node scripts/timings.mjs report-1.json report-2.json ... > test/timings.json
    import { readFileSync } from 'node:fs';
    import path from 'node:path';
    
    const timings = {};
    for (const file of process.argv.slice(2)) {
      const report = JSON.parse(readFileSync(file, 'utf8'));
      for (const r of report.testResults) {            // one entry per test file
        const key = path.relative(process.cwd(), r.name).split(path.sep).join('/');
        timings[key] = Math.round((r.endTime - r.startTime) / 100) / 10; // ms to s, 1 decimal
      }
    }
    const sorted = Object.fromEntries(Object.entries(timings).sort(([a], [b]) => a.localeCompare(b)));
    console.log(JSON.stringify(sorted, null, 2));
    Terminal
    gh run download 123456789 --pattern "jest-report-*"   # the run ID of a green main run
    node scripts/timings.mjs jest-report-*/report.json > test/timings.json

    Commit the new file in a small PR, so the timing data is reviewed like any other change.

  3. Write the split script

    Each shard reads the full list of test files, computes the same longest-first split, and prints only its own files. Files in the quarantine list (step 6) are left out.

    JavaScript
    // scripts/split-tests.mjs: this shard's test files, balanced by recorded time
    // usage: node scripts/split-tests.mjs <shard 1..N> <N> < list-of-test-files
    import { existsSync, readFileSync } from 'node:fs';
    import path from 'node:path';
    
    const [shard, total] = process.argv.slice(2).map(Number);
    const read = (f, empty) => (existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : empty);
    const timings = read('test/timings.json', {});             // { "test/x.e2e-spec.ts": 41.2 }
    const skip = new Set(read('test/quarantine.json', []).map((q) => q.file));
    const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
      .map((f) => path.relative(process.cwd(), f).split(path.sep).join('/')) // same keys on any OS
      .filter((f) => !skip.has(f));
    
    const known = files.map((f) => timings[f]).filter((s) => s !== undefined);
    const guess = known.length ? known.reduce((a, b) => a + b, 0) / known.length : 1; // new file
    
    const shards = Array.from({ length: total }, () => ({ files: [], seconds: 0 }));
    files
      .map((file) => ({ file, seconds: timings[file] ?? guess }))
      .sort((a, b) => b.seconds - a.seconds || a.file.localeCompare(b.file)) // longest first
      .forEach((t) => {
        const target = shards.reduce((min, s) => (s.seconds < min.seconds ? s : min)); // emptiest
        target.files.push(t.file);
        target.seconds += t.seconds;
      });
    
    const mine = shards[shard - 1];
    console.error(`shard ${shard}/${total}: ${mine.files.length} files, about ${Math.round(mine.seconds)} s`);
    console.log(mine.files.join('\n'));                        // one path per line

    Try it locally. With the order service's ten timed files plus one new file, wallet-topup, which has no timing yet (the quarantine list is still empty at this point):

    Terminal
    npx jest --config test/jest-e2e.json --listTests > all-tests.txt
    node scripts/split-tests.mjs 2 4 < all-tests.txt
    Output
    shard 2/4: 3 files, about 219 s
    test/orders.e2e-spec.ts
    test/delivery-slots.e2e-spec.ts
    test/riders.e2e-spec.ts
  4. Write the workflow: triggers, concurrency and the fast jobs

    Create .github/workflows/ci.yml. Steps 4 and 5 are one file. Note the merge_group trigger: without it, the merge queue never gets a result.

    YAML
    name: CI
    on:
      pull_request:
        branches: [main]
      push:
        branches: [main]
      merge_group:                    # the merge queue tests each group with this event
    
    permissions:
      contents: read                  # the token can only read the code
    
    concurrency:
      group: ci-${{ github.event.pull_request.number || github.ref }}
      cancel-in-progress: ${{ github.event_name == 'pull_request' }}   # never cancel main
    
    jobs:
      lint:
        runs-on: ubuntu-latest
        timeout-minutes: 10
        steps:
          - uses: actions/checkout@v7
          - uses: actions/setup-node@v7
            with: { node-version: 22, cache: npm }   # restores npm's download folder
          - run: npm ci                              # exactly the lockfile, nothing else
          - run: npm run lint:ci
          - run: npm run typecheck
    
      unit:
        runs-on: ubuntu-latest
        timeout-minutes: 10
        steps:
          - uses: actions/checkout@v7
          - uses: actions/setup-node@v7
            with: { node-version: 22, cache: npm }
          - run: npm ci
          - run: npm run test:ci
  5. Add the integration shards, the build and the gate

    Each shard gets its own Postgres and Redis. The health checks make the runner wait until both accept connections. strategy.job-total is the number of matrix jobs, so changing the shard list is a one-line edit.

    YAML
      integration:
        runs-on: ubuntu-latest
        timeout-minutes: 15
        strategy:
          fail-fast: false                  # finish every shard, show every failure
          matrix:
            shard: [1, 2, 3, 4]
        services:
          postgres:
            image: postgres:17
            env: { POSTGRES_PASSWORD: postgres }
            ports: ['5432:5432']
            options: --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
          redis:
            image: redis:7
            ports: ['6379:6379']
            options: --health-cmd "redis-cli ping" --health-interval 5s --health-retries 10
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
          REDIS_URL: redis://localhost:6379
        steps:
          - uses: actions/checkout@v7
          - uses: actions/setup-node@v7
            with: { node-version: 22, cache: npm }
          - run: npm ci
          - run: npx prisma migrate deploy  # the same migrations production runs
          - name: Run this shard's files
            run: |
              npx jest --config test/jest-e2e.json --listTests > all-tests.txt
              node scripts/split-tests.mjs ${{ matrix.shard }} ${{ strategy.job-total }} < all-tests.txt > shard.txt
              [ -s shard.txt ] || exit 0    # an empty shard has nothing to run
              npx jest --config test/jest-e2e.json --ci --runTestsByPath $(cat shard.txt) --json --outputFile=report.json
          - uses: actions/upload-artifact@v7
            if: always()                    # keep the report even when tests fail
            with: { name: 'jest-report-${{ matrix.shard }}', path: report.json, retention-days: 14 }

    The empty-shard guard matters: --runTestsByPath with no paths would run every test. Then the build and the one job the ruleset will require:

    YAML
      build:
        runs-on: ubuntu-latest
        timeout-minutes: 10
        steps:
          - uses: actions/checkout@v7
          - uses: actions/setup-node@v7
            with: { node-version: 22, cache: npm }
          - run: npm ci
          - run: npm run build
    
      ci-ok:                                # the only check the ruleset requires
        needs: [lint, unit, integration, build]
        if: always()                        # run even when a needed job failed or was skipped
        runs-on: ubuntu-latest
        timeout-minutes: 2
        steps:
          - name: Fail unless every needed job succeeded
            if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')
            run: exit 1
          - run: echo "lint, unit, integration and build all passed"
  6. Keep a quarantine list with owners

    The split script skips every file in test/quarantine.json. Each entry carries who owns the fix, the ticket and the date, so the list can be reviewed every week. If only one test in a big file is flaky, move that test into its own file first, so the rest keeps running.

    JSON
    [
      {
        "file": "test/delivery-slots.e2e-spec.ts",
        "owner": "nadia",
        "ticket": "ORD-1412",
        "added": "2026-10-12",
        "reason": "builds a Now slot from the real clock; fails after 22:00"
      }
    ]

    A second, small workflow runs the quarantined files every night, so a fix shows up as green and a regression is still visible. It blocks nobody.

    YAML
    # .github/workflows/quarantine.yml (services and env as in the integration job)
    on:
      schedule:
        - cron: '30 1 * * *'          # every night at 01:30 UTC
    jobs:
      quarantined:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v7
          - uses: actions/setup-node@v7
            with: { node-version: 22, cache: npm }
          - run: npm ci
          - run: npx jest --config test/jest-e2e.json --ci --runTestsByPath $(node -p "require('./test/quarantine.json').map(q => q.file).join(' ')")
  7. Require ci-ok and turn on the merge queue

    Merge the workflow first: GitHub only offers checks that have already run in the repository. Then edit the protect-main ruleset from Branching strategies, releases and versions so its status check rule requires only ci-ok:

    JSON
    {
      "type": "required_status_checks",
      "parameters": {
        "strict_required_status_checks_policy": false,
        "required_status_checks": [{ "context": "ci-ok" }]
      }
    }

    In the same ruleset, tick Require merge queue, keep the merge method your team uses (Squash in dw-14), and leave Only merge non-failing pull requests on. Empty the bypass list, or keep only an admin role for emergencies. Then test the gate: push a PR with a failing test, confirm ci-ok goes red and the merge stays blocked, and run gh ruleset check main to see every rule that applies.

Bring it to your team

Ask these in your next CI review or retro.

  • "What is our median time from push to result, and which job is the long pole?"
  • "How many runs last month failed and then passed on the same commit? Which tests caused them?"
  • "Does every quarantined test have an owner, a ticket and a date, and is anything older than our deadline?"
  • "If an earlier job fails, can our required check be skipped and still count as passed?"
  • "How many merges bypassed the required checks, and why?"
  • "Do integration tests run the same migrations as production, against a real Postgres?"

Red flags

  • "Just re-run it" as the normal answer to a red build
  • npm install in CI, or a cached node_modules used instead of installing
  • Retries that turn red into green with no record of which tests needed them
  • A quarantine list with no owners, or tests that have sat in it for months
  • Required checks listed per matrix job, or a required job with needs: and no if: always()
  • A long bypass list on main, used every week

Practice

Split test files by time

Write splitByTiming(tests, shards). tests is a list of { file, seconds }; a new file may have no seconds yet and then counts as the average of the files that have one. Sort the files longest first (equal times: file name A to Z), then give each file to the shard with the smallest total so far (equal totals: the lowest shard number). Return an array with one { files, seconds } per shard, files in the order they were added. The starter deals files out by count, like cards.

JavaScript exerciseRuns in your browser with tests
// tests: [{ file: 'checkout', seconds: 148.5 }, ...]; seconds may be missing
// Return [{ files: [...], seconds: total }, ...], one entry per shard
function splitByTiming(tests, shards) {
  const out = Array.from({ length: shards }, () => ({ files: [], seconds: 0 }));
  tests.forEach((t, i) => {
    const s = out[i % shards];          // deal by count: ignores how long files take
    s.files.push(t.file);
    s.seconds += t.seconds ?? 0;
  });
  return out;
}
Ctrl + Enter runs
Show a solution
JavaScript
function splitByTiming(tests, shards) {
  const timed = tests.filter((t) => typeof t.seconds === 'number');
  const guess = timed.length ? timed.reduce((sum, t) => sum + t.seconds, 0) / timed.length : 1;
  const out = Array.from({ length: shards }, () => ({ files: [], seconds: 0 }));
  const sorted = tests
    .map((t) => ({ file: t.file, seconds: typeof t.seconds === 'number' ? t.seconds : guess }))
    .sort((a, b) => b.seconds - a.seconds || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
  for (const t of sorted) {
    let target = out[0];
    for (const s of out) if (s.seconds < target.seconds) target = s; // strict: lowest index wins ties
    target.files.push(t.file);
    target.seconds += t.seconds;
  }
  return out;
}

The two tie rules make the result the same on every shard, which matters because each shard computes the split on its own. The strict < keeps the first shard on equal totals.

Pass the exercise to finish this task.

Plan the cut for the Partner app's API

The Partner app's API (also NestJS) has one CI job that takes about 19 minutes: npm install 3 min, lint with --fix 1.5 min, typecheck 1 min, unit tests 2.5 min, integration tests 10 min (six files: 4.5, 2, 1.5, 1, 0.5 and 0.5 min, against a Postgres that a setup script starts with docker run and a 20-second sleep), build 1 min. About one run in ten fails and passes on re-run. The required checks are build only. Write a short plan to get feedback under 10 minutes that the team will trust.

  • Splits the work into parallel jobs and names the new long pole
  • Replaces npm install with npm ci plus the npm download cache, and removes --fix from CI lint
  • Notices that the 4.5-minute file sets the floor for sharding and proposes splitting that file
  • Replaces the sleep with a service container and a health check
  • Measures the flaky runs per commit and quarantines offenders with an owner and a deadline
  • Changes the gate to one aggregate check with if: always(), so failing tests can no longer merge
Show a model answer
Markdown
## Partner API CI: from 19 min to under 10

Jobs in parallel (each starts with checkout, setup-node cache: npm, npm ci ~1 min)
- lint (lint:ci, no --fix) + typecheck: ~3.5 min
- unit: ~3.5 min
- integration x 2 shards: Postgres service container with pg_isready health check
  (drop the docker run + sleep 20), prisma migrate deploy, split by recorded time
- build: ~2 min
- ci-ok: needs all, if: always(), fails on any failure/cancelled/skipped

Long pole
- The 4.5 min file is the floor for any split. Split it into 2-3 files first.
- Then 2 shards of ~5 min of tests each, ~7 min per job including setup.

Flakiness
- Keep Jest JSON reports as artifacts; list tests with pass + fail on the same commit.
- Quarantine the top offenders in test/quarantine.json with owner, ticket, date.
- Rule: fixed or deleted within 14 days. Nightly job runs the quarantined files.

Gate
- Ruleset on main requires ci-ok only (today only build is required, so a PR
  with failing tests can merge). Empty the bypass list. Test with a red PR.

Check yourself

The order service's ruleset requires integration (1) to integration (4) by name. A teammate cuts the matrix to three shards, since the suite got faster. What happens to the next PRs?

After sharding, the four integration shards take 3.5, 3.6, 3.4 and 3.5 minutes of tests, but the whole run still takes 8 minutes. Where should you look next?

A test that checks delivery slots fails on about 5% of runs, always between 22:00 and midnight UTC. The team adds jest.retryTimes(3) and the failures disappear. What is wrong with this?

Flashcards

Open my review

Key takeaways

  • CI is only useful when people trust it: fast (about ten minutes), rarely red for no reason, and clear about what broke.
  • Run independent checks as parallel jobs, install with npm ci and a download cache, and test against real Postgres and Redis service containers with production's migrations.
  • Split slow suites by recorded time with the longest-first rule; no split beats the longest single file, so break up big files.
  • Measure flakiness as pass and fail on the same commit, quarantine with an owner and a deadline, and never retry silently.
  • Require one aggregate ci-ok check with if: always(), keep the bypass list empty, and add a merge queue (with merge_group) when many PRs merge each day.

Sources

Episode transcriptThe smoke alarm nobody unplugs · about 12 minutes · tap any line to play from there
  1. NadiaImran, picture festival week. A developer on the order service opens a pull request, and the CI run goes red. Again.
  2. ImranAnd let me guess. Somebody says, it's always red, the integration tests are flaky, just merge it.
  3. NadiaExactly that happened. Someone with admin rights merged past the red check. Cash on delivery checkout broke for about forty minutes, in the busiest week of the year.
  4. ImranSo the tests were right that time, and nobody believed them.
  5. NadiaThat's today's whole topic. A pipeline only protects you if people wait for it and believe it. We're going to build one they do.
  6. NadiaThink of the smoke alarm in a busy kitchen. If it goes off every time someone makes toast, what do the cooks do?
  7. ImranTake the battery out. I've seen it in our staff canteen. There's a smoke alarm on the ceiling with no battery in it at all.
  8. NadiaAnd then on the day of a real fire, nothing warns anybody. A good alarm does three things. It reacts fast. It almost never goes off for nothing. And when it does, it tells you where the problem is.
  9. ImranSo for CI, that's speed, no false alarms, and a failure message you can actually read.
  10. NadiaRight. When CI is slow, people stop waiting. When it's flaky, people stop reading red. When the log is four thousand lines, people press re-run instead of looking. Each of those is a battery taken out.
  11. ImranHow fast is fast enough?
  12. NadiaDORA, the research group behind the delivery metrics, puts it at about ten minutes. Past that, people start another task, forget the pull request, and changes pile up into big batches. Big batches break more.
  13. ImranAnd where was the order service?
  14. NadiaTwenty-four minutes. If you can see your screen, the first diagram shows it. At the top, one long bar: install, lint, unit tests, a huge integration block of about thirteen minutes, then build. All in one job, each step waiting for the one before.
  15. ImranSo the run takes the sum of every step.
  16. NadiaYes. Below it is the new layout. Lint, unit tests, four integration jobs and the build all start at the same time on separate machines. Now the run takes as long as the slowest job, plus a little overhead. About eight minutes.
  17. ImranCan I guess the other easy win? Cache everything. Cache the node modules folder and skip the install.
  18. NadiaThat's the tempting one, and it's a trap. The node modules folder holds compiled parts tied to one operating system and one Node version. A stale copy means CI tests something different from what the lockfile says.
  19. ImranSo what do you cache instead?
  20. Nadianpm's download folder. You run npm ci, which installs exactly what the lockfile lists and fails if the lockfile and package file disagree. The cache only makes the downloads fast. The result stays exact.
  21. ImranAnd the integration tests need a database.
  22. NadiaA real one. GitHub Actions can start a Postgres container and a Redis container next to each job. With a health check, the runner waits until Postgres accepts connections. Then you run the same migrations production runs, so a broken migration fails in CI and not at two in the morning.
  23. NadiaOne small NestJS detail. The generated lint script runs with the fix option. In CI that quietly rewrites files on the runner and reports success. Give CI its own lint script without it.
  24. ImranOkay, so four integration jobs. How do you decide which tests go in which job?
  25. NadiaThat's the interesting bit. Jest has a built-in shard option, but by default it splits the files by count. It has no idea how long each file takes.
  26. ImranDoes that matter much?
  27. NadiaA lot. The second diagram shows two sets of four bars. Split by count, one shard got three hundred and twenty-seven seconds of work and another got a hundred and twenty-seven. The whole run waits for the slowest one.
  28. ImranSo three shards finish early and sit there.
  29. NadiaExactly. The lower set of bars is split by recorded time, and they're almost equal, the slowest at two hundred and nineteen seconds. The rule is old and simple to say. Sort the files longest first. Give each file to the shard with the least work so far.
  30. ImranLike loading delivery bikes. You put the heaviest boxes on first, each on the emptiest bike, and the small parcels fill the gaps at the end.
  31. NadiaThat's a good picture. And where do the times come from? Jest can write a report with how long each file took. A small script turns reports from a green run on main into a timings file. A new test file with no timing yet counts as the average.
  32. ImranIs there a limit? Could I add twenty shards and get a one minute run?
  33. NadiaNo. Shards move whole files, so no split can beat your longest single file. And every shard pays the same setup cost, starting the machine, installing, starting Postgres. Past a point, more shards only add overhead.
  34. ImranNow the flaky tests. That's what started the festival mess.
  35. NadiaA flaky test gives different results on the same code. On an API like ours the causes repeat. Two test files touching the same product row at the same time. A test that reads the real clock. A test that sleeps half a second and hopes the job queue has finished.
  36. ImranThe clock one sounds sneaky.
  37. NadiaIt is. A delivery slot test passes all day and fails after ten at night, when the thirty minute Now delivery closes.
  38. ImranSo a test that failed on Monday and passed on Tuesday is flaky?
  39. NadiaNot necessarily. Those are different commits. Monday's code might have had a real bug that Tuesday fixed. The proof is a pass and a fail on the same commit. Every re-run of a failed job tests the same commit again, so your reports already hold the evidence.
  40. ImranAnd once you've found them?
  41. NadiaQuarantine, with an owner. You take the test out of the checks that block merging, but you don't delete it. Each entry has an owner, a ticket and a date. A nightly job still runs it. And a team rule, say fixed or deleted within fourteen days, keeps the list from turning into a graveyard.
  42. ImranWhat about automatic retries? Run it three times, take the green.
  43. NadiaA retry that turns red into green hides exactly the signal you need. If you retry at all, log every retried test and count them. Never let a suite retry silently forever.
  44. NadiaSo here's how it played out at Mangrove. Ken gave the pipeline two weeks. Parallel jobs, npm ci with the cache, Postgres and Redis containers, shards split by time. A typical pull request now gets its answer in about eight minutes.
  45. ImranAnd the flakiness?
  46. NadiaThe flake list pointed at one main offender. The inventory hold tests and the checkout tests reserved stock for the same test product in a shared database. Giving each test file its own products fixed it. Red for no reason went from about one run in eight to under one in fifty.
  47. ImranThen the last piece is making the check required, I assume.
  48. NadiaYes, and there's a trap there that catches a lot of teams. Say you add a final job called C I OK that needs all the others, and you make it the required check.
  49. ImranSounds right.
  50. NadiaHere's the problem. When a job it needs fails, that final job is skipped. And GitHub reports a skipped job as success. A skipped required check does not block the merge.
  51. ImranSo the tests fail and the merge button goes green. That's terrible.
  52. NadiaThe fix is one line. The final job gets a condition that says always run, and its step fails if any needed job failed, was cancelled or was skipped. Then test it on purpose: push a pull request with a failing test and check the merge stays blocked.
  53. ImranWhy one final job instead of requiring each job by name?
  54. NadiaBecause shard names change. If the ruleset requires integration four and you cut to three shards, every pull request waits forever for a check that never comes.
  55. NadiaOne more tool for busy teams, the merge queue. The third diagram shows three approved pull requests, forty-one, forty-two and forty-three, lined up. GitHub tests main plus forty-one, then main plus forty-one plus forty-two, and so on.
  56. ImranBecause two pull requests can each be green alone and still break main together.
  57. NadiaExactly. In the picture, forty-two fails, so it leaves the queue and its author is told. The queue rebuilds main plus forty-one plus forty-three, that passes, and both merge.
  58. ImranSo main only ever receives combinations that passed. Is there a catch?
  59. NadiaOne. Your workflow must listen to the merge group event, or the queue never gets a result.
  60. NadiaThe Build it section walks through all of it. You make the npm scripts safe for CI, record how long each test file takes, and write the split script.
  61. NadiaThen you write the workflow: the fast jobs, four integration shards with Postgres and Redis, the build and the gate job. Last, a quarantine list with owners, and a ruleset that requires only the gate, with the merge queue on.
  62. ImranAnd the practice exercise has you write the split function yourself, longest file first into the emptiest shard.
  63. ImranFor my next review with the team, I'd ask three things. What's our median time from push to result, and which job is the long pole? How many runs failed and passed on the same commit last month? And can our required check be skipped and still count as passed?
  64. NadiaGood list. I'd add one more. How many merges bypassed the checks, and why?
  65. NadiaNow a question to think about. Your four shards are perfectly balanced, about two hundred seconds each. Someone adds a new test file that takes three hundred seconds on its own. What's the fastest the run can now be? Take a second.
  66. ImranMore than three hundred seconds, because that file can't be split across shards.
  67. NadiaRight. The fix is to break that file into smaller files, which also makes its failures easier to read.
  68. ImranLet me sum it up. One, CI only works if people trust it: fast, rarely red for no reason, clear about what broke. Two, parallel jobs, exact installs and shards split by time get you to about ten minutes or less. Three, a flaky test is one that passes and fails on the same commit, and you quarantine it with an owner.
  69. NadiaAll true. The nuance I'd add to the last one: the gate matters as much as the tests. One required job that always runs, an empty bypass list, and a test that proves a failing pull request really is blocked.
  70. ImranAnd next time?
  71. NadiaNext time we open up containers: what an image really is, why the order of lines in a Dockerfile decides how fast it builds, and how the order service's image shrinks to a fraction of its size.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Pipeline template

Build with a dependency cache, unit and end-to-end tests, then staging and production gated on the tests.

Open in the DevOps & TechOps Lab
Next stopContainers from the inside
Was this lesson helpful?
Questions and discussion

Loading the discussion…