The three previous lessons described three different workflows, and all three ended up pointing at the same dependency: automated checks you can trust. Git Flow needs them least because it has a manual QA phase on the release branch. GitHub Flow requires them in order to be able to claim that main is deployable. Trunk Based Development requires them fast and flawless, because the trunk receives changes every hour.

This lesson builds that piece. And it closes two promises left over from the course: the one from lesson 06-01, where we saw that client-side hooks are not a security control because anybody can dodge them with --no-verify, and where we announced the family of hooks that run on the server; and the one from 07-04, where a protected branch hook declined turned up without a full explanation of what it was.

It is also worth setting the scope from the outset. Here we are talking about continuous integration: automatically checking that what gets integrated works. Not about continuous delivery or deployment — how that code reaches production, with which environments, what promotion strategy and how it gets rolled back — which are the content of lesson 10-05: Git in DevOps. The border sits exactly where the code is declared fit to be integrated.

Contents

  1. What continuous integration is and what it is not
  2. How it hooks into Git: the triggers
  3. Which commit really gets checked in a pull request
  4. An example pipeline, explained line by line
  5. Status checks and protected branches
  6. Server-side hooks: pre-receive, update and post-receive
  7. Client, server and CI: who checks what
  8. Keeping CI fast
  9. Merge queue: integrating without races
  10. What is left out: deployment

  1. What continuous integration is and what it is not

Let us start with the misunderstanding, because it is almost universal.

"We already do continuous integration: we have a server that runs the tests."

That is not continuous integration. It is test automation, which is a very good thing and is a requirement, but it is not the same thing.

The original definition, from Martin Fowler and the extreme programming movement, is this:

Continuous integration is the practice whereby the members of a team integrate their work frequently, at least daily, and each integration is verified by an automated build and test suite in order to detect integration errors as soon as possible.

The word that carries all the meaning is integrate. A team where each person works for three weeks in their own branch, with a CI server that religiously tests those isolated branches, is not doing continuous integration. It is continuously testing three divergent versions of the project that have not yet met each other. The day they do meet, all the problems appear at once: which is exactly what the practice was meant to avoid.

That is why this lesson comes after 07-05 and not before. The branching policy is the hard part; the checking server is the easy part.

It is continuous integration It is not
The whole team integrates into the mainline daily Having a CI server
Every integration is verified automatically Running the tests before a monthly release
The mainline is always healthy The tests passing in each person's branch
A failure is fixed immediately, as a priority Piling up a board full of red checks
The answer arrives in minutes A two-hour checking cycle

And the four practical rules that hold it up:

  1. Integrate often, at least once a day per person (lesson 07-05).
  2. Every integration triggers the verification, with no exceptions and no manual exclusions.
  3. Fixing the mainline is the top priority. If main is red, everything stops. If it is not fixed in minutes, it gets reverted (lesson 05-06).
  4. The answer must be fast, or the loop stops closing (section 8).

  1. How it hooks into Git: the triggers

The CI system does not guess when to work: it reacts to Git events. Those events are the triggers, and they are the contact surface between the two things.

The three fundamental ones:

The push trigger

The most basic one. Every time somebody updates a reference on the server, the checks are launched on that commit.

# Trigger on every push to any branch
on:
  push:
    branches:
      - '**'

And the usual variants, which in a real project get combined:

on:
  push:
    # Only on the mainline and on the working branches
    branches:
      - main
      - 'feature/**'
      - 'fix/**'
    # Ignore changes that cannot break anything
    paths-ignore:
      - '**.md'
      - 'docs/**'

The paths-ignore deserves a comment: filtering by path saves machine time, but use it carefully. If the filter excludes a file that does affect the result (a configuration, a data file), you will have unchecked integrations and you will not know it. And if that check is required on the protected branch, the PR can end up waiting for ever for a check that will never be launched. It is a classic and baffling problem.

The pull request trigger

It is launched when a PR is opened and every time commits are added to it. It is the trigger that holds up the review: whoever is reviewing wants to see the result before approving.

on:
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened, ready_for_review]

The event types matter:

Event When it happens
opened The PR is opened
synchronize New commits arrive on the branch: the most frequent case
reopened A closed PR is reopened
ready_for_review It stops being a draft (lesson 07-01)

The tag trigger

The tags of lesson 05-05 are the natural trigger for everything to do with releasing a version:

on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'      # v2.4.0, but not v2.4.0-beta

Remember from 05-05 that tags do not travel on their own: git push origin main does not send them. If your release depends on a tag, the command is git push origin main --follow-tags or git push origin v2.4.0. It is a very common cause of "I tagged it and nothing happened".

Other triggers

Trigger Typical use
Scheduled (cron) The full nightly suite, dependency auditing
Manual Re-running a check, running something on demand
From another pipeline Chaining phases
External webhook Reacting to events from other systems

A very widespread and highly recommended pattern: a fast suite on every push and every PR, the full suite at night. We shall look at it in section 8.

  1. Which commit really gets checked in a pull request

This section explains one of the most frequent surprises in the whole module.

Ana opens a PR from feature/filter-by-label towards main. CI runs and comes out green. Ana merges… and main goes red. How is that possible, if it had just passed?

And sometimes the opposite happens: Ana has not touched anything, but when she gets back from lunch her PR's CI has gone from green to red without her having made a single commit.

The explanation is the same in both cases, and it is this:

In a pull request, CI normally does not check your branch. It checks the result of merging your branch with the target branch.

