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

  1. Why the branching model decides your CI's fate
  2. Trunk-based, GitHub Flow and Git Flow head to head
  3. Short branches and pull request size
  4. Protecting main: required checks, CODEOWNERS and the merge queue
  5. Avoiding useless runs: paths and concurrency
  6. Merge strategies, traceability and lead time
  7. Ephemeral preview environments per PR
  8. Reservalia's complete ci.yml
  9. Common Mistakes and Tips
  10. Exercises
  11. Conclusion

  1. 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.

  1. 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.

  1. 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:

  1. PR 1: a migration adding the recurrence column to appointments, without using it. Zero risk.
  2. PR 2: the Recurrence type in shared-types and the calculation logic, with its unit tests. Nobody calls it yet.
  3. PR 3: the endpoint that uses it, behind a disabled flag.
  4. PR 4: the interface in apps/web, also behind the flag.
  5. 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.

  1. Protecting main: required checks, CODEOWNERS and the merge queue

The 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/nuria

Combined 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.

  1. Avoiding useless runs: paths and concurrency

Every 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 one

github.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.

  1. 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 main commit = one artifact. Since the publish job tags the image with the SHA, a main with one commit per PR makes reservalia/api:a3f9c21 correspond exactly to one reviewable pull request. With merge commits, a single merge introduces several SHAs and the correspondence blurs.
  • git bisect works well. Every main commit 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.

  1. 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.

  1. Reservalia's complete ci.yml

This 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:

  1. quality, test and build run 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.
  2. publish depends on all three and only runs on main. A PR never publishes; a green main always leaves an artifact.
  3. 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 main that is always deployable, stop the line and fast feedback.
  • A ci.yml that triggers on every pull request and every push to main, on pinned runners, with Node read from the .nvmrc and a PostgreSQL 16.3 with a healthcheck for the tests.
  • A reproducible build with npm ci and the lockfile, and a multi-stage Dockerfile with 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 quality job with Prettier, ESLint at --max-warnings 0 and tsc --noEmit, and a realistic plan for reducing debt without stopping the team.
  • A publish job that pushes to ECR an immutable artifact identified by the SHA, ready to be promoted without rebuilding, and a /version endpoint for always knowing what is running.
  • And the main protection rules that turn the agreements into something the system enforces, together with CODEOWNERS, concurrency and 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

Module 2: Continuous Integration (CI)

Module 3: Continuous Deployment (CD)

Module 4: Advanced CI/CD Practices

Module 5: Implementing CI/CD in Real Projects

Module 6: Tools and Technologies

Module 7: Practical Exercises

Module 8: Additional Resources

© Copyright 2026. All rights reserved