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
Continuous integration (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 flaky test 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?
Minutes and waiting hurt, but the deeper damage is to behaviour: once red often means "nothing", people stop reading failures, and a real bug passes on a lucky re-run. Fixing flakiness protects the meaning of red.
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 runners. 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 ciinstalls exactly whatpackage-lock.jsonsays, deletes any existingnode_modulesfirst, and fails if the lockfile andpackage.jsondisagree.npm installmay update the lockfile instead, so two runs of the same commit can test different code. - Caching downloads, not results.
actions/setup-nodewithcache: npmrestores npm's download folder, keyed by the lockfile hash (the mechanism is in GitHub Actions: continuous integration). Do not cachenode_modulesitself: it holds compiled native modules tied to one OS and Node version, andnpm cideletes it anyway. - Real dependencies for integration tests. A service container 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
concurrencygroup per PR withcancel-in-progressstops the run for an old commit when a newer one is pushed. Never cancel runs onmain.
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.
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?
A stale or mismatched node_modules means CI tests something other than what the lockfile describes, which is the opposite of a trusted result. Caching npm's download folder keeps npm ci exact and still saves most of the download time.
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 shard 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.
- A record of how long each file takes. Jest's
--json --outputFile=report.jsonwrites a start and end time for every test file. A small script turns the reports from a recent run onmainintotest/timings.json. Durations change slowly, so refreshing it every week or two is enough. - 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 LPT 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.
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?
Sharding moves whole files. No split can finish faster than the longest single file, so 300 seconds is the floor. The fix is to break that file into smaller files, which also makes its failures easier to read.
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. Quarantine 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?
Only a pass and a fail on the same code prove flakiness. Results on different commits can be real breaks and fixes, three failures in a row look like a real bug, and a change in duration is noise, not a wrong result.
Gates that people cannot quietly skip
A green tick that is not required is a suggestion. A required status check 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 merge queue 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 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.
-
Make the scripts safe for CI
CI calls npm scripts, so developers can run exactly the same checks locally. The NestJS starter's
lintscript fixes files in place; CI gets a version that only reports.--cimakes 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 cirefuses to run and the npm cache has nothing to hash. -
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
mainand turn them intotest/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));Terminalgh run download 123456789 --pattern "jest-report-*" # the run ID of a green main run node scripts/timings.mjs jest-report-*/report.json > test/timings.jsonCommit the new file in a small PR, so the timing data is reviewed like any other change.
-
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 lineTry 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):Terminalnpx jest --config test/jest-e2e.json --listTests > all-tests.txt node scripts/split-tests.mjs 2 4 < all-tests.txtOutputshard 2/4: 3 files, about 219 s test/orders.e2e-spec.ts test/delivery-slots.e2e-spec.ts test/riders.e2e-spec.ts -
Write the workflow: triggers, concurrency and the fast jobs
Create
.github/workflows/ci.yml. Steps 4 and 5 are one file. Note themerge_grouptrigger: without it, the merge queue never gets a result.YAMLname: 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 -
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-totalis the number of matrix jobs, so changing the shard list is a one-line edit.YAMLintegration: 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:
--runTestsByPathwith no paths would run every test. Then the build and the one job the ruleset will require:YAMLbuild: 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" -
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(' ')") -
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-mainruleset from Branching strategies, releases and versions so its status check rule requires onlyci-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-okgoes red and the merge stays blocked, and rungh ruleset check mainto 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 installin CI, or a cachednode_modulesused 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 noif: 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.
// 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;
}
const suite = [
{ file: 'cart', seconds: 34 }, { file: 'checkout', seconds: 148.5 },
{ file: 'delivery-slots', seconds: 66.5 }, { file: 'inventory-hold', seconds: 92 },
{ file: 'orders', seconds: 124 }, { file: 'payments', seconds: 111 },
{ file: 'promotions', seconds: 45.5 }, { file: 'refunds', seconds: 72 },
{ file: 'riders', seconds: 28 }, { file: 'substitutions', seconds: 50 },
];
test('every file lands in exactly one shard', () => {
const files = splitByTiming(suite, 4).flatMap((s) => s.files).sort();
expect(files).toEqual(suite.map((t) => t.file).sort());
});
test('longest file first, each into the least loaded shard', () => {
expect(splitByTiming(suite, 4)).toEqual([
{ files: ['checkout', 'promotions'], seconds: 194 },
{ files: ['orders', 'substitutions', 'riders'], seconds: 202 },
{ files: ['payments', 'delivery-slots'], seconds: 177.5 },
{ files: ['inventory-hold', 'refunds', 'cart'], seconds: 198 },
]);
});
test('the slowest shard is close to a perfect quarter (192.9 s)', () => {
const slowest = Math.max(...splitByTiming(suite, 4).map((s) => s.seconds));
expect(slowest).toBeLessThan(210);
});
test('a new file with no timing counts as the average of the others', () => {
expect(splitByTiming([{ file: 'a', seconds: 10 }, { file: 'b', seconds: 30 }, { file: 'new' }], 2))
.toEqual([{ files: ['b'], seconds: 30 }, { files: ['new', 'a'], seconds: 30 }]);
});
test('more shards than files leaves the extra shards empty', () => {
expect(splitByTiming([{ file: 'x', seconds: 5 }], 3)).toEqual([
{ files: ['x'], seconds: 5 }, { files: [], seconds: 0 }, { files: [], seconds: 0 },
]);
});
Show a solution
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.
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 installwithnpm ciplus the npm download cache, and removes--fixfrom 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
## 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?
Required checks are matched by name, and no job produces integration (4) any more, so every PR waits for a status that never comes. The tests are indeed all covered, which is why option 2 is tempting, but the ruleset does not know that. Requiring one aggregate job such as ci-ok avoids the problem.
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?
The tests are balanced, so the rest of the time is overhead that every shard pays. More shards add the same overhead again for little gain. Retries hide flakiness, and moving lint later only delays the cheapest feedback.
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?
The failure has a clear cause: the real clock. A retry turns red to green during the day and teaches nothing, and the code may have the same bug in production at night. Fix the cause with a fake or injected clock. Deleting the test throws away coverage of a real rule.
Flashcards
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 ciand 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-okcheck withif: always(), keep the bypass list empty, and add a merge queue (withmerge_group) when many PRs merge each day.
Sources
- Capabilities: Continuous integration, DORA
- Managing a merge queue, GitHub Docs
- Creating PostgreSQL service containers, GitHub Docs
- Jest CLI options, Jest documentation