That is: it does not test D2 (your branch's tip), it tests an ephemeral merge commit between D2 and the current tip of main.

gitGraph
   commit id: "C1"
   commit id: "C3 (branch base)"
   branch feature/filter
   checkout feature/filter
   commit id: "D1"
   commit id: "D2 (your tip)"
   checkout main
   commit id: "C4"
   commit id: "C5 (current tip)"
   merge feature/filter id: "M (what CI tests)"

On GitHub, that commit is exactly the refs/pull/<n>/merge reference we saw in lesson 07-01: the server calculates it and keeps it up to date every time either side changes.

# Fetch exactly what CI is testing
git fetch origin pull/42/merge:what-the-ci-tests
git switch what-the-ci-tests

That command is the best diagnosis when CI fails and you cannot reproduce the failure locally: you are probably running pull/42/head while CI runs pull/42/merge.

Why it is done this way, and why it is right: what matters is not whether your branch works in isolation, but whether it will work once integrated. Checking the merge catches the semantic conflicts we talked about in lesson 07-05: Carla renamed a function in main, you added a call under the old name in your branch, there is no textual conflict, and the result is broken. Testing only your branch would never see it.

The three practical consequences, which explain the two mysteries at the start:

  1. Your PR's result can change without you doing anything, because main has moved on. It is not a fault in the system: it is valuable information you have just received for free.
  2. Green on the PR does not guarantee green on main when you merge if main has moved on between the last check and the merge. It is the race the merge queue in section 9 solves.
  3. If there are conflicts, the merge commit cannot be calculated and CI does not run at all. First the conflicts get resolved (lesson 03-05), then there is a signal again.

And an important nuance for lesson 07-01: on GitLab this is called merged results pipelines and it is configurable; some platforms test only the branch tip by default. Find out which yours does, because it completely changes how you interpret a red.

A security restriction, picking up 07-01. When the PR comes from a fork, the code has been written by somebody outside. If that run had access to the project's credentials, anybody could steal them by opening a PR that prints the environment variables. That is why platforms run PRs from forks in restricted mode, with no secrets and read-only permissions, and often require a team member to authorise each run. It is inconvenient and it is essential.

  1. An example pipeline, explained line by line

We are going to build task-manager's pipeline. The file uses the most common syntax and event names, but the concepts are identical on any platform: what changes is the words, not the ideas.

# .ci/pipeline.yml — Continuous integration for task-manager
name: Continuous integration

# 1. TRIGGERS: when this runs
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# 2. CONCURRENCY: cancel stale runs of the same branch
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # 3. FIRST JOB: fast checks
  fast-checks:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      # 3.1 Fetch the code
      - name: Check out the code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0        # full history: needed for step 3.5

      # 3.2 Prepare the environment
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'          # cache ~/.npm between runs

      # 3.3 Install dependencies reproducibly
      - name: Install dependencies
        run: npm ci

      # 3.4 Static analysis
      - name: Run the linter
        run: npm run lint

      # 3.5 Check the format of the commit messages
      - name: Validate the commit messages
        if: github.event_name == 'pull_request'
        run: |
          BASE="${{ github.event.pull_request.base.sha }}"
          git log --format=%s "$BASE..HEAD" | while read -r subject; do
            if ! echo "$subject" | grep -qE '^(GT-[0-9]+|Merge) '; then
              echo "Message with no ticket reference: $subject"
              exit 1
            fi
          done

  # 4. SECOND JOB: tests on several systems, in parallel
  tests:
    runs-on: ${{ matrix.os }}
    timeout-minutes: 20
    strategy:
      fail-fast: false          # one system must not cancel the others
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - name: Run the test suite
        run: npm test -- --coverage

      # 4.1 Save the results even if the tests fail
      - name: Publish the coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-${{ matrix.os }}
          path: coverage/

  # 5. THIRD JOB: check the submodules
  submodules:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive    # fetch ui-components
      - name: Check that the submodule points at a published commit
        run: |
          cd ui-components
          git fetch origin main
          git merge-base --is-ancestor HEAD origin/main \
            || { echo "ui-components points at a commit that is not on main"; exit 1; }

  # 6. THE FINAL GATE: a single job that summarises the result
  all-green:
    runs-on: ubuntu-latest
    needs: [fast-checks, tests, submodules]
    if: always()
    steps:
      - name: Verify that nothing has failed
        run: |
          [ "${{ contains(needs.*.result, 'failure') }}" = "false" ] || exit 1
          echo "All the checks have passed."

Now, why each piece is where it is.

1. Triggers. It runs on every push to main and on every PR against main. Not on any branch: the PR already covers the working branches, and running both duplicates the cost without adding signal.

2. Concurrency. If Ana pushes three commits in five minutes, without this three full runs are launched and only the last one matters. cancel-in-progress cancels the stale ones. In an active team this alone halves the machine cost. Careful: do not apply it to main if your pipeline has effects (publishing an artefact, for example); cancelling halfway can leave things half done.

3.1 fetch-depth: 0. By default, CI systems do a shallow clone (git clone --depth 1, lesson 02-02): they fetch only the last commit. It is much faster, but it breaks anything that needs history: git log between two references, git describe --tags, git merge-base, git blame. Since step 3.5 walks through the PR's commits, the full history is needed here. This is probably the most common configuration failure in all of CI: a Git command that works locally and in CI says fatal: bad revision.

3.3 npm ci and not npm install. ci installs exactly what the lock file says and fails if it does not match the manifest. install may resolve new versions and make today's run differ from yesterday's without anybody having changed a thing. Reproducibility: the same input must give the same result.

3.5 Validating the commit messages. Here is the connection with lesson 06-01: the commit-msg hook already validates the GT-NNN format on the laptop, but it can be skipped with --no-verify. This check verifies it on the server, where nobody can dodge it. Note the range: $BASE..HEAD, that is, only the commits the PR contributes (lesson 07-02). The specific rules of the format are the subject of lesson 08-01; here we are only building the mechanism that enforces them.

4. Matrix. Ana uses Ubuntu, Bruno macOS and Carla Windows 11. Checking the three systems in parallel catches the classic problem of paths with \ versus /, or of capitalisation in file names, before it reaches main. fail-fast: false matters: without it, the first system to fail cancels the others and you lose useful information.

4.1 if: always(). Without this, the coverage report is not saved when the tests fail, which is exactly when you most need it for diagnosis.

5. Submodules. This picks up lesson 06-05. task-manager uses ui-components as a submodule, and the classic mistake is committing a pointer to a commit that only exists on the laptop of whoever did it. Anybody else will clone and be unable to initialise the submodule. git merge-base --is-ancestor checks that the commit pointed at is reachable from the submodule's main branch. It is a three-line check that saves whole afternoons.

6. The final gate. A single job that depends on all the others. Its usefulness is practical: on the protected branch you require one single mandatory check, all-green, instead of maintaining a list of five names that has to be updated every time a job is added. With the matrix it is even more valuable, because the generated names (tests (ubuntu-latest), etc.) change when you touch the matrix and the protection rules are left waiting for checks that no longer exist.

  1. Status checks and protected branches

We have the checks. Now they have to be turned into a requirement, and this is the part that definitively answers the question lesson 06-01 left open.

The status check

When the pipeline finishes, it publishes its result attached to the commit, not to the branch or to the PR. It is a piece of data of the form: "commit a7c2e91 has passed the all-green check". That is a status check.

It can be queried from the platform itself, and it is visible in the interface as the green tick or the red cross next to the commit.

The protected branch

On its own, a status check is informative. What turns it into an obligation is the protected branch configuration, which we already introduced in lesson 07-04:

Rule Effect on main
Forbid direct pushing Everything comes in through a PR
Require N approvals Nothing goes in without review
Require green checks Nothing goes in with CI red
Require the branch to be up to date with main The real combination gets tested
Dismiss approvals when new commits arrive You do not approve one version and merge another
Forbid force pushes Nobody rewrites published history (05-06)
Forbid deleting the branch main does not disappear
Require signed commits Only code with verified authorship goes in

When somebody tries to bypass this, the rejection comes from the server:

git push origin main
remote: error: GH006: Protected branch update failed for refs/heads/main.
remote: error: Required status check "all-green" is expected.
To git@git.example.com:team/task-manager.git
 ! [remote rejected] main -> main (protected branch hook declined)
error: failed to push some refs to 'git.example.com:team/task-manager.git'

Why this really is an effective control

Here is the answer lesson 06-01 left pending, and it is worth stating precisely:

A client-side hook lives on the disk of whoever runs it, in .git/hooks/, which is not version-controlled. Anybody can delete it, modify it or dodge it with --no-verify. It is a help against absent-mindedness.

A status check runs on the project's infrastructure and the decision to accept the push is taken by the server. --no-verify is a client option: it tells your Git not to run your hooks. It does not travel in the network protocol and the server has no idea it exists. There is nothing to bypass.

The same difference, in one sentence: the client-side hook warns you; the server decides.

A git push --force onto a protected branch is rejected in the same way, and there lies the technical guarantee of the golden rule of lesson 05-06: not rewriting published history stops being a recommendation and becomes an impossibility.

A warning about the exceptions

Almost every platform allows certain roles to bypass the protections. It is tempting to leave that door open "just in case". But think about when it gets used in practice: during a production incident, in a hurry, at eleven at night, by somebody who is tired. That is, exactly when the checks are most needed. If an emergency route really is needed, let it be explicit, let it leave a record, and let it be reviewed afterwards.

  1. Server-side hooks: pre-receive, update and post-receive

And now, at last, the complete family that lesson 06-01 announced.

When somebody runs git push, the server receives the objects and, before updating any reference, runs its own hooks. They live in the hooks/ directory of the bare repository (lesson 04-01), not in any laptop's .git/hooks/.

Hook When Input Effect of the exit code
pre-receive Once, before accepting anything On stdin: <old-sha> <new-sha> <ref>, one line per reference A non-zero exit rejects the entire push
update Once per reference As arguments: <ref> <old-sha> <new-sha> Rejects that reference only; the others can go through
post-receive After acceptance, with everything updated The same as pre-receive, on stdin Ignored: nothing can be rejected any more

pre-receive: the gate

A real example for task-manager: rejecting pushes that introduce files of more than five megabytes, which is the usual cause of repositories becoming unmanageable (and the reason Git LFS exists, lesson 10-03).

#!/bin/bash
# hooks/pre-receive in the server's bare repository
LIMIT=$((5 * 1024 * 1024))

while read -r old new ref; do
    # A new branch: compare against the empty tree
    if [ "$old" = "0000000000000000000000000000000000000000" ]; then
        range="$new"
    else
        range="$old..$new"
    fi

    # Walk through the new objects arriving in this push
    while read -r mode type sha size path; do
        [ "$type" = "blob" ] || continue
        if [ "$size" -gt "$LIMIT" ]; then
            echo "REJECTED: '$path' takes up $size bytes (limit: $LIMIT)."
            echo "Use Git LFS for large files."
            exit 1
        fi
    done < <(git rev-list --objects "$range" |
             git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' |
             awk '{print "-", $1, $2, $3, substr($0, index($0,$4))}')
done
exit 0

Note two details. The first: the check for the all-zeros hash, which is how Git signals "this reference did not exist before" (a new branch). The second: exit 1 at any point rejects the entire push, including the other branches that came in the same push. That atomicity is deliberate: either everything goes in or nothing does.

Other typical uses of pre-receive:

  • Rejecting force pushes onto specific branches.
  • Rejecting commits whose message does not follow the GT-NNN convention.
  • Rejecting commits whose author does not have an email in the company's domain.
  • Detecting secrets (API keys, credentials) in the content arriving. This is the subject of lesson 08-05, but it is worth knowing that the right place for that barrier is here.

update: control per reference

It runs once per reference and can reject just that one. It is the natural place for rules specific to a branch or to tags:

#!/bin/bash
# hooks/update — only the release team may create version tags
ref="$1"; old="$2"; new="$3"

case "$ref" in
  refs/tags/v*)
    if [ "$old" != "0000000000000000000000000000000000000000" ]; then
        echo "REJECTED: version tags are neither modified nor moved."
        exit 1
    fi
    if ! id -nG "$USER" 2>/dev/null | grep -qw release; then
        echo "REJECTED: only the 'release' group may create v* tags."
        exit 1
    fi
    ;;
esac
exit 0

This example protects something important that we saw in lesson 05-05: a published tag does not move. If somebody moves v2.0.0 to another commit, whoever had already downloaded it has a different version from whoever downloads it tomorrow, and that is a source of bugs that are impossible to diagnose.

post-receive: notifying

It runs with everything already accepted, so it cannot reject anything. Its role is to tell the rest of the world.

#!/bin/bash
# hooks/post-receive
while read -r old new ref; do
    branch="${ref#refs/heads/}"
    [ "$branch" = "$ref" ] && continue      # ignore tags

    author=$(git log -1 --format='%an' "$new")
    subject=$(git log -1 --format='%s' "$new")

    # Notify the team chat
    curl -sS -X POST "https://chat.example.com/hooks/task-manager" \
         -H 'Content-Type: application/json' \
         -d "{\"text\": \"$author has pushed to $branch: $subject\"}" >/dev/null

    # And trigger the continuous integration pipeline
    curl -sS -X POST "https://ci.example.com/api/run" \
         -H "Authorization: Bearer $CI_TOKEN" \
         -d "{\"branch\": \"$branch\", \"commit\": \"$new\"}" >/dev/null
done

That second curl is literally the trigger from section 2. When you use a hosted platform, this happens underneath: the server's post-receive emits a webhook that the CI system receives. It is also the hook that generates the remote: Create a pull request for... message we saw in lesson 07-01.

The usual limitation

We already noted it in 06-01 and it bears repeating: on a hosted platform you do not have access to the hooks/ directory. GitHub, GitLab or Bitbucket will not let you put a script on their server. What they offer are their managed equivalents:

Concept Your own server Hosted platform
Rejecting a push by its content pre-receive Push rules (on paid plans) or a CI check
Controlling who updates which reference update Protected branches, tag rules, CODEOWNERS
Notifying and triggering post-receive Webhooks, native integrations
Requiring the tests to pass Hard: the push is synchronous Required status checks

The last row is interesting and explains the real division of labour. A pre-receive runs during the push, with the user waiting at the terminal: it cannot launch a twenty-minute test suite. That is why slow checks do not live in a hook, but in CI, and their mandatory nature is imposed at the moment of merging the PR, not at the moment of pushing. Server-side hooks are for fast, structural rules; CI is for slow, substantive verification.

  1. Client, server and CI: who checks what

The complete picture of the three barriers, which is the practical summary of the whole of module 6 and this one:

Client-side hook Server-side hook CI / status check
Where it runs Each person's laptop The Git server The CI infrastructure
When Before commit / push During the push After the push, in parallel
Can it be dodged? Yes: --no-verify, or by deleting the file No No
Is it version-controlled? No (unless core.hooksPath + Husky) No, it belongs to the server Yes: the pipeline is in the repository
Speed required Seconds Seconds Minutes
Does it block the user? Yes, while it runs Yes, while it runs No: it is asynchronous
What to put here Formatting, linting what changed, message format File size, permissions over refs, secrets, force pushes Tests, builds, the OS matrix, coverage, security
Role Saving the trip to the server Non-negotiable structural rules The real verification

The healthy strategy is not to pick one, but to chain all three, each slower and more complete than the last:

flowchart LR
    A["pre-commit<br/>2 seconds<br/>formatting and lint"] --> B["pre-push<br/>30 seconds<br/>fast tests"]
    B --> C["pre-receive<br/>1 second<br/>structural rules"]
    C --> D["CI on the PR<br/>8 minutes<br/>full suite"]
    D --> E["Protected branch<br/>decides whether it merges"]

Each link catches its own thing as early as possible. The local hook saves you a trip to the server; the server rejects what is structurally unacceptable; CI genuinely verifies; and the protected branch turns the CI result into the final decision.

And the rule that sums up the relationship between the three: what is mandatory gets checked where the user is not in charge.

  1. Keeping CI fast

Slow CI is not a minor inconvenience: it destroys the very practice it is meant to sustain.

The causal chain is direct. If the check takes forty minutes, people stop waiting for the result and switch task. When they come back, the context has to be rebuilt. Since waiting is expensive, changes get piled up to amortise the wait: bigger PRs, longer branches, less frequent integrations. That is, exactly the opposite of continuous integration. And worse: a red takes forty minutes to be detected, during which another five people have built on top of it.

Widely accepted practical benchmarks:

Duration Effect on the team
< 5 min You wait for the result without switching task. Ideal
5–10 min Acceptable. The practical limit for TBD
10–20 min Context switching happens. It starts to hurt
20–60 min Changes pile up. Continuous integration degrades
> 60 min People ignore CI. It has stopped being useful

The five techniques with the best return:

  1. Caching

The most profitable by a distance. Installing dependencies is usually the slowest and the most repetitive step.

- name: Cache the dependencies
  uses: actions/cache@v4
  with:
    path: ~/.npm
    # The key includes the hash of the lock file:
    # if the dependencies do not change, the cache is reused
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-

The crux of the matter is the key: it must change exactly when what is cached changes. A restore-keys as a safety net allows a partial cache to be reused when the exact one does not exist.

  1. Parallelism

Independent jobs run at the same time. With the matrix from section 4, three operating systems cost the same in wall-clock time as one. You can also split the tests into shards:

strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npm test -- --shard=${{ matrix.shard }}/4

Four machines, a quarter of the time. Careful: there is a fixed cost per job (starting the machine, fetching the code, installing) that puts a floor under it. Splitting into twenty shards of thirty seconds each speeds nothing up if starting takes a minute.

  1. Running only what is affected

If the PR only touches README.md, the full suite is not needed. Git provides the information required:

- name: Detect what has changed
  id: changes
  run: |
    BASE="${{ github.event.pull_request.base.sha }}"
    FILES=$(git diff --name-only "$BASE...HEAD")
    echo "$FILES"
    if echo "$FILES" | grep -qvE '\.(md|txt)$'; then
      echo "code=true" >> "$GITHUB_OUTPUT"
    else
      echo "code=false" >> "$GITHUB_OUTPUT"
    fi

- name: Run the tests
  if: steps.changes.outputs.code == 'true'
  run: npm test

Note the "$BASE...HEAD" with three dots: it is the diff from the common ancestor, for exactly the reason we explained in lesson 07-02. With two dots, files touched by main would appear as changed, and the detection would be wrong.

Important warning: if this check is required on the protected branch and you decide to skip it, the PR will wait for ever for a check that never arrives. The correct pattern is for the job to always run and finish quickly when there is nothing to do, not for it not to run.

  1. Tiering: fast always, full at night

Not everything has to run on every push.

Suite When Duration Content
Fast Every push and every PR < 10 min Lint, unit tests, build
Medium On merging into main < 30 min Integration tests, the full matrix
Full Every night No limit End to end, performance, security, browsers
on:
  schedule:
    - cron: '0 3 * * *'      # every day at 03:00 UTC

The trade-off: a failure caught only by the nightly run takes up to a day to appear. That is acceptable if the fast suite covers what breaks frequently.

  1. A shallow clone where possible

The fetch-depth: 0 from section 4 is necessary for the steps that consult the history, but it is slow in large repositories. In the jobs that do not need it, leave the default shallow clone. And for extreme cases there are partial clones (--filter=blob:none), which we shall see in lesson 10-04.

And one last recommendation that is not technical: measure. Almost every platform shows the duration per job and per step. Ten minutes looking at that breakdown usually reveals that 70% of the time goes on one specific step that can be cached. Optimising blindly is throwing time away.

  1. Merge queue: integrating without races

Remember consequence 2 from section 3: green on your PR does not guarantee green on main, because main may have moved on between the last check and the merge.

With three people and four integrations a day, the probability is low and gets accepted. With thirty people and fifty integrations a day, it happens daily, and the obvious solution — requiring the branch to be up to date with main before merging — produces a perverse effect: every time somebody merges, all the other PRs become out of date and have to be re-updated and re-tested. It turns into a race that is only won by whoever presses the button fastest.

The merge queue (merge train on GitLab) solves this. Instead of merging when you press the button, the PR enters a queue, and the system:

  1. Takes the first PR in the queue.
  2. Builds a speculative commit: main + that PR.
  3. Runs the checks on that combination.
  4. If it passes, it merges it into main for real.
  5. If it fails, it ejects it from the queue and notifies its author, without blocking the others.

And the optimisation that makes it viable: several are processed in parallel, speculatively. With three PRs in the queue, main+A, main+A+B and main+A+B+C get tested simultaneously. If all three pass, all three are merged at once. If B fails, B is discarded and the main+A result is reused, reprocessing only what came after.

flowchart TD
    A["PR A approved"] --> Q["Merge queue"]
    B["PR B approved"] --> Q
    C["PR C approved"] --> Q
    Q --> S1["Test main+A"]
    Q --> S2["Test main+A+B"]
    Q --> S3["Test main+A+B+C"]
    S1 --> R["Green: merge"]
    S2 --> R
    S3 --> X["Red: eject C<br/>and merge only A and B"]

Advantages: main never breaks because of a race, nobody has to re-update their branch by hand, and the team's throughput stops degrading with its size.

Cost: it consumes considerably more machine time (each speculative combination is a full run) and it adds latency between approval and merge.

When it is needed: when the team integrates so many times a day that the races are routine, normally from ten or fifteen people working on the same repository upwards. Below that it is unnecessary complexity: requiring the branch to be up to date is enough.

  1. What is left out: deployment

We have reached the limit of this lesson, and it is worth marking it clearly.

What we have built answers: "is this change correct and can it be integrated?". That is continuous integration.

What comes afterwards answers: "how does this change reach the users' hands?". That is continuous delivery and deployment, and it includes pre-production environments, promotion strategies, progressive rollout, rollback, infrastructure as code, observability and everything that happens after the commit goes into main.

That half is the content of lesson 10-05: Git in DevOps. From Git's point of view, the only thing to hold on to here is the border:

Continuous integration (this lesson) Delivery and deployment (10-05)
Question Is it correct? Can it be integrated? How does it reach the user?
Triggers in Git push, pull request Merge into main, a tag
Result Green or red, merge or not A running version
If it fails It does not get merged The deployment gets rolled back

Note the triggers row: the tags of lesson 05-05 are the hinge between the two halves. It is what makes git tag -a v2.4.0 && git push --follow-tags, in many projects, the act that starts a release.

Common Mistakes and Tips

Mistake 1: believing that "having CI" is doing continuous integration. If each person integrates every three weeks, a test server fixes nothing. The practice is integrating often; the server only verifies it.

Mistake 2: not knowing that CI tests the merge, not your branch. It is the most frequent cause of "it works on my machine" in PRs. Fetch pull/<n>/merge and reproduce on that.

Mistake 3: forgetting fetch-depth: 0 when the pipeline uses the history. The shallow clone breaks git log between references, git describe, git merge-base and git blame with confusing errors.

Mistake 4: npm install instead of npm ci. Runs stop being reproducible and failures appear that nobody has caused.

Mistake 5: requiring checks whose names change. With matrices, the names are generated. Use a single summary job (all-green) as the required check.

Mistake 6: filtering a required check by path. If it does not get launched, the PR waits for ever. The job must always run and finish quickly when there is nothing to do.

Mistake 7: living with unstable tests. A random red teaches the team to re-run without looking, and with that all the signal dies. Fix it, isolate it or delete it, but do not ignore it.

Mistake 8: tolerating forty-minute CI. It degrades the whole flow towards large PRs and long branches. It is an investment with an immediate return.

Mistake 9: relying on client-side hooks for what is mandatory. --no-verify and that is that. What is non-negotiable goes on the server or in a status check.

Mistake 10: leaving the protected-branch exception open. It will get used during an incident, in a hurry, which is when it is the worst idea.

Mistake 11: not pushing the tags and expecting the release to be triggered. git push --follow-tags.

Tip 1: concurrency with cancel-in-progress from day one. An immediate saving in machine time, at zero cost.

Tip 2: a summary job that all the others feed into. It hugely simplifies the protected branch configuration.

Tip 3: measure before optimising. The per-step breakdown almost always reveals a single cacheable culprit.

Tip 4: tier your suites. Fast on every push, medium on merging, full at night.

Tip 5: validate in CI what the commit-msg hook validates locally. The hook warns early; CI compels.

Tip 6: check the submodule pointers in CI. Three lines that prevent broken clones for the whole team (lesson 06-05).

Tip 7: save the artefacts with if: always(). A failure's reports are precisely the ones you need most.

Tip 8: --filter and partial clones in large repositories. And if the problem is heavy binary files, the answer is Git LFS (lesson 10-03).

Exercises

Exercise 1: checking what CI checks

With no platform, reproducing the mechanism with pure Git:

  1. Create a repository with app.js and main in three commits.
  2. Create feature/filters from the second commit and make two commits that rename a function.
  3. Go back to main and add a commit that calls that function under the old name.
  4. Work out by hand the commit CI would test: create a temporary branch from main and merge the feature branch into it.
  5. Check that git diff main...feature/filters does not reveal the problem, and that the result of the merge does have it.
  6. Write a three-line script that, given two branches, produces the merge commit without leaving a trace (hint: git merge-tree).

Exercise 2: an effective server-side hook

  1. Create /tmp/server/task-manager.git as a bare repository and clone it into /tmp/ana.
  2. Write a hooks/pre-receive in the bare repository that rejects any push containing a commit whose subject does not begin with GT-NNN or with Merge .
  3. Check that a commit with a correct message goes through and an incorrect one is rejected, and that in the second case no reference in the push is updated.
  4. Install in /tmp/ana a client-side commit-msg hook with the same rule, and demonstrate that git commit --no-verify dodges it but that the pre-receive still rejects the push.
  5. Write a hooks/update that prevents modifying or deleting any existing tag, and check it.

Exercise 3: a minimal pipeline and its optimisation

  1. Write a ci/pipeline.yml file for task-manager with: triggers on pushes to main and on PRs, a lint job, a test job across a matrix of three systems and a summary job that both feed into.
  2. Add dependency caching with a key based on the hash of the lock file.
  3. Add a step that validates the commit messages from the PR only, using the correct range.
  4. Add change detection that avoids running the tests if only .md files have been touched, without failing to publish the check's result.
  5. Explain, for each choice, what problem it prevents.

Solutions

Solution 1:

mkdir /tmp/ci-merge && cd /tmp/ci-merge
git init -qb main
printf 'function saveTasks(t) { return t; }\n' > app.js
git add . && git commit -q -m "Add saveTasks"
echo 'const tasks = [];' >> app.js
git add . && git commit -q -m "Add the initial state"
BASE=$(git rev-parse HEAD)
echo 'saveTasks(tasks);' >> app.js
git commit -qam "Save the state on start-up"
# 2. The branch renames
git switch -qc feature/filters "$BASE"
sed -i 's/function saveTasks/function persistTasks/' app.js
git commit -qam "Rename saveTasks to persistTasks"
echo 'function filter(f) { return tasks.filter(f); }' >> app.js
git commit -qam "Add filtering"
# 3. main already had the old call (the commit from step 1)
# 4. The commit CI would test
git switch -q main
git switch -qc ci-merge-simulation
git merge -q feature/filters
cat app.js
function persistTasks(t) { return t; }
const tasks = [];
saveTasks(tasks);
function filter(f) { return tasks.filter(f); }

It merges with no conflict and the result calls saveTasks, which no longer exists: a semantic conflict.

# 5. The PR's diff does not reveal it
git switch -q main
git diff main...feature/filters

The diff shows the rename and the filtering, and nothing else: the broken call is in main, not in the branch, so it does not appear. Only by testing the merge is it detected.

# 6. The merge commit without touching the working tree
git merge-tree --write-tree main feature/filters
4f8b1d3a9c2e7f0b5d8a1c4e7f0b3d6a9c2e5f81

git merge-tree (Git 2.38 or later) returns the resulting tree without modifying the working directory or creating any commit. It is, essentially, what the platforms do to calculate pull/<n>/merge.

Solution 2:

mkdir -p /tmp/server && git init -q --bare /tmp/server/task-manager.git
git clone -q /tmp/server/task-manager.git /tmp/ana
cd /tmp/ana && git switch -qc main
echo "initial" > app.js && git add . && git commit -q -m "GT-001 Initial state"
git push -qu origin main
# 2. The server-side hook
cat > /tmp/server/task-manager.git/hooks/pre-receive <<'EOF'
#!/bin/bash
EMPTY=0000000000000000000000000000000000000000
while read -r old new ref; do
    [ "$new" = "$EMPTY" ] && continue            # branch deletion
    if [ "$old" = "$EMPTY" ]; then
        range="$new"
    else
        range="$old..$new"
    fi
    while read -r subject; do
        if ! echo "$subject" | grep -qE '^(GT-[0-9]{1,5} |Merge )'; then
            echo "REJECTED: '$subject' does not follow the GT-NNN convention."
            exit 1
        fi
    done < <(git log --format=%s "$range")
done
exit 0
EOF
chmod +x /tmp/server/task-manager.git/hooks/pre-receive
# 3. A correct message: it goes through
cd /tmp/ana
echo "ok" >> app.js && git commit -qam "GT-002 Add filtering by label"
git push origin main
# An incorrect message: it is rejected
echo "bad" >> app.js && git commit -qam "various fixes"
git push origin main
remote: REJECTED: 'various fixes' does not follow the GT-NNN convention.
To /tmp/server/task-manager.git
 ! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs to '/tmp/server/task-manager.git'
# And no reference has moved
git --git-dir=/tmp/server/task-manager.git log --oneline -1 main
# 4. The client-side hook and --no-verify
cat > /tmp/ana/.git/hooks/commit-msg <<'EOF'
#!/bin/bash
grep -qE '^(GT-[0-9]{1,5} |Merge )' "$1" || {
  echo "The message must begin with GT-NNN."; exit 1; }
EOF
chmod +x /tmp/ana/.git/hooks/commit-msg

cd /tmp/ana
git reset -q --hard HEAD~1
echo "bad" >> app.js
git commit -qam "more fixes again"            # the hook blocks it
git commit -qam "more fixes again" --no-verify # it dodges it
git push origin main                           # the server is NOT dodged

--no-verify disables the local hook because the file is on Ana's disk. The pre-receive runs on the server and there is no client option that reaches it: that is what makes it an effective control.

