Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

By updated

Improve this page

A reliable pipeline does not contain its commands: it calls steps that live in the repository. The same make targets then run on your machine and in the CI (continuous integration), and a failure can be replayed at home with the same exit code. This lesson, for admins, ops and developers who have never designed a chain, shows on a small Python service how to arrange the steps, write the file that orchestrates them, choose what triggers it and read a red log. It relies on Forgejo, a forge (a platform that hosts Git repositories and runs pipelines) installed locally, with no GitHub or GitLab account.

What you will learn

  • Place lint, tests and build in the repository as make targets
  • Write a pipeline that only calls these targets, in order
  • Choose the triggers of a pipeline and predict what starts it
  • Explain why a pipeline stops at the first failing step
  • Reproduce locally a failure read in a CI log

Prerequisites

  • Know how to create a commit, a branch and merge with Git
  • Know how to run a command in a terminal and read its exit code (0 means success, any other number a failure)
  • Know how to read a YAML file: indentation, lists, key-value pairs

The basic vocabulary (job, runner, step) is set out in What is a CI/CD pipeline?. No knowledge of GitHub Actions or GitLab CI is required: the syntax of a given engine is learned in its own course, and this lesson keeps only what helps to understand the model.

Why do the steps live in the repository?

The steps of a chain must live in the repository, next to the code, as named commands that anyone can run by hand. If the commands are written only in the pipeline file, the only way to test them is to push a commit (send it to the forge) and wait for the result. A broken step is then discovered a minute later, on a server you do not control.

The running example of the course is called inventaire: a Python program with no external dependency that counts servers by role. Its repository contains the code, its tests and a Makefile, the file that names the project's commands.

  • Répertoireinventaire/
    • Makefile
    • README.md
    • Répertoiresrc/
      • Répertoireinventaire/
        • init .py
        • main .py
        • donnees.py
    • Répertoiretests/
      • test_donnees.py
    • Répertoire.forgejo/
      • Répertoireworkflows/
        • ci.yml

The Makefile defines one target per step. make lint detects code errors with pyflakes, without running the program, make test runs the unit tests of the unittest module, make build packages the service into an executable archive.

VERSION := $(shell sed -n 's/^__version__ = "\(.*\)"/\1/p' src/inventaire/__init__.py)
ARTEFACT := dist/inventaire-$(VERSION).pyz
.PHONY: lint test build
lint:
python3 -m pyflakes src tests
test:
python3 -m unittest discover -s tests -v
build:
mkdir -p dist
python3 -m zipapp src -m 'inventaire.__main__:main' -p '/usr/bin/env python3' -o $(ARTEFACT)
cd dist && sha256sum inventaire-$(VERSION).pyz > inventaire-$(VERSION).pyz.sha256
@cat dist/inventaire-$(VERSION).pyz.sha256

The two variables at the top are computed once: VERSION reads the number written in src/inventaire/__init__.py, ARTEFACT derives the name of the produced file from it. The .PHONY line declares targets that are not files, so that make always runs them. In the lab, the supplied Makefile does not yet have a lint target: you write it yourself, following the model above.

The build target produces the archive dist/inventaire-1.0.0.pyz and its SHA-256 fingerprint, a checksum that identifies the file uniquely. Run on the lab workstation, it prints:

ae59c4255d59e41f95c81484174a471ed190ff86d9e0977fc291386a86ec1cc0 inventaire-1.0.0.pyz

To run these targets on your own machine, you need the same tools as the runner: GNU make, Python 3 and pyflakes (package python3-pyflakes on Debian and Ubuntu). The lab machine already has them; this is, by the way, the limit of the model, which the pre-commit section below takes up.

Make is not mandatory. A task runner such as Task plays the same role with a YAML file, and a versioned bash script does too. What matters is the principle: a stable command name, in the repository, identical locally and in CI.

How does the pipeline orchestrate these steps?

The pipeline file contains no logic: it chooses when the chain starts, where it runs, then calls the targets in order. On Forgejo, it is stored in .forgejo/workflows/. Its syntax is close to that of GitHub Actions, which makes the lesson transferable; for the full syntax, the GitHub Actions course is the reference.

name: ci
on:
push:
branches: [main]
pull_request:
jobs:
integrer:
runs-on: debian
steps:
- uses: https://data.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: make lint
- run: make test
- run: make build

Three blocks are enough to read this file. The on block lists the triggers, studied in the next section. The jobs block defines a job named integrer, which runs on a runner, the machine that executes the job (here a Debian machine of the lab VM). A runner declares labels, names that say what it can do: runs-on: debian asks for a runner carrying the debian label. The steps block lists the steps of the job, in order; a step is a command or an action, not to be confused with the stage of the vocabulary lesson.

The first step, uses, downloads the repository code. It is indispensable, because the runner starts with an empty directory: the measured log shows that it empties the working folder, initializes a Git repository then fetches the requested commit. This action is pinned by its commit SHA (3d3c42e5…), with the version in a comment: a tag (a name placed on a commit) such as v7 can be moved by its author, a SHA cannot.

The persist-credentials: false parameter asks the action not to leave the repository access token in the Git configuration of the working directory. Without it, this token would stay readable by all the following steps, including a third-party script. This chain pushes no code: better not to leave it lying around.

The three run steps contain only make lint, make test and make build, in a single job: they run one after the other on the same runner. A larger pipeline often spreads them over parallel jobs, as the vocabulary lesson shows; the principle stays the same, each job calling its targets. The pipeline invents nothing: it runs what you already launch on your machine. A change of command is made in the Makefile, therefore in a reviewed commit like the rest of the code, and the pipeline file does not change.

Which events trigger a pipeline?

A pipeline starts when a forge event matches a rule of the on block. The event is not the commit itself: it is the action that makes it visible to the forge: a push, the opening of a merge request (the proposal to merge one branch into another, reviewed before being accepted; pull request on GitHub, merge request on GitLab), the placing of a version tag, the clock or a click. The choice of triggers therefore decides when you learn that a change is broken.

The diagram follows a commit from its push to the pipelines. Look at the path in thick line, which leads to the ci pipeline: only two events take it, a push to main and a merge request. A push to another branch, on the other hand, leads nowhere as long as no merge request exists. The dotted lines go to other pipelines (publish, deploy) covered in the following lessons.

A pushed commit produces one of three events. A push to main and a merge request start the ci pipeline. A push to another branch starts nothing while no merge request is open. Separately, a tag, a schedule and a manual or API trigger start other pipelines.no rule matchesthen openingPushed commitor human actionPush to mainPush to another branchPull requestopened or updatedNo runRelease tagScheduletriggerManual triggeror API callci pipelinemake lint, test, buildOther pipelinespublish, deploy
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The main triggers each have a use and a consequence.

TriggerWhat fires itWhat it implies
push filtered by brancha push to mainchecks the real state of the main branch, after the merge
pull_requestopening or updating a merge requestjudges the change before it is merged
tagpushing a version tagused to publish a specific version
schedulea cron-type clock (periodic schedule)replays the chain with no commit, useful to detect what drifts
workflow_dispatcha click in the interface, or a request to the forge APIstarts the chain at the request of a person or another system

This lab plays push and pull_request; the lessons artifacts and environments and promotion play the tag and the manual trigger. The schedule trigger, which takes a clock in cron format (UTC by default) and is ignored on other branches and in merge requests, is not played by this lab. The GitHub Actions triggers guide gives the syntax for that tool.

Why does a push to a branch start nothing?

A push to a branch other than main triggers no pipeline as long as no merge request is open. The lab measured it: after pushing the test-casse branch alone, the forge started no run. The push rule of the file names only main, so nothing matches this event.

This behaviour is intended. It avoids consuming runners for every draft, but it has a downside: one may wrongly believe a branch is validated when no check took place. Hence the second trigger, pull_request: as soon as merge request #1 is opened, the forge starts a run for the pull_request event, which fetches the branch commit (refs/remotes/pull/1/head in the log) and runs the chain on it.

The two rules complement each other. The merge request judges the change before the merge, the push to main checks the result after. Removing either leaves a hole: without the first, you merge without having tested; without the second, a merge that breaks the whole goes unnoticed.

What happens when a step fails?

A step that returns an exit code other than zero stops the job: the following steps do not run, and the run is marked as failed. This behaviour is called fail fast. It is made possible by the exit code convention, which make and the runner both respect: 0 to continue, anything else to stop.

The diagram shows the ci chain on the broken commit of the merge request. make lint returns 0 and the chain continues; make test returns 2, this is the stopping point; make build is never launched, and the job ends in failure.

Three steps in sequence. make lint ends with code 0, make test with code 2. The make build step is never run and the job is marked failed.failure: immediate stopmake lintexit code 0make testexit code 2make buildnever executedJob failedred pipeline
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The run log confirms it. In merge request #1, a test expected three servers with the web role while the code counted two. Here is the measured excerpt, from the end of the test to the end of the job:

FAIL: test_compte_par_role (test_donnees.TestCompter.test_compte_par_role)
...
AssertionError: {'web': 2, 'base': 1} != {'web': 3, 'base': 1}
...
FAILED (failures=1)
make: *** [Makefile:14: test] Error 1
⚙️ [runner]: RUN exit status 2
...
Job failed

After FAILED (failures=1), no mkdir -p dist line appears: the build target was not run. The merge request status shows ci / integrer (pull_request) as failed. The previous run, on main, had shown ci / integrer (push) as successful.

One detail puzzles beginners: the error says Error 1 and the runner says exit status 2. The recipe of the target returned 1, make then reports the failure by returning 2 itself, and it is this code that the runner reports. For the job, only the non-zero value matters.

The order of the steps follows from this: put first the fastest one and the one most likely to fail. make lint takes a second and catches a typo before the tests run; the build, more expensive and useless on faulty code, comes last.

This immediate stop has two reasons. It saves compute time: no point building an archive that you know will not be published. It keeps the useful message at the bottom of the log: the last failure is the cause to examine, not an error drowned among ten others. The best practices page places fail fast among the invariants of a reliable pipeline.

How do you read a failure and reproduce it locally?

To diagnose a red run, read the log from the end: the last step executed is the culprit, and its message contains the cause. In our case, three lines are enough: the FAIL: line names the test, the AssertionError gives the expected value and the obtained value, the make: *** line points at the target, test, and the Makefile line.

The next reflex is to replay the failure at home. Since the step is a make target, it is enough to switch to the faulty commit and run the same command:

Terminal window
git switch test-casse
make test
echo "code de sortie : $?"

The lab output ends with FAILED (failures=1), and the exit code is 2, as in the CI. The failure reproduces identically, you fix it on your machine, and you push a commit for which you know make test passes.

If the failure does not reproduce locally, the problem is no longer in the code but in the difference between the two environments: tool version, environment variable, file missing from the repository. This difference is precisely what the course teaches you to reduce. A chain that calls versioned targets already limits it to the execution environment.

How is this different from pre-commit?

The two approaches coexist, and they do not compete. The pre-commit tool runs checks before the commit, on your machine, to catch a style fault before it leaves. The pipeline replays them after the push, on a neutral server, because a local check can be bypassed or forgotten.

The make targets of this lesson bring the two closer: the same command runs on the workstation and on the runner, and the pipeline only calls it. It is the simplest form of a portable pipeline, which is enough as long as the runner offers the right tools. The notions of job, runner, cache and artifact are detailed in the page what is a pipeline.

Troubleshooting

The three symptoms below were met during the lab.

SymptomCauseFix
No pipeline after a push to a working branchthe push rule names only main, and no merge request is openopen a merge request: the pull_request event starts the chain
make: *** [Makefile:14: test] Error 1 then RUN exit status 2 and Job faileda test fails, make test returns a non-zero code and the job stopsread the FAIL: line, run make test locally on the same commit, fix
The fingerprint built by the CI (3d87b1d0…) differs from the one on the workstation (ae59c425…) for the same sourceprobable cause: the .pyz archive is a zip file, which records the modification date of each embedded file, and these dates differ between the CI clone and your copydo not rebuild to compare; see the lesson artifacts

The third row does not report a breakdown: it warns that "same source, same result" takes particular work, which the artifacts lesson covers.

Hands-on practice

The lab takes you from the bare repository to a running chain: you add the lint target to the Makefile, then write the pipeline file that calls the make targets, triggered by a push to main and by a merge request. You see it go green, then push a broken test in a merge request to see it fail, and reproduce the failure locally. The merge request is opened from the forge web interface (ssh -L 3000:localhost:3000 forge-1.lab, then http://localhost:3000) or through its API. The validation reads the state of the forge and of the machine (lint target, pipeline file, run statuses, merge request, saved output of make test), never the commands you typed.

The lab is started with dsoxlab run etapes-et-declencheurs, after dsoxlab provision which prepares the VM. The installation of DSOXLab is described in the DSOXLab documentation.

Key takeaways

  • The steps (lint, test, build) live in the repository, as named targets: a Makefile, a Taskfile or versioned scripts.
  • The pipeline invents nothing: it calls these targets in order, which guarantees the same command locally and in CI.
  • A third-party action is pinned by its commit SHA, with the version in a comment.
  • A trigger is a rule on an event: push filtered by branch, merge request, tag, schedule, manual or API trigger.
  • A push to a branch other than main starts nothing as long as no merge request is open: the merge request judges before the merge, the push to main checks after.
  • A non-zero exit code stops the job (fail fast): the following steps do not run.
  • A log is read from the end and the step is replayed locally with the same make target, to get the same exit code.
  • A failure that does not reproduce locally points to an environment difference, not a code fault.

Knowledge check

Check that the essentials of this lesson are in place. The questions only cover what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn