Reservalia has resolved two of the four fronts, and in doing so the pipeline has put on weight: the security job from the previous lesson added two minutes, dependencies get audited, images get scanned and signed. The first front — the pipeline is slow and it is putting on weight — now comes back in full force, along with a line of Diego's the team keeps repeating: "if CI takes longer than going for a coffee, I stop watching it". This lesson is about winning that time back without losing verification. We will start with the one thing that is not negotiable: measuring before touching anything, because you optimise what you measure and not what you suspect. Then we will go through the four levers ordered by cost/benefit — cache, parallelisation, selective execution and runner choice — with their invalidation rules and their theoretical limits. We will look at the bill, which grows more quietly than the clock. And we will see what happens when the team triples in size. All of it while watching the risk that accompanies every optimisation: the false green, a blazingly fast pipeline that no longer checks what we think it does. What we will not do here is extract templates or reuse blocks: that is 04-05.
Contents
- Measure before touching: where the time goes
- Lever 1: dependency, compilation and layer caching
- Lever 2: parallelisation and Amdahl's limit
- Lever 3: selective execution in the monorepo
- Lever 4: runner choice
- The four levers compared
- The cost: minutes, storage and how to stop the bill growing on its own
- Scaling with the team: queues, merge queue and thirty people
- The cross-cutting risk: the false green
- Common Mistakes and Tips
- Exercises
- Conclusion
- Measure before touching: where the time goes
The first rule is uncomfortable because it contradicts instinct: you almost never guess where the time is. Everybody has a hypothesis — "it is the tests" — and in most pipelines the real answer is installing dependencies or building the image.
GitHub Actions gives you the data without installing anything: in the interface, each run shows the duration per job and, when you open a job, per step. To see it in aggregate, the API:
gh api "repos/reservalia/reservalia/actions/runs?branch=main&per_page=50" \
--jq '.workflow_runs[] | select(.name=="CI") | [.run_started_at, .updated_at] | @tsv' # 1
gh api "repos/reservalia/reservalia/actions/runs/$RUN_ID/jobs" \
--jq '.jobs[] | {name: .name,
seconds: ((.completed_at|fromdate) - (.started_at|fromdate))}' # 2- Fifty runs, not one. A single measurement mixes infrastructure noise with reality; with fifty you see the median and, above all, the spread. A job whose duration swings between 40 s and 4 min has a different problem from one that always takes 3 min.
- Per job and per step. The aggregate tells you how long the pipeline takes; the detail tells you what to touch.
This is the real breakdown of Reservalia's CI after adding the security job, measured over the last fifty runs on main:
| Job | Step | Median | % of the job | Observation |
|---|---|---|---|---|
quality (0:50) |
npm ci (32 s) + prettier/lint/tsc (18 s) |
50 s | 64% / 36% | Installation dominates |
test (3:00) |
npm ci |
32 s | 18% | |
| unit tests | 42 s | 23% | ||
| migrations + integration | 1:46 | 59% | The real bulk | |
build (4:05) |
npm ci (32 s) + npm run build (1:08) |
1:40 | 41% | |
docker build |
2:25 | 59% | The critical path | |
security (2:00) |
scans | 2:00 | 100% | Already runs in parallel |
publish (2:00) |
build + push to ECR | 2:00 | 100% | It rebuilds the image |
Three conclusions no intuition would have produced. The critical path is build (4:05) + publish (2:00) = 6 minutes, and within it docker build rules, not the tests. npm ci is repeated four times, 32 seconds each: it does not add to the clock because the jobs are parallel, but it does add to the billable minutes. And publish rebuilds the image build had already built: two minutes given away. The target the team sets, consistent with the 10-minute rule from 04-01, is to go from 6 minutes to 3. And a methodological warning: write down the baseline before touching anything. Without it, two months from now nobody will know whether the cache helped or whether the pipeline is faster because somebody deleted tests.
- Lever 1: dependency, compilation and layer caching
It is the lever with the best cost/benefit ratio: it takes minutes to implement and saves on every run of every job. Dependency cache. Reservalia has had it since 02-02 (cache: npm in setup-node) and 04-02 explained its key: the lockfile hash. Of the 32 seconds of npm ci, about 20 are downloading and 12 installing; with a warm cache that leaves about 14 seconds.
Compilation cache. TypeScript and Vite know how to compile incrementally if you let them keep their state:
- uses: actions/cache@v4
with:
path: |
.tsbuildinfo
apps/web/node_modules/.vite # 1
key: build-${{ runner.os }}-${{ hashFiles('**/*.ts','**/*.tsx','tsconfig*.json') }}
restore-keys: build-${{ runner.os }}- # 2- Each tool has its own state directory:
.tsbuildinfofortsc,.vitefor Vite,.turboor.nxwith a monorepo orchestrator. restore-keysis the decisive part here. An exact key match is rare — any change in any.tsinvalidates it — but recovering the previous cache lets you recompile only what changed. Withoutrestore-keys, the compilation cache almost never hits and is of no use.
Docker layer cache. It is the one that saves most at Reservalia, because docker build is 2:25 of build's 4:05.
- uses: docker/setup-buildx-action@v3 # 1
- uses: docker/build-push-action@v5
with:
context: .
file: apps/api/Dockerfile
push: true
tags: ${{ env.ECR }}/reservalia/api:${{ github.sha }}
cache-from: type=registry,ref=${{ env.ECR }}/reservalia/api:cache # 2
cache-to: type=registry,ref=${{ env.ECR }}/reservalia/api:cache,mode=max- buildx is Docker's extended builder; without it there is no cache export.
- Cache in the registry (
type=registry) rather than in GitHub (type=gha). GitHub's cache has a 10 GB limit per repository and evicts the least used entries, so in an active repository you lose it constantly; a:cachetag in ECR itself does not have that problem, it is shared between jobs and branches, and it lives where you already have access.mode=maxalso stores the intermediate layers, which is what makes the cache genuinely useful.
The order of the Dockerfile instructions is the other half of the performance, and it is free. Docker invalidates a layer and all the following ones when its inputs change, so what changes rarely goes at the top:
FROM node:20.11.0-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./ # 1 · only the manifests
RUN npm ci --omit=dev # 2 · expensive layer, reused nearly always
COPY . . # 3 · the code, last
RUN npm run build- Copying only the manifests before installing is the central trick: as long as the lockfile does not change, the
npm cilayer is reused. - If the
COPY . .came before theRUN npm ci, any change in any file would invalidate the installation and 418 packages would be reinstalled on every commit. - A
.dockerignorethat excludesnode_modules,.gitanddistreduces the context sent to the daemon, which sometimes saves more than the cache itself.
With the layer cache warm, docker build drops from 2:25 to about 40 seconds and the whole build job to 2:10.
- Lever 2: parallelisation and Amdahl's limit
Reservalia already parallelises independent jobs, since 02-07. The next step is to split the slowest job internally, which for tests is called sharding:
test:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3] # 1
steps:
- run: npm run test:unit -- --shard=${{ matrix.shard }}/3
- run: npm run test:integration --workspace apps/api -- --shard=${{ matrix.shard }}/3
test-complete: # 2
needs: [test]
if: always()
runs-on: ubuntu-22.04
steps:
- run: '[ "${{ needs.test.result }}" = "success" ] || exit 1'- Three shards, three machines. Each runs a third of the test files. The split is done by the runner (Vitest and Jest support it) and it should be by estimated duration rather than by number of files, or one shard will end up carrying all the integration tests.
- The aggregator job is essential and it is always forgotten. The branch protection's required checks are configured by job name, and with a matrix the names become
test (1),test (2),test (3). If tomorrow you move to five shards, the checkstest (4)andtest (5)do not exist in the configuration and nobody requires them: two fifths of the suite stop blocking the merge without anybody noticing. An aggregator job with a fixed name, whose result is required, solves the problem for good.
And now the limit, which stops you spending money without gaining time. Amdahl's law says the total improvement is bounded by the part that cannot be parallelised. In the test job there are 32 s of npm ci that happen in every shard and do not get divided:
| Shards | Serial part | Parallel part (2:28) | Total | Gain |
|---|---|---|---|---|
| 1 | 32 s | 2:28 | 3:00 | — |
| 2 | 32 s | 1:14 | 1:46 | −41% |
| 3 | 32 s | 49 s | 1:21 | −55% |
| 6 | 32 s | 25 s | 0:57 | −68% |
From 1 to 3 shards you gain 55%; from 3 to 6, only 13% more in exchange for doubling the spend, and from 6 to 12 barely an extra 8%. Reservalia stops at 3. The general conclusion is that parallelisation has rapidly diminishing returns: the floor is set by the serial part — installation, runner start-up, code download — and that is why the cache from the previous section also raises the ceiling of this lever.
- Lever 3: selective execution in the monorepo
The most profitable lever in a monorepo: do not run what the change cannot have broken. It requires knowing the internal dependency graph, which at Reservalia is this one:
flowchart TD
T["packages/shared-types"] --> A["apps/api"]
T --> W["apps/web"]
D[".github/ · infra/ · package-lock.json"] -.->|"affects everything"| A
D -.-> W
The rule is read upwards: a change in shared-types forces everything to be verified, because both consumers depend on it; a change only in apps/web cannot break the API; and a change in docs/ breaks nothing.
changes: # 1 · a cheap job that decides
runs-on: ubuntu-22.04
outputs:
api: ${{ steps.filter.outputs.api }}
web: ${{ steps.filter.outputs.web }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'apps/api/**'
- 'packages/shared-types/**' # 2 · the dependency, made explicit
- 'package-lock.json'
- '.github/workflows/**'
web:
- 'apps/web/**'
- 'packages/shared-types/**'
- 'package-lock.json'
build-api:
needs: [changes]
if: needs.changes.outputs.api == 'true' # 3 · the whole job is skipped
runs-on: ubuntu-22.04
steps: [ ... ]- A preliminary job of about 10 seconds works out what has changed and publishes it as
outputs: the mechanism from 04-01 applied to a decision. - The internal dependency is declared by hand in the filter, and that is the fragile point: if somebody adds a new package to
packages/and forgets to include it, the jobs will skip changes that did matter. Tools such as Turborepo or Nx derive the graph from the code itself and avoid that oversight, at the cost of one more tool to maintain. With three packages a hand-written filter is reasonable; with thirty, it is not. ifat job level skips the whole job, not just a step.
And here is the property you need to understand well: a job skipped by if reports "success" — a neutral success — not a failure. GitHub treats it as satisfied for the purposes of required checks, so a PR that only touches apps/web can be merged even though build-api never ran. It is exactly the behaviour you want, and it is also why 02-07 recommended filtering per job or per step and never filtering the whole event with on: paths: a filtered event produces no result at all, and the required check waits for ever. A note of honesty: selective execution is the lever with the highest false-green risk of the four, because its premise is "this cannot have broken" and that premise can be wrong. We will come back to it in section 9.
- Lever 4: runner choice
| Option | Advantage | Drawback | Relative cost |
|---|---|---|---|
| Standard (2 vCPU, 7 GB) | Cheap and always available | Slow on many-core tasks | ×1 |
| Large (8 or 16 vCPU) | Compiles and builds much faster | Costs 4-8 times more per minute | ×4 to ×8 |
| More jobs in parallel | Scales in width | Multiplies the npm ci runs and the start-ups |
×N |
| Self-hosted | No per-minute cost; persistent disk and cache | You have to maintain it and secure it | Fixed cost |
Three criteria for deciding. A large machine only helps if the task uses several cores: docker build and tsc do; waiting for a database does not. Paying ×4 for a job that waits on the network is throwing money away, which is why it is worth looking at CPU usage before sizing up. More machines scale better than bigger machines when the work is divisible, up to the Amdahl limit from section 3. And cold start is a fixed cost of 10 to 30 seconds per job — provisioning the machine and downloading the code: with twenty small jobs you pay several minutes just starting up, so splitting too finely stops paying off. On self-hosted runners, the warning that links back to the two previous lessons: they remove the per-minute cost and allow a persistent local cache, but they reintroduce the long-lived pipeline problem from 04-01 — state accumulates between runs — and a serious security risk: a self-hosted runner executing workflows from forks' pull requests is running strangers' code inside your network. If they are used, they must be ephemeral (a new machine per job, destroyed when it finishes) and never accept jobs from forks. Reservalia does not need them: at its volume, hosted runners work out cheaper than Nuria's time maintaining them.
- The four levers compared
Applied to Reservalia, in the order in which they are best executed, with their associated risk:
| Lever | Effort | Estimated saving | Risk | Concrete risk |
|---|---|---|---|---|
| Dependency cache | Very low | −18 s per job | Low | A badly built key: false green |
| Docker layer cache | Low | −1:45 in build |
Low | A badly ordered Dockerfile: it never hits |
Reusing the image in publish |
Low | −1:50 | Very low | None worth noting |
| Test sharding ×3 | Medium | −1:39 in test |
Medium | No aggregator job: checks not required |
| Selective execution | Medium | −2 to −4 min on partial PRs | High | An incomplete filter: what changed is not tested |
Large runner for build |
Very low | −40 s | Low | ×8 cost without measuring whether it pays |
And the result on the critical path, measured with the same yardstick:
| Job | Before | After | What achieved it |
|---|---|---|---|
quality |
0:50 | 0:35 | Dependency cache |
test |
3:00 | 1:21 | Cache + sharding ×3 |
build |
4:05 | 2:10 | Layer cache in the registry |
security |
2:00 | 2:00 | Unchanged: it was already parallel |
publish |
2:00 | 0:50 | Reuses the digest instead of rebuilding |
| Critical path | 6:05 | 3:00 |
Target met with room to spare and without removing a single verification: that is exactly what distinguishes an optimisation from a cut.
- The cost: minutes, storage and how to stop the bill growing on its own
The time is suffered by the team; the bill is suffered by somebody who is not in the conversation, and that is why it grows without anybody noticing. Three line items:
Runner minutes. They are billed per job and per started minute, so parallelising reduces the clock but increases the spend: Reservalia's 6 minutes of clock were about 12 machine-minutes across the five jobs. With 12 deployments and around 40 pull requests a week, the sum adds up on its own. Artifact and cache storage. This is the line item that surprises people. A 40 MB web-dist artifact per run, with the default 90-day retention and 200 runs a month, is about 24 GB accumulated that nobody will ever open again.
- uses: actions/upload-artifact@v4
with: { name: web-dist, path: apps/web/dist, retention-days: 7 } # 1- Lowering
retention-daysis the highest-yield measure per character typed. Seven days is enough to investigate a failure; ninety only serves to be billed for. Retention can also be set at organisation level.
The measures that stop the bill growing on its own, in order of effectiveness:
concurrencywithcancel-in-progresson PR branches: if Diego pushes three times in ten minutes, only the last one runs. With the exception ofmain, which is never cancelled (02-07).- Do not run on drafts: an
if: github.event.pull_request.draft == falseavoids the whole pipeline while the PR is still being built. timeout-minuteson every job, because a hung job consumes six billable hours before giving up by default; and short retention of artifacts, with periodic clean-up of old caches.
A criterion for budget conversations: compare the cost of the pipeline with the cost of the team's time. Five people waiting two extra minutes on each of forty weekly runs is several hours of work every week, worth considerably more than the difference between a standard runner and a large one. The conclusion is not always to spend more, but that is the honest comparison and it is almost never made.
- Scaling with the team: queues, merge queue and thirty people
A 3-minute pipeline for three people can be a 25-minute wait for thirty, without a single line of YAML changing. What changes is the contention.
| With 3 people | With 30 people |
|---|---|
| Jobs start instantly | Jobs wait because of the plan's concurrency limit |
main breaks once a month |
main breaks several times a week because of mutually incompatible PRs |
| A flaky test is annoying | A test flaky at 3% fails several times a day and blocks everybody |
| The cache nearly always hits | The cache is invalidated daily: the lockfile changes far more often |
Three concrete answers, and a counter-intuitive effect at the end. The execution queue is the first limit you hit: plans have a maximum of concurrent jobs per organisation, and once you reach it the jobs wait even though each one is lightning fast. It is detected by measuring the waiting time as well as the execution time — if it grows, the problem is not your YAML — and it is resolved by raising the limit or reducing the number of jobs per run. The merge queue from 02-07 goes from a luxury to a necessity: with thirty people merging on the same day, the situation of two separately green PRs breaking main when combined happens several times a week; the queue verifies each PR against the result of the previous ones, and its cost — more runs — is exactly what you save on a broken main. And flaky tests stop being an annoyance and become a tax: one that fails 3% of the time, with 100 runs a day, produces three re-runs a day; the quarantine policy from 02-04 — take it out of the blocking path, with a ticket, an owner and a deadline — is what stops the team learning to re-run without looking. And the counter-intuitive effect: the cache hits less the bigger the team, because the lockfile changes more often. That is another argument for well-thought-out restore-keys, which let you use a partial cache instead of losing everything over one byte.
- The cross-cutting risk: the false green
Every optimisation shares the same failure mode: the pipeline stays green but no longer checks what we think it does, and it is a silent failure because nobody investigates why CI passed.
| Optimisation | How it produces a false green | How to detect it |
|---|---|---|
| Dependency cache | A key with no lockfile hash: you test against old dependencies | Print the installed version of a key package |
| Compilation cache | A compiled artifact from another commit is reused | Compare the dates of the generated files |
| Sharding | One shard runs nothing because of a badly written pattern | Count the tests executed against the total |
| Selective execution | The filter does not cover a change that did matter | A full nightly run with no filters |
continue-on-error |
A real failure stays amber and does not block | Audit the repository's continue-on-error uses |
The two checks with the highest yield, in case you can only implement two:
# 1 · The total number of tests executed must never go down
TOTAL=$(jq '[.testResults[].assertionResults[]] | length' report-*.json | paste -sd+ | bc)
[ "$TOTAL" -ge "$MIN_EXPECTED" ] || { echo "::error::only $TOTAL tests executed"; exit 1; }- Count the tests and fail if the number drops. It is the cheapest and most effective defence against the false green there is: it covers badly configured sharding, a filter that skipped a package and a test file that stopped being discovered because of a rename. Five lines of Bash worth an entire policy. The second is the nightly pipeline from 04-01 run with no optimisations at all: no cache, no path filters and with the full suite. It is the reference run. If the nightly fails and the PR CI is green, the difference between the two is your optimisation's failure, and that diagnosis is worth more than any manual audit. And a rule of hygiene that sums up the section: every time you optimise, run the pipeline once without the optimisation and compare the reports; if the number of tests, of files analysed or of findings detected does not match, you have not optimised anything, you have stopped checking things.
Common Mistakes and Tips
Mistake 1: optimising without measuring. You attack what you suspect — the tests — and the time was in docker build or in four npm ci runs. Half an hour of measurement saves days of misdirected work. Mistake 2: not writing down the baseline, so afterwards you can neither prove the improvement nor detect the regression.
Mistake 3: a fixed cache key. key: npm-cache is never invalidated: you end up testing against months-old dependencies; the key always derives from the content. Mistake 4: caching node_modules/ instead of the download directory, with binaries compiled for another machine. Mistake 5: parallelising without an aggregator job: required checks are configured by name, and going from 3 to 5 shards means two of them stop being required without anybody noticing.
Mistake 6: excessive sharding, which multiplies the cost by the serial part and barely improves the clock (Amdahl). Mistake 7: incomplete path filters that do not include the internal packages each application depends on: the most direct route to a false green. Mistake 8: non-ephemeral self-hosted runners, which accumulate state and, if they accept forks' PRs, run strangers' code inside your network.
Tip 1: measure the waiting time as well as the execution time. If it grows, the problem is capacity and not your YAML. Tip 2: set a time budget per pipeline and treat it as such: to add three minutes, find somewhere to take them from. Tip 3: review the durations once a month, since it is the metric that degrades most quietly. Tip 4: count the tests executed on every run, the cheapest defence against the false green.
Exercises
Exercise 1
A team's CI takes 22 minutes and everybody assumes "it is the E2E tests". Describe how you would obtain the real breakdown, which three alternative hypotheses you would check first and how you would decide where to start using a cost/benefit criterion.
Exercise 2
A team enables selective execution with paths-filter. Two weeks later, a change in packages/shared-types reaches production and breaks the API, with CI green throughout the process. Explain exactly what failed, why the quality gate did not stop it and which three measures prevent it.
Exercise 3
Reservalia has to choose between two investments: (a) a 16 vCPU runner for build, which brings docker build down from 2:25 to 45 s and costs ×8 per minute; (b) implementing the layer cache in the registry, which brings it down to 40 s with a warm cache and leaves it at 2:25 when cold. Argue which to choose, in what order and under what circumstances the answer would change.
Solutions
Solution 1. How to measure: extract from the API the duration per job and per step of the last fifty runs on main, compute each step's median and spread and sort by absolute time. The median matters rather than the mean — one anomalous run distorts it — and the spread matters, because a highly variable step signals network waiting or contention, not real work. With that you identify the critical path: parallel jobs do not add up, so optimising a job that is not on the critical path does not shave a single second off the clock. The three alternative hypotheses, by probability: (1) dependency installation, repeated in every job and with no cache or with the cache missing — checkable by looking at whether the log says cache hit or cache miss; (2) building the image, with no layer cache or with a Dockerfile that copies the code before installing, so it never hits; (3) queue waiting time, which is not execution at all and is visible by comparing run_started_at with the real start of the first job.
How to decide: order the interventions by (estimated saving ÷ effort) and work through them from top to bottom, always starting with what is on the critical path. In practice, the dependency cache and the Dockerfile ordering are almost always first — hours of work, minutes saved per run and low risk; moving E2E out of the PR pipeline into the nightly one is usually second; and only then does it make sense to consider bigger runners.
Solution 2. What failed: the API job's filter declared apps/api/** but did not include packages/shared-types/**. When only the shared package changed, paths-filter decided the API was not affected, the job was skipped and — by the property in section 4 — reported neutral success. The PR showed all checks green.
The quality gate did not stop it precisely because it worked as designed: required checks verify that the job has a success result, and a skipped job has one. The gate does not distinguish "verified and correct" from "not verified". That is the explicit price of selective execution, and it is why it is the highest-risk lever of the four. The three measures: (1) complete the filter so that every consumer includes the paths of its internal dependencies, and add CODEOWNERS over .github/workflows/ so that a change to the filters is reviewed by somebody who understands the graph; (2) replace the hand-written filter with a tool that derives the graph from the code (Turborepo, Nx or npm's own workspaces), so that adding a package does not require remembering anything; and (3) run the full suite with no filters in the nightly pipeline, which would have caught the problem in under 24 hours even if the first two measures had failed. In addition, the test-count check from section 9 would have given the signal in the PR itself: going from 480 tests to 120 is a visible figure.
Solution 3. The right answer is (b) first, and (a) probably never, for four reasons.
Cost. The cache is a configuration: it takes half an hour to implement and adds not a penny per minute. The large runner multiplies by eight the cost of a job that runs on every pull request and every push to main: around 240 runs a month at Reservalia. Result. They are equivalent in the good case — 45 s against 40 s — and the cache wins in the typical case, because most commits touch neither the lockfile nor the first layers of the Dockerfile, which is exactly when the cache hits.
Compatibility. They are not mutually exclusive, but they apply in order: with a warm cache, the remaining work in docker build is so small that a large runner barely improves it, so optimising first and sizing up later avoids paying ×8 for work you could simply not do. Risk. The large runner's risk is purely economic; the cache's is a false green if the key is badly built, and it is mitigated by section 9 and by a property of Docker layers: they are identified by the hash of their content, so their invalidation is cryptographic and does not depend on any convention.
When the answer would change: if the cold docker build were on the critical path of something urgent — a rollback requiring a rebuild; if the build used multi-core native compilation (Rust, C++, multi-platform images), where 16 vCPUs give an improvement no cache can; or if the team published dozens of different images and the cache hit rarely. At Reservalia, with a single service and small commits, none of that applies.
Conclusion
The first front is closed, and with numbers: Reservalia's CI has gone from 6 minutes to 3, without removing a single verification. It was achieved, in this order, by the cache — dependencies by lockfile hash, compilation with restore-keys, and Docker layers exported to the registry rather than to GitHub's store, backed by a Dockerfile that copies the manifests before the code; by sharding the suite into three shards, with a fixed-name aggregator job so the required checks stay required; by selective execution based on the monorepo's graph, with paths-filter at job level and neutral success as the key piece; and by a conscious decision about the runner, which in this case was not to spend more. But the underlying lesson is none of the four levers: it is the method. Measure first — per job and per step, over fifty runs, writing down the baseline — because intuition pointed at the tests and the time was in docker build and four repeated npm ci runs. Then order by cost/benefit and apply from top to bottom, always on the critical path. And check after every step that you are still verifying the same things, because all optimisations share the same failure mode: the false green. Counting the tests executed and keeping an unoptimised nightly run as the reference are the two defences that cost almost nothing and catch almost everything. To that you add an eye on cost — minutes, artifact retention, concurrency, drafts — and foresight about what changes when the team triples: execution queues, merge queue and flaky tests that go from annoyance to tax.
There remains, however, a problem no optimisation fixes and that this lesson has made worse. ci.yml already has five jobs and, between it, cd.yml, infra.yml, rollback.yml and nightly.yml, the same block of checkout, setup-node and npm ci with its cache appears copied six times: every improvement we have introduced had to be applied six times, and the next person to touch one of them will leave the other five behind. On top of that, module 5 is going to add a mobile application and some microservices that need exactly the same thing. The next lesson, Pipeline as Code: Templates, Reuse and Testing the Pipeline, treats the pipeline as what it is — production code — and applies to it what is applied to code: extracting the repeated parts into composite actions and reusable workflows, versioning them, reviewing them and, above all, testing them, because until today the only way Reservalia has had to test a pipeline change has been to merge it and watch.
CI/CD Course: Continuous Integration and Deployment
Module 1: Introduction to CI/CD
- Basic CI/CD Concepts
- Benefits of CI/CD
- Popular CI/CD Tools
- The Course Project: the Application We Are Going to Automate
- DORA Metrics: How Software Delivery Is Measured
Module 2: Continuous Integration (CI)
- Introduction to Continuous Integration
- Setting Up a CI Environment
- Build Automation
- Automated Testing
- Code Quality and Static Analysis
- Artifacts, Versioning and Promotion
- Integration with Version Control
Module 3: Continuous Deployment (CD)
- Introduction to Continuous Deployment
- Deployment Automation
- Infrastructure as Code and Reproducible Environments
- Deployment Strategies
- Feature Flags, Rollback and Failure Recovery
- Monitoring and Feedback
Module 4: Advanced CI/CD Practices
- CI/CD Pipelines
- Dependency Management
- Security in CI/CD
- Scalability and Performance
- Pipeline as Code: Templates, Reuse and Testing the Pipeline
- Databases in the Pipeline: Safe Migrations
Module 5: Implementing CI/CD in Real Projects
- Case Study: Web Project
- Case Study: Mobile Application
- Case Study: Microservices
- Case Study: Modernising a Legacy Project
Module 6: Tools and Technologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker and Kubernetes
- GitHub Actions in Depth
- Comparison and Criteria for Choosing a Tool
Module 7: Practical Exercises
- Exercise 1: Setting Up a Basic Pipeline
- Exercise 2: Integrating Automated Tests
- Exercise 3: Deploying to a Production Environment
- Exercise 4: Monitoring and Feedback
- Exercise 5: Hardening the Pipeline with Security and Secrets
- Final Project: A Complete End-to-End Pipeline