# 5. Protecting the tags
git reset -q --hard HEAD~1
cat > /tmp/server/task-manager.git/hooks/update <<'EOF'
#!/bin/bash
ref="$1"; old="$2"
EMPTY=0000000000000000000000000000000000000000
case "$ref" in
  refs/tags/*)
    if [ "$old" != "$EMPTY" ]; then
        echo "REJECTED: tags are neither modified nor moved."
        exit 1
    fi ;;
esac
exit 0
EOF
chmod +x /tmp/server/task-manager.git/hooks/update

cd /tmp/ana
git tag -a v1.0.0 -m "Version 1.0.0" && git push -q origin v1.0.0
git tag -f -a v1.0.0 -m "Moved" && git push --force origin v1.0.0
remote: REJECTED: tags are neither modified nor moved.
 ! [remote rejected] v1.0.0 -> v1.0.0 (hook declined)

Solution 3:

# ci/pipeline.yml
name: Continuous integration

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      # 2. Cache with a key based on the lock file
      - uses: actions/cache@v4
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: npm-${{ runner.os }}-
      - run: npm ci
      - run: npm run lint

      # 3. Commit messages: only the ones the PR contributes
      - name: Validate the commit messages
        if: github.event_name == 'pull_request'
        run: |
          BASE="${{ github.event.pull_request.base.sha }}"
          git log --format=%s "$BASE..HEAD" | while read -r s; do
            echo "$s" | grep -qE '^(GT-[0-9]+ |Merge )' || {
              echo "Invalid message: $s"; exit 1; }
          done

  tests:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # 4. Change detection: the job ALWAYS runs,
      #    but it skips the expensive step if there is only documentation.
      - name: Detect code changes
        id: changes
        shell: bash
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            BASE="${{ github.event.pull_request.base.sha }}"
            FILES=$(git diff --name-only "$BASE...HEAD")
          else
            FILES=$(git diff --name-only HEAD~1 HEAD)
          fi
          if echo "$FILES" | grep -qvE '\.(md|txt)$'; then
            echo "code=true" >> "$GITHUB_OUTPUT"
          else
            echo "code=false" >> "$GITHUB_OUTPUT"
          fi

      - uses: actions/setup-node@v4
        if: steps.changes.outputs.code == 'true'
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
        if: steps.changes.outputs.code == 'true'
      - run: npm test
        if: steps.changes.outputs.code == 'true'
      - name: No code changes
        if: steps.changes.outputs.code == 'false'
        run: echo "Documentation only: the tests are not run."

  all-green:
    runs-on: ubuntu-latest
    needs: [lint, tests]
    if: always()
    steps:
      - run: |
          [ "${{ contains(needs.*.result, 'failure') }}" = "false" ] || exit 1
          echo "All the checks have passed."

5. What each choice prevents:

Choice Problem it prevents
concurrency + cancel-in-progress Wasting machines on runs that no longer matter
fetch-depth: 0 fatal: bad revision when walking the PR's commits
Cache with the lock file hash Reinstalling identical dependencies on every run
npm ci Non-reproducible runs caused by version resolution
fail-fast: false Losing the information from the other systems when one fails
The $BASE..HEAD range Validating commits from main that do not belong to this PR
A three-dot diff in the detection Counting as changed files that only main touched
The job always runs, the expensive step is skipped A required check that never launches, leaving the PR waiting for ever
The all-green job Having to list matrix names in the protected branch

Conclusion

This lesson closes module 7. The essentials:

  • Continuous integration is not having a CI server: it is genuinely integrating and often, at least daily, with every integration verified automatically. Without frequent integration, a test server only verifies divergent versions that have not yet met each other.
  • It hooks into Git through triggers: on push, on pull request (including synchronize, when new commits arrive) and on tag, which is the hinge towards release. And remember that tags do not travel with a plain git push.
  • In a pull request, CI checks the result of the merge, not your branch. It is the pull/<n>/merge reference. That is why your PR can go red without you having done anything, why it catches semantic conflicts, and why green on the PR does not guarantee green on main.
  • A well-built pipeline fetches the history it needs (fetch-depth: 0), installs reproducibly (npm ci), parallelises across a matrix of systems, also checks the submodule pointers, and ends in a summary job that the others feed into.
  • A status check is attached to a commit; a protected branch turns it into a requirement. And this is what 06-01 left pending: --no-verify is a client option, it does not travel over the network and the server has no idea it exists. The local hook warns you; the server decides.
  • The server-side hooks close the circle: pre-receive (once, rejects the whole push), update (once per reference, rejects that one only) and post-receive (afterwards, only notifies and triggers). They are effective because they run where the user is not in charge. On hosted platforms you have no access to hooks/, and their equivalents are push rules, protected branches and webhooks.
  • The healthy division of labour chains the three barriers: a client-side hook for the fast and local, a server-side hook for the structural and non-negotiable, CI for the slow and real verification. What is mandatory gets checked where the user is not in charge.
  • Slow CI destroys the flow: it pushes towards large PRs, long branches and late integration. Under ten minutes as the target, with caching, parallelism, running only what is affected and tiered suites.
  • The merge queue eliminates the race between approved PRs by testing speculative combinations before merging. It is worth it from ten or fifteen people on the same repository upwards.
  • And the border: integration ends here. How that code reaches the users — environments, promotion, progressive rollout, rollback — is lesson 10-05.

The module, in one idea

The task-manager team began this module with a mastered tool and no agreements. It now has a complete process: Diego, with no write access, proposes changes from his fork by way of pull requests; Ana reviews them with git diff main...branch and git range-diff; the team has chosen its branching flow according to the product — Git Flow for the installable version, something close to GitHub Flow or TBD for the cloud one — and a set of automated checks that nobody can dodge decides what goes into the mainline.

They have a process. What they lack now is habits.

Because an impeccable process coexists perfectly well with an unreadable history. You can have protected branches, mandatory review and green CI, and still pile up a hundred commits called "changes" that nobody will be able to interpret a year from now; version-control the node_modules folder and a configuration file with the database password; suffer absurd conflicts because a colleague on Windows saves line endings differently; and discover one day that an API key has been sitting in the public history for eight months.

That is module 8. How to write commit messages that serve as documentation (08-01, where we shall finally develop the convention we have only enforced here); how to keep a readable history and which merge policy to choose (08-02, which closes the decision we left open in 07-04); what should never be version-controlled (08-03 and 08-04); how not to leak a secret and what to do if it has already happened (08-05); and how to keep the repository fast as it grows (08-06).

We start with the most everyday and the most neglected thing of all: lesson 08-01: Writing Good Commit Messages.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved