Reservalia now has four jobs, real tests, static analysis and an artifact published to ECR. And yet all of that can still fail to work for a reason that has nothing to do with the YAML: if branches live for three weeks, there is no continuous integration no matter how good the pipeline is. This last lesson of the module closes the circle by connecting the tool with how the team works. We will compare the three most widespread branching models and see which fits CI/CD and which does not; we will configure in GitHub the rules that turn the agreements from lesson 02-01 into something that does not depend on goodwill; we will learn to avoid useless runs with paths and concurrency; we will analyse what effect each merge strategy has on the artifact's traceability and on the lead time calculation; and we will finish with Reservalia's complete ci.yml, with its four jobs and their dependencies, and with a stocktake of what the module has achieved.
Contents
- Why the branching model decides your CI's fate
- Trunk-based, GitHub Flow and Git Flow head to head
- Short branches and pull request size
- Protecting
main: required checks,CODEOWNERSand the merge queue - Avoiding useless runs:
pathsandconcurrency - Merge strategies, traceability and lead time
- Ephemeral preview environments per PR
- Reservalia's complete
ci.yml - Common Mistakes and Tips
- Exercises
- Conclusion
- Why the branching model decides your CI's fate
Go back to the integration hell from lesson 01-01: the cost of merging grows non-linearly with the time branches live apart. That fact alone already determines which branching models are compatible with CI.
A long-lived branch is a large batch in disguise. While it lives, nothing is being integrated — the pipeline verifies that branch against a main that drifts further away every day; the green is deceptive, because "my branch passes" does not mean "it will pass when merged with what has landed in the meantime"; and risk accumulates: ten small changes merged over two weeks are diagnosed one at a time, whereas the same ten merged all at once form a single failure with ten possible causes.
The rule that sums up this section. Integration frequency is a variable of the process, not of the tool. No pipeline can compensate for three-week branches.
- Trunk-based, GitHub Flow and Git Flow head to head
| Trunk-based | GitHub Flow | Git Flow | |
|---|---|---|---|
| Permanent branches | main |
main |
main + develop |
| Temporary branches | Very short or none | One per feature | feature/, release/, hotfix/ |
| Branch lifetime | Hours, max. 1 day | 1-3 days | Weeks |
| Integration frequency | Several times a day | Daily | Weekly or less |
| Merge cost | Almost nil | Low | High |
| Fit with CI/CD | Excellent | Very good | Poor |
| Mental complexity | Low | Low | High |
| Requires feature flags | Yes, often | Sometimes | Rarely |
| Context where it shines | Teams with mature CI and continuous deployment | Most product teams | Software with versions maintained in parallel |
Three clarifications that avoid misunderstandings:
Git Flow is not "bad", it is designed for a different problem. It was born in 2010 for software distributed in versions that maintains several at once — think of a desktop application supporting 3.x while developing 4.0. In that context, the release/ and hotfix/ branches make sense. Applying it to a SaaS with a single deployment, like Reservalia, adds a develop branch whose only function is to delay integration.
GitHub Flow is the sweet spot for most teams. One short branch per change, a pull request, automated verification, review and merge to main. It is what Reservalia does, and it is what we have assumed throughout the module.
Trunk-based does not mean "no branches and no review". It means branches that live hours and daily merges. When a change cannot be finished in a day, it is integrated incomplete but inactive, hidden behind a feature flag — the technique from lesson 03-05. That is the skill to learn, and it is not a question of discipline.
- Short branches and pull request size
There is one variable that predicts review time better than any other: the number of lines changed.
| PR size | Typical time to merge | Review quality |
|---|---|---|
| < 100 lines | Hours | High: it actually gets read |
| 100-400 lines | 1 day | Acceptable |
| 400-1,000 lines | 2-4 days | Low: skimmed diagonally |
| > 1,000 lines | A week or more | Almost nil: "LGTM" |
And it works as a self-reinforcing loop: a big PR takes a long time to review, and while it waits it accumulates conflicts with main, which forces parts to be redone, which makes it bigger. The way out of the loop is splitting the work.
How it is split in practice, with the example of Reservalia's recurring bookings:
- PR 1: a migration adding the
recurrencecolumn toappointments, without using it. Zero risk. - PR 2: the
Recurrencetype inshared-typesand the calculation logic, with its unit tests. Nobody calls it yet. - PR 3: the endpoint that uses it, behind a disabled flag.
- PR 4: the interface in
apps/web, also behind the flag. - PR 5: enabling the flag.
Five PRs of 100-200 lines, each reviewable in one sitting, each integrated the same day. And a valuable property: if something goes wrong, you know exactly which of the five broke it.
- Protecting
main: required checks, CODEOWNERS and the merge queue
main: required checks, CODEOWNERS and the merge queueThe agreements from lesson 02-01 — "nobody pushes to main", "nothing red gets merged" — still depend on everybody remembering. Branch protection rules turn them into something the system enforces.
Reservalia's configuration for main:
| Rule | What it prevents | Why |
|---|---|---|
| Require a pull request before merging | A direct git push to main |
Every change goes through verification and review |
Require status checks to pass: quality, test, build |
Merging while red | It is rule 3 of the agreement, no longer negotiable |
| Require branches to be up to date | Merging onto an old base | Prevents the "green that breaks on merge" |
| Require 1 approval | Merging your own code with no review | A second pair of eyes on the design |
| Dismiss stale approvals | Approving and then changing the code | The approval refers to a specific diff |
| Require linear history | A spaghetti-shaped history | A linear main is readable and bisectable |
| Block force pushes | Rewriting main's history |
A force-push to main is irreversible in practice |
| Include administrators | Marta skipping her own rules | One exception turns the rule into a suggestion |
CODEOWNERS declares who must review depending on the path touched. It is stored in .github/CODEOWNERS:
# By default, any change is reviewed by the team
* @reservalia/team
# Migrations and the pipeline need expert eyes
apps/api/src/db/migrations/ @reservalia/marta @reservalia/nuria
.github/workflows/ @reservalia/nuria
infra/ @reservalia/nuriaCombined with "require review from Code Owners", it guarantees that a change to a migration is not merged without somebody who understands the consequences seeing it.
The merge queue solves a real and little-known problem. Scenario: PRs A and B are both green, both based on the same main. A is merged. B still reports green, but it was never tested alongside A. If A renamed a function B uses, main turns red after the merge, without any PR ever having been red.
flowchart LR
A["PR A green<br/>base: main@X"] --> Q["merge queue"]
B["PR B green<br/>base: main@X"] --> Q
Q --> C1["tests A on main@X"] --> C2["tests B on main@X+A"]
C2 -- green --> M["merge of A and B"]
C2 -- red --> R["B leaves the queue<br/>main stays healthy"]
The queue builds a temporary branch with the changes already queued and verifies each PR against the result of the previous ones. With two or three PRs a day it does not pay off; from around ten a day on the same repository, it stops main breaking several times a week. Reservalia does not need it today; it would with fifteen developers.
- Avoiding useless runs:
paths and concurrency
paths and concurrencyEvery run costs minutes and, above all, costs attention. Two mechanisms stop you wasting them.
concurrency: cancelling stale runs. Diego pushes three times in a row to his branch within ten minutes. With no configuration, you will have three full pipelines running at once, and only the last one matters:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }} # one queue per branch
cancel-in-progress: true # cancels the previous onegithub.ref identifies the branch, so each branch has its own queue and PRs do not cancel each other. An important warning: on main it is best not to cancel (cancel-in-progress: false or a condition on the branch), because each main commit produces a publishable artifact and you do not want to lose any.
paths: not running what does not apply. A change to a .md does not need images built. The robust approach is not filtering the whole event — we already saw in 02-02 that this leaves required checks waiting forever — but detecting what has changed and skipping the expensive work, while keeping the check green:
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
code:
- 'apps/**'
- 'packages/**'
- 'package-lock.json'
- name: Build the image
if: steps.changes.outputs.code == 'true' # ← the step is skipped, the job goes green
run: docker build -f apps/api/Dockerfile .That way the build check always reports a result, and the documentation PR gets merged without waiting three minutes for nothing.
- Merge strategies, traceability and lead time
How a PR is integrated into main has consequences that go beyond style.
| Strategy | What it leaves in main |
Effect on traceability | Effect on lead time |
|---|---|---|---|
| Merge commit | All the PR's commits + a merge commit | Complete but branched history; git bisect gets complicated |
The first commit can be much older than the merge |
| Squash | A single commit per PR | One commit = one change = one artifact; ideal for bisect | The intermediate dates are lost |
| Rebase | The PR's commits, rewritten on top of main |
Linear and detailed history | Each commit keeps its original date |
Reservalia uses squash, and the reasons are concrete:
- One
maincommit = one artifact. Since thepublishjob tags the image with the SHA, amainwith one commit per PR makesreservalia/api:a3f9c21correspond exactly to one reviewable pull request. With merge commits, a single merge introduces several SHAs and the correspondence blurs. git bisectworks well. Everymaincommit is a complete, verified state. With merge commits you trip over broken intermediate commits ("wip", "fixing the test").- Working commits stop mattering. Diego can make fifteen commits with rough-and-ready messages; what gets written down is the PR title, which is what commitlint validates (lesson 02-05).
Now the effect on the lead time for changes from lesson 01-05, which is calculated from the commit date to the deployment:
- With squash, the resulting commit is born at merge time, so the measured lead time covers only merge → deployment and underestimates the real time: it does not count the time the change spent waiting for review.
- With merge commit or rebase, the first commit's date is preserved and the lead time includes all the waiting, so it reflects reality better.
If you use squash — like Reservalia — the honest measurement consists of taking as the origin the date of the PR's first commit (a value GitHub's API provides) instead of the merged commit's. It is not a minor detail: it is the difference between measuring 40 minutes and measuring the real 6.2 days of the baseline.
- Ephemeral preview environments per PR
A preview environment is a temporary, isolated deployment of a pull request's code, with its own URL: pr-482.dev.reservalia.com. It is born when the PR is opened and destroyed when it is closed.
What it is for, in order of real value: Marta opens the link and sees the change working instead of imagining it while reading the diff; the critical E2E tests from 02-04 can be run against it; and somebody from the business side validates the change without installing anything.
And what you must bear in mind before building one: it costs money (every open PR consumes resources, and without automatic destruction they pile up), it needs data (a database per environment, populated with fictional data: never a copy of production with real customer data) and not everything deserves one: at Reservalia it makes far more sense for apps/web, which is visual and cheap to deploy to S3, than for apps/api.
The mechanics of deploying and destroying are the subject of module 3; here it is enough to know the concept and to know that the artifact is already ready to feed it.
- Reservalia's complete
ci.yml
ci.ymlThis is the result of the whole module, with the already-explained parts summarised so the structure is visible:
# .github/workflows/ci.yml
name: CI
on:
pull_request: { branches: [main] }
push: { branches: [main] }
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} # never cancel main
env:
TZ: Europe/Madrid
jobs:
quality: # ~50 s
name: Quality
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
# checkout · setup-node (.nvmrc + cache) · npm ci
- run: npx prettier --check .
- run: npm run lint # --max-warnings 0
- run: npm run typecheck # tsc --noEmit
test: # ~3 min
name: Tests
runs-on: ubuntu-22.04
timeout-minutes: 15
services:
postgres:
image: postgres:16.3
env: { POSTGRES_USER: reservalia, POSTGRES_PASSWORD: ci, POSTGRES_DB: reservalia_test }
ports: ['5432:5432']
options: --health-cmd "pg_isready -U reservalia -d reservalia_test" --health-retries 10
env:
DATABASE_URL: postgres://reservalia:ci@localhost:5432/reservalia_test
steps:
# checkout · setup-node · npm ci
- run: npm run test:unit --workspaces --if-present
- run: npm run migrate --workspace apps/api
- run: npm run test:integration --workspace apps/api
- run: npm run test --workspace apps/api -- --coverage
build: # ~4 min
name: Build
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
# checkout · setup-node · npm ci
- run: npm run build
- run: test -f apps/api/dist/index.js && test -d apps/web/dist
- uses: docker/build-push-action@v5
with: { context: ., file: apps/api/Dockerfile, push: false,
cache-from: 'type=gha', cache-to: 'type=gha,mode=max' }
publish: # ~2 min · main only
name: Publish artifact
runs-on: ubuntu-22.04
needs: [quality, test, build]
if: github.ref == 'refs/heads/main'
permissions: { id-token: write, contents: read }
steps:
# checkout · AWS OIDC credentials · ECR login
- uses: docker/build-push-action@v5
with: { context: ., file: apps/api/Dockerfile, push: true,
tags: '${{ steps.ecr.outputs.registry }}/reservalia/api:${{ steps.meta.outputs.short_sha }}' }flowchart LR
E["pull_request / push to main"] --> Q["quality ~50 s"]
E --> T["test ~3 min"]
E --> B["build ~4 min"]
Q --> P["publish ~2 min<br/>main only"]
T --> P
B --> P
Three properties of this design worth underlining:
quality,testandbuildrun in parallel. The total time for a PR is that of the slowest job — around 4 minutes — not the sum of the three. Well below the 10-minute limit the team set.publishdepends on all three and only runs onmain. A PR never publishes; a greenmainalways leaves an artifact.- The first three are the required checks in the branch protection rule from section 4. Without them green, the merge button is disabled.
Common Mistakes and Tips
Mistake 1: long-lived branches with an excellent pipeline. It is the mistake that sums up the module. No YAML compensates for integrating every three weeks.
Mistake 2: adopting Git Flow out of habit. If your product has a single deployment and you do not maintain old versions, the develop branch only serves to delay integration. Mistake 3: not ticking "include administrators"; the day somebody with permissions skips the check "because it is urgent", the rule ceases to exist for everyone.
Mistake 4: filtering with paths an event whose check is required. The PR sits waiting forever for a result that will never arrive. Filter at step level, not at event level.
Mistake 5: cancelling runs on main. cancel-in-progress: true with no exception for main leaves you with merged commits and no published artifact.
Tip 1: measure the average size of your PRs. If it is over 400 lines, the team's problem is not the pipeline: it is how the work is split.
Tip 2: review pipeline times every month. It is the metric that degrades most quietly. Treat it as a budget: to add three minutes, find somewhere to take them from.
Tip 3: write the protection rules into the README.md; the team knowing what is required and why reduces friction and requests for exceptions.
Exercises
Exercise 1
A team of 6 people develops a SaaS with a single deployment. They use Git Flow: feature/ branches of 2-3 weeks, develop, weekly release/ branches and hotfix/. They have a complete, well-made pipeline. Explain three reasons why they are not practising continuous integration and propose the transition, stating what to change first.
Exercise 2
PRs A and B are green on main@X. A renames calculateSlots to calculateAvailability and updates its call sites. B adds a new call to calculateSlots. Both are merged. Describe what happens, why no protection rule prevented it and which two mechanisms would have stopped it.
Exercise 3
Reservalia measures a lead time of 41 minutes, but Marta knows a change takes days from when it is started. Explain where the discrepancy comes from and how to correct the measurement.
Solutions
Solution 1. Three reasons: (1) the 2-3 week feature/ branches violate the central practice of integrating at least daily, so the pipeline verifies branches that diverge ever further from develop; (2) develop acts as an intermediate warehouse: the code is "integrated" there, but main — what is actually deployed — only receives it weekly, so real integration is weekly; (3) the release/ branches imply that the software is stabilised after being developed, the opposite of "the main branch is always deployable".
Transition, in order: first shorten the branches (split the work into PRs of under 400 lines, with feature flags for anything incomplete), because that is the change that produces the benefit and the hardest one; second, remove develop and have PRs go straight to main; third, drop the release/ branches by deploying from main when appropriate; fourth, enable the protection rules with the required checks. Starting with the configuration without shortening the branches would change nothing.
Solution 2. When A is merged, main no longer has calculateSlots. B still reports green because it was verified against main@X, where the function still existed; when it is merged, main breaks: the typecheck in the quality job will fail and probably the tests too. No PR was ever red.
No rule prevented it because the rules check the PR's state, not the result of combining it with what has been merged in between. The two mechanisms that stop it: (a) require branches to be up to date before merging, which forces B to update with main — and then its pipeline fails before the merge, as it should; and (b) the merge queue, which tests B on top of A's result before merging it and drops it from the queue if it fails. The first is free and enough for small teams; the second scales better with a high PR volume.
Solution 3. The discrepancy comes from the squash strategy. On merging, git creates a new commit whose date is the merge date, so the git show -s --format=%cI we used in 01-05 measures only the merge → deployment stretch, which at Reservalia is around 41 minutes: the pipeline and deployment time. Everything before that — development, waiting for review, corrections — disappears from the calculation.
The fix: take as the origin the date of the pull request's first commit, available in GitHub's API, and store it in the deployments table alongside the SHA. Lead time then gets measured from when the work genuinely started. As a cross-check, it is worth comparing that figure with the average time between a PR being opened and merged; if they differ a lot, the bottleneck is before the PR is opened.
Conclusion
This lesson closes module 2, and it is worth looking at where we have come from. At the start, Reservalia had nothing: .github/workflows/ was empty, Diego built on his laptop and the tests ran "whenever someone remembered". Now:
- The team has six written rules predating a single line of YAML, and the practices that constitute genuine CI: daily integration, an automated build, a
mainthat is always deployable, stop the line and fast feedback. - A
ci.ymlthat triggers on every pull request and every push tomain, on pinned runners, with Node read from the.nvmrcand a PostgreSQL 16.3 with a healthcheck for the tests. - A reproducible build with
npm ciand the lockfile, and a multi-stageDockerfilewith a pinned base image, a non-root user and a.dockerignore. - Unit and integration tests with a clear criterion for what blocks the merge, coverage treated as a signal and a quarantine policy for flaky tests.
- A
qualityjob with Prettier, ESLint at--max-warnings 0andtsc --noEmit, and a realistic plan for reducing debt without stopping the team. - A
publishjob that pushes to ECR an immutable artifact identified by the SHA, ready to be promoted without rebuilding, and a/versionendpoint for always knowing what is running. - And the
mainprotection rules that turn the agreements into something the system enforces, together withCODEOWNERS,concurrencyand a merge strategy — squash — chosen for its consequences on traceability, not out of taste.
As for the DORA metrics from lesson 01-05, the module has mainly attacked the change failure rate: every change is verified on a clean machine before being merged. The lead time has improved too, though only partly, because PRs are smaller. But the other two metrics remain untouched, and the reason is simple: the artifact exists, it is published and verified… and Diego is still the one who deploys it by hand. The Friday ritual continues. We have automated the left half of the diagram we drew in 01-01 and we have not touched the right half.
That is exactly what starts in module 3, Continuous Deployment (CD). The first lesson, Introduction to Continuous Deployment, revisits the distinction between continuous delivery and continuous deployment — now with a real pipeline in front of us — and defines what a team needs before letting a machine touch production: reproducible environments, safe migrations, the ability to go back and enough observability to find out before the customer does. From there, the reservalia/api:a3f9c21 artifact currently waiting in ECR will start travelling on its own to dev, staging and prod, and Friday afternoon will go back to being, simply, Friday afternoon.
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
