Skip to content
Computer Science Fundamentals
Source Code
← Home

Continuous integration

100 questions

Right
0
Wrong
0
Not answered
100
Difficulty
Number of questions

100 questions match this filter

Theory summary

Imagine a class of ten kids building one huge Lego castle together. If each kid works alone for a whole month and everybody tries to join their pieces on the last day, nothing fits and everybody is sad. If instead every kid snaps their piece onto the castle a little at a time, and a friend checks right away that the castle still stands, problems are tiny and fixed in minutes. Continuous integration, called CI, is exactly this idea for software: join small pieces of work often, and let a machine check every piece automatically.

The machine follows a written recipe called a . It builds the program, checks the style of the code, runs the tests, and says 'green, all good' or 'red, something broke'. The same recipe can go further and deliver the program to the users. That is CD, short for continuous delivery or continuous deployment. You meet CI and CD every day: every app on your phone that gets updates many times a year was built, tested and shipped by a pipeline.

In this summary you will learn: what CI and CD are and the rules that make them work, how to read a GitHub Actions workflow (the pipeline tool used by this very repository), how to run jobs on many systems at once, how to save time with caches and pass files with artifacts, how to keep passwords safe, how to share pieces of pipeline, how to build gates that stop bad changes, how to release new versions without scaring the users, how version numbers and changelogs work, and how to protect the pipeline from attackers. At the end you will be ready for the quiz.

CI and CD: the big picture

Back to the Lego castle. The old way of working is called big-bang integration: everybody builds alone for weeks and joins everything at the end. The is the place where all the work comes together. In continuous integration every developer merges work into the mainline often, at least once a day, and every merge starts an automatic plus a test run. Problems show up minutes after they are made, while the person still remembers what they did.

CI is a habit of the team, not a machine. A server that builds every branch, while the branches stay apart for two weeks, is not real CI, because the real joining still happens late. The server only helps the habit.

The rules that make CI work

  • Merge small and often, at least once a day, so each merge is small and conflicts are rare.
  • Keep the mainline green. If the build breaks, fixing it or undoing the change (a revert) comes before any new work, and nobody adds more changes on top of a red build.
  • Fail fast. Run the cheap, quick checks first and the slow, expensive ones later. A two-minute unit test run should go before a forty-minute end-to-end run, because most mistakes are caught by the quick one.
  • Keep everything in version control: code, tests, build and deploy scripts, the configuration of each environment and the pipeline itself. Passwords are the only exception: the repository only refers to them, it does not store them.
  • Use one deployment process for every environment. Staging and production should be deployed by the same automatic script, and only the configuration changes. A production deploy done by hand from a wiki page depends on memory, and a skipped step breaks things at the worst moment.

From CI to CD

Each step needs the one before it. Deployment without CI would just ship mistakes faster.
PracticeWhat it meansWho presses the button
Continuous integrationEvery change is merged into the mainline and checked by an automatic build and testsNobody, it is automatic
Continuous deliveryThe software is always ready to be released, because every change passes the whole pipelineA person decides when to release (a business decision)
Continuous deploymentEvery change that passes the pipeline is released to users right awayNobody, there is no human gate

The deployment pipeline

The book Continuous Delivery (Humble and Farley) calls the recipe a deployment pipeline: an automatic path that takes a change from version control to the users. The path has stages that cost more and give more confidence as you go. A change either fails early with feedback, or becomes a release candidate.

commit  -->  acceptance  -->  manual/other  -->  release
stage        tests            checks
(minutes)    (slower)         (optional)

  fail here = developer is told in minutes
  pass = same artefact moves to the next stage
Stages of a deployment pipeline

The first stage is the commit stage. It compiles the code, runs the unit tests and the fast code analysis, and produces the artefact (the packaged program). It must be fast, a few minutes, so developers can wait for the answer before moving on. Then comes a golden rule: build the binaries once, store the artefact, and move that same artefact through every later stage. A rebuilt program is a different program, because tool versions and settings can change between builds. Differences between environments are handled by configuration, never by building again.

What if a feature is half-done but you must merge every day? With trunk-based development everybody works on the mainline, so unfinished work must be safe to ship. A feature flag (also called a feature toggle) is a switch in the code: the new code is merged and even deployed, but stays switched off for users. Remove the flag once the feature is released, or flags pile up as technical debt.

↑ Back to the contents

GitHub Actions: workflows, events, jobs and steps

GitHub Actions is the tool that runs the pipeline of this repository. Think of a kitchen. The recipe is a workflow. The recipe starts when something happens, like a customer ordering: that is an event. The recipe is split into jobs, each done by a different cook in a different kitchen, and each job is a list of steps done one after another.

A workflow is a file stored in the folder .github/workflows/ of the repository. Because it lives in the repository, any change to the pipeline is reviewed and saved in the history like the rest of the code.

Events: what starts a workflow

The key on lists the events. The most common are push (someone pushed commits), pull_request (a request to merge a branch was opened or updated), workflow_dispatch (a person starts it by hand from the Actions tab, and may fill in a form of typed inputs) and schedule (a timer). A filter such as branches, tags or paths belongs to the event it is written under.

on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:
  schedule:
    - cron: '30 5 * * 1'
Four triggers. Without 'types', pull_request defaults to opened, synchronize and reopened

In the example, the push filter only applies to push, so a pull request on any branch still starts the workflow. A pull request with no types reacts to three activities: opened, synchronize (new commits were pushed to it) and reopened.

The schedule event uses syntax: minute, hour, day of month, month, day of week. The times are in UTC, and the run uses the latest commit of the default branch. So 30 5 * * 1 means Monday at 05:30 UTC. Scheduled runs can start late when GitHub is busy, so they are not exact to the minute.

Jobs and steps

  • A job runs on its own fresh machine. Every regular job needs runs-on to say which kind of machine it wants.
  • Jobs run in parallel by default. needs creates an order: a job waits for the jobs it lists, and is skipped if one of them fails (unless its if says otherwise).
  • A step is one task inside a job. Steps of the same job run in order, on the same machine, sharing the same files.
  • Jobs are isolated from each other. They exchange data only on purpose, with outputs or artifacts.
jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [...]
  test:
    runs-on: ubuntu-latest
    steps: [...]
  deploy:
    needs: [lint, test]
    runs-on: ubuntu-latest
    steps: [...]
lint and test run together; deploy starts only after both

A step does one of two things. With uses it runs a ready-made action, a reusable piece such as actions/checkout, and you give it settings with with. With run it runs your own shell commands, such as bun test. A fresh runner starts with an empty folder, so most jobs begin with actions/checkout, which downloads the commit being built (by default only that one commit).

Contexts, conditions and variables

Text written as ${{ ... }} is an expression. It can read contexts, which are bags of information. The github context describes the event. On a push, github.ref is something like refs/heads/main. On a pull request it is refs/pull/<n>/merge, and github.head_ref and github.base_ref hold the source and target branch. So if: github.ref == 'refs/heads/main' runs a step only after a merge into main, and it is skipped in a pull request.

  • env sets environment variables for the whole workflow, one job or one step. The most specific level wins for a given name: step beats job, job beats workflow.
  • Without an if, a step runs only if all earlier steps succeeded (the hidden default is success()). failure() runs only after a failure, and always() runs no matter what, which is good for uploading reports or cleaning up.
  • concurrency lets only one run of a group go at a time. With cancel-in-progress: true, a new run cancels the old one in the same group. Grouping by github.ref saves time on branches that get many pushes.

Passing values between steps and jobs

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.meta.outputs.tag }}
    steps:
      - id: meta
        run: echo "tag=v1.2.3" >> "$GITHUB_OUTPUT"
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "${{ needs.build.outputs.tag }}"
Two hops: step output, then job output, then needs

A step writes a line name=value into the file whose path is in $GITHUB_OUTPUT. Later steps of the same job read it as steps.<id>.outputs.<name>, so the producing step needs an id. To reach another job, the job must map the value in its own outputs, and the other job reads needs.<job>.outputs.<name>. Forget the mapping and you get an empty string.

↑ Back to the contents

Runners and matrix builds

A runner is the computer that does the work of a job. GitHub-hosted runners are made from a maintained image (Ubuntu, Windows or macOS). They are ephemeral: a brand-new machine is created for one job and thrown away when the job ends. That makes builds clean and repeatable, but it also means every job must install its tools again, which is why caching matters.

Labels such as ubuntu-latest point to the newest image GitHub considers stable, and that target moves over time. A versioned label such as ubuntu-24.04 stays fixed until it is retired. Pinning trades automatic updates for reproducibility, which is why this repository writes ubuntu-24.04.

Self-hosted runners

You can also run your own machine as a self-hosted runner. It has default labels (self-hosted, its operating system and its architecture) plus custom ones. runs-on: [self-hosted, linux, gpu] is a list of required labels: only a runner that has all three can take the job.

The matrix

What if you want to test your program on three operating systems and three Node.js versions? Writing nine jobs by hand is boring. A matrix under strategy lists variables and their values, and GitHub makes one job for every combination (the Cartesian product). Each job reads its own values with the matrix context, even in runs-on.

test:
  strategy:
    matrix:
      os: [ubuntu-latest, macos-latest, windows-latest]
  runs-on: ${{ matrix.os }}
  steps:
    - uses: actions/checkout@v5
    - run: bun test

Two variables with 2 and 3 values give 2 x 3 = 6 jobs. A single workflow run can create at most 256 matrix jobs. Two extra keys change the list:

  • exclude removes the combinations it matches.
  • include adds extra keys to a matching combination. If it would overwrite one of the original values, it becomes a new combination of its own.
  • The order is: first the product, then the exclude removals, and last the include entries. Counting jobs means following that order.
os: [ubuntu, windows]  x  node: [20, 22]  = 4 jobs

 ubuntu-20   ubuntu-22   windows-20   windows-22

exclude windows+20  -->  3 jobs
include ubuntu+24   -->  4 jobs (24 is not an original
                         value, so it is a new combination)
Counting a matrix, step by step

When one job fails

By default fail-fast is true: when one matrix job fails, the jobs still waiting or running are cancelled. That saves runner time when the failure will probably repeat everywhere. Set fail-fast: false to let every combination finish, so you see exactly which platforms fail.

For an experimental version, such as a next Node.js release, use continue-on-error: ${{ matrix.experimental }} on the job. Failures of those combinations stay visible but do not fail the run, while failures of the stable combinations still do.

↑ Back to the contents

Caching and artifacts

Because every hosted runner starts empty, each run would download the same packages again and again. Two tools store files outside the runner, for two different reasons.

FeatureCacheArtifact
PurposeSpeed: avoid repeating slow workCarry results: to later jobs, to people, to a release
Reused byMany runs, found by a keyOne workflow run
If it is lostOnly costs time, the job recomputesThe result is gone
Typical contentDownloaded packagesBuild output, test reports, coverage

How a cache works

actions/cache saves a folder under a key and restores it on a later run when the key matches. The key should name what the files depend on. The function hashFiles() turns the content of a lockfile into text, so the key changes exactly when the dependencies change. Adding runner.os keeps the caches of different systems apart.

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: deps-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      deps-${{ runner.os }}-
An exact hit needs the same OS and the same lockfile content
  • Exact hit: a cache with exactly this key exists. It is restored, and nothing new is saved.
  • Partial hit: the exact key missed, so the action tries the prefixes of restore-keys in order and restores an older but similar cache. That is usually much faster than starting from nothing. Because the exact key missed, a fresh cache is saved at the end, so the next run gets an exact hit.
  • Miss: nothing matches. The job does the slow work and saves a new cache.

A cache entry is immutable: once a key is saved, it never changes. So a constant key such as deps-${{ runner.os }} is a trap. The first run saves it, every later run hits it, and the cache silently becomes stale. The key must change whenever the cached content should change.

Cache access is limited by branch. A run can restore caches from its own branch and from the default branch (for pull requests, also from the base branch). A cache saved on main helps every branch, while a cache saved on a feature branch helps only that branch. Language setup actions can do all this for you: cache: npm in actions/setup-node picks the folder and builds the key from the lockfile.

How artifacts work

A job uploads files with actions/upload-artifact and another job downloads them with actions/download-artifact. This is the right way to give a dist/ folder from a build job to a deploy job, and it also keeps outputs that people can download. It is the build-once idea in action: the files deployed are the files that were built and tested.

- uses: actions/upload-artifact@v4
  with:
    name: coverage-${{ matrix.os }}
    path: coverage/
    retention-days: 1
A unique name per matrix job, and a short retention
  • Since version 4, artifacts are immutable and their names must be unique within a run. Matrix jobs that upload must put matrix values in the name. A later job can download them by pattern and merge them.
  • Artifacts and logs are kept 90 days by default. Administrators can change that, and an upload can ask for fewer days with retention-days. Shorter retention saves storage, which counts against your quota.
↑ Back to the contents

Secrets, environments and permissions

A pipeline needs passwords and keys: to publish a package, to enter a server. Those are secrets. If they leak, anyone can pretend to be you. So the pipeline follows the idea of least privilege: every job gets only the powers it really needs, and nothing more.

Secrets and variables

Secrets are stored encrypted in the settings of a repository, an organisation or an environment, and read with ${{ secrets.NAME }}. The usual pattern is to map one into env for the single step that needs it. GitHub hides the exact value of a secret in logs, replacing it with ***. But the hiding matches the exact text, so a changed form (encoded, split, wrapped in JSON) can still leak. You can ask for more masking with ::add-mask::, but the best plan is never to print secrets.

Not everything is secret. A cloud region name is plain configuration. Put it in a variable and read it as ${{ vars.NAME }}. Secrets and variables can be set per environment, so the same workflow picks different values for staging and production.

The GITHUB_TOKEN and permissions

For each job GitHub creates a temporary key, the GITHUB_TOKEN. It works only on the current repository and expires when the job ends, so it is safer than a personal access token. Its powers come from the permissions key. As soon as you list a scope, everything you did not list becomes none.

permissions: {}
jobs:
  test:
    runs-on: ubuntu-latest
    steps: [...]
  release:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps: [...]
No powers at the top, extra power only where needed

Here the test job gets no permissions at all, so even a hijacked step cannot write to the repository. A permissions block in a job replaces the workflow-level one for that job. The workflow of this repository starts with contents: read.

One more detail: things done with the GITHUB_TOKEN (pushing a commit, opening a pull request, creating a tag) do not start new workflow runs. This avoids endless loops where workflows trigger each other. If you need to chain, use workflow_dispatch, workflow_run or a token from a GitHub App.

Environments

An environment (such as production) is a named place to deploy, with its own rules, secrets and variables. A job joins it with environment: production. Then:

  • Required reviewers: a person must approve before the job starts. This is the usual way to add a manual release decision to continuous delivery.
  • Branch rules: only chosen branches or tags may deploy there. GitHub checks this itself, so it holds even if someone edits the workflow on a feature branch.
  • Environment secrets: released only to jobs that name the environment, after its rules are met. A job with no environment: key reads an empty value for them.

Pull requests from forks

A sends code from outside the trust boundary. So for the pull_request event, workflows from forks run without secrets and with a read-only token. This is on purpose.

OIDC: secrets without secrets

To deploy to a cloud, you do not have to store a long-lived key. With OpenID Connect (OIDC) the job asks GitHub for a short ID token and shows it to the cloud, which trusts it only if claims like the repository and environment match its policy. The job needs permissions: id-token: write to request that token. The credentials are short-lived, so there is no key to leak or rotate.

↑ Back to the contents

Custom actions and reusable workflows

When you copy the same ten lines into twenty workflows, you will forget to fix one of them someday. GitHub gives two ways to write something once and use it many times, and they work at different sizes.

Custom actions

An action is a reusable unit of work that a step calls with uses. It lives in a folder with a metadata file called action.yml (or action.yaml). That file declares the inputs it accepts (with description, default and whether required), the outputs it produces, and the runs section that says how it executes. There are three kinds:

KindHow it runsWhere it runsGood for
JavaScriptNode.js codeLinux, Windows and macOSFast and portable
Docker containerInside an image you buildLinux runners onlyFull control of the environment, but slower to start
CompositeA list of steps, which may call other actionsAnywhere its steps can runPacking a few steps together

A composite action runs inside the caller's job, on the caller's runner. It has two famous rules. First, each run step must declare its shell explicitly, because composite steps have no default shell. Second, a composite action cannot hand a value out by magic: the steps inside have private outputs, so action.yml must map an output with value: pointing at an inner step output.

outputs:
  version:
    description: Computed version
    value: ${{ steps.calc.outputs.version }}
runs:
  using: composite
  steps:
    - id: calc
      shell: bash
      run: echo "version=1.4.0" >> "$GITHUB_OUTPUT"
The output mapping and the explicit shell

Reusable workflows

A reusable workflow is a normal workflow file that has workflow_call in its on. It declares typed inputs and the secrets it needs. A caller runs it as a job with uses: at job level, pointing to owner/repo/.github/workflows/<file>@<ref> (or a local path). Inputs go under with. Secrets go one by one under secrets, or all at once with secrets: inherit inside the same organisation. A caller job that uses a reusable workflow cannot have steps of its own.

jobs:
  ci:
    uses: my-org/shared/.github/workflows/ci.yml@v1
    with:
      node-version: 22
    secrets: inherit

A reusable workflow is isolated from its caller. Only the declared inputs and secrets go in, and its outputs go back. The caller's env is not passed down, so a variable set at the top of the caller prints empty inside the called workflow.

Which one to pick?

FeatureComposite actionReusable workflow
What it packsSome stepsWhole jobs
Where it runsInside the caller's job and runnerIn its own jobs, with their own runners
Can haveSteps onlyMatrices, environments, secrets, many jobs
Best forA small repeated recipeA standard pipeline shared by many repositories

Versions of actions

Actions are versioned with Git refs. A common habit is to publish exact tags such as v4.2.1 and also a moving major tag v4 that the author moves forward. A consumer can choose @v4 (automatic compatible updates), @v4.2.1 (one exact release) or a full commit SHA (immutable). Each choice trades convenience for control.

↑ Back to the contents

Quality gates

At the airport you pass the ticket check, the security check and the boarding gate. If any one says no, you stop. A quality gate is a check in the pipeline that a change must pass to go on. The cheap, fast gates come first (format, lint, type check, unit tests), so most bad changes are stopped within minutes. A gate must be automatic and binary, pass or fail. If people can negotiate with it, it is no longer a gate.

A gate is just an exit code

How does GitHub Actions know a command passed? By the Unix contract: exit code 0 means success, any other number means failure. Linters, type checkers and test runners all follow it, so a pipeline can chain them. A script that finds a problem, prints it and still exits with 0 is a gate that never closes.

- name: Documentation index is up to date
  run: bun run docs:index --check
A check mode: it writes nothing and fails if the committed file differs

This example is used by this repository. Some files are generated from others and also committed, and they can drift away from their source. With --check, the script rebuilds the result in memory, compares it with the committed file and exits non-zero on any difference. CI never changes the repository: it reports, and the developer applies the fix locally. Formatters work the same way (biome ci).

Making a gate binding on GitHub

A failing workflow is only a red mark until you protect the branch. Branch protection rules (or rulesets) turn chosen checks into required status checks. Then GitHub refuses to merge a pull request while a required check is failing, pending or absent.

  • Up to date before merging (strict checks): the result counts only if the pull request branch contains the latest base branch, so the tested code equals the merged code. Two changes can each pass alone and break when combined. The price is more builds, because every merge makes other open pull requests out of date.
  • Merge queue: pays that price automatically. It builds a temporary branch with the latest base plus the queued pull requests, and runs the required checks on that combination. GitHub announces this with the merge_group event, so every workflow that reports a required check needs on: merge_group next to pull_request. If it is missing, the queued pull request waits for a check that never comes.

Skipped checks: a careful detail

How the check is skippedWhat GitHub reportsEffect on merging
The workflow did not start (branch or path filter)The check is pending, never reportedThe pull request is blocked
A job was skipped by its if conditionThe job counts as successThe pull request can merge, though nothing was tested

So a required gate must not be skippable by something the author controls, such as adding or removing a label.

Thresholds, such as coverage

A commit stage can fail on measurable thresholds: compiler warnings, style breaches, duplication, code coverage (the share of lines that run during tests). A coverage floor like 80 percent is useful, since code that never runs in a test is surely unchecked. But it is a weak ceiling: running a line is not the same as checking its behaviour, and when the number becomes a target, people write tests only to raise it.

↑ Back to the contents

Deployment strategies

Your new version passed every gate. Now you must put it in front of real users without breaking their day. First, an important pair of words from Continuous Delivery. Deployment is technical: putting a version in an environment and starting it. Release is a business act: letting users reach that version or feature. Keeping them apart lowers the risk of both, because you can deploy and check the new version in production before anyone sees it. All the strategies below are ways to release something already deployed.

Blue-green

Keep two production environments as alike as possible, called blue and green. Only one gets user traffic. You install the new version in the idle one and test it there. Releasing means changing the router or load balancer to point to it, and it takes seconds.

          +--> blue  (v1)  live
users --> router
          +--> green (v2)  idle, smoke-tested

switch the router:
          +--> blue  (v1)  idle, ready for rollback
users --> router
          +--> green (v2)  live
Blue-green: the old version stays ready

Rollback is also a switch: send traffic back to the old environment. It takes seconds and needs no rebuild.

Canary

Old coal miners took a canary bird into the mine, because it fell ill first if the air was bad. A canary release runs the new version next to the old one, giving it a small share of the real traffic. If its numbers are as good as the old version, the share grows until 100 percent. If they are worse, the traffic goes back and few users were affected.

A canary is an experiment with a control group: the same metric, measured at the same time, on old and new. Compare rates, never raw counts, because the groups have very different sizes. And decide the rule before the rollout, so a tool and not a hopeful person decides to continue or stop.

Error rate = errors divided by requests. Compare the two rates against the rule you wrote down
VersionRequestsErrorsError rate
Stable49,0009898 / 49,000 = 0.2%
Canary1,0003131 / 1,000 = 3.1%

Rolling update

A rolling update replaces the instances of a service in batches, a few at a time. It needs little extra hardware and causes no downtime, but for the whole rollout two versions are alive together. So the new version must be backward compatible with the old in everything they share: database schema, message formats, sessions and the API seen by clients.

It also lowers capacity while it runs. The capacity left is (instances minus batch) times the capacity of one instance. With 5 instances of 100 requests per second each, a batch of 2 leaves 3 x 100 = 300 requests per second. If the peak load is higher than that, the batch is too big. Batch size is a trade between the speed of the rollout and the safety margin, and adding temporary extra instances (a surge) removes the trade at the price of hardware.

Smoke tests

A deployment can fail even when the code is perfect: a wrong connection string, a missing environment variable, a closed firewall port. A smoke test is a short automatic check run right after the deployment, before user traffic arrives. It checks that the running application starts and reaches its database, queues and other services. If it fails, the deployment is rolled back or never released, which costs nothing when users were kept away.

The hard part: data

Code can be rolled back by flipping a router. Data cannot. If version 2 changes the database in a way version 1 cannot read, the rollback path of blue-green, canary and rolling updates is gone. The same is true for rows, files, caches and queue messages that version 2 wrote. The safe plan is to split the change into steps, called expand and contract:

  1. Expand: change the schema in a compatible way, for example add the new column full_name and keep the old name.
  2. Deploy code that can read both, then release the new version.
  3. Contract: in a later deployment, when no rollback to old code is expected, remove the old column.
↑ Back to the contents

SemVer, Conventional Commits and changelog

When a friend says 'version 2.4.7', does that tell you anything? With Semantic Versioning 2.0.0 (SemVer) it does. A version has the form MAJOR.MINOR.PATCH, and each part promises something to the people who use the software.

When a number grows, every number to its right goes back to 0
PartGrows whenExample from 2.4.7
PATCHA backward-compatible bug fix2.4.8
MINORBackward-compatible new functionality2.5.0
MAJORAn incompatible change to the public API3.0.0

Careful: with a major version above zero, any incompatible change to the public API needs a new major. (Versions 0.y.z are for early development, and this repository, which is before 1.0.0, uses a minor bump for a new mini-project and a patch bump for fixes.)

Pre-releases and build metadata

A pre-release is written with a hyphen and dot-separated identifiers, such as 1.0.0-beta.2. It always sorts before the normal version it prepares. Two pre-releases are compared identifier by identifier: numbers as numbers, words in ASCII (alphabet) order, and a number is lower than a word. Comparing versions as plain text gives wrong answers (10 would be smaller than 9), so tools use a SemVer library.

1.0.0-alpha  <  1.0.0-alpha.1  <  1.0.0-beta
  <  1.0.0-beta.2  <  1.0.0-beta.11  <  1.0.0-rc.1
  <  1.0.0

1.0.0+build.5  and  1.0.0+build.9  --> same precedence
Order of precedence, lowest to highest

Build metadata comes after a plus, such as 1.0.0+20261010.sha.5114f85. It names a build and is ignored when comparing: two versions that differ only in it have the same precedence. So never put information that matters for upgrades in build metadata.

Conventional Commits

A commit message is easier for machines to read when it has a fixed shape. Conventional Commits 1.0.0 says the first line is type(scope): description. The type tells what kind of change it is (fix, feat, and others such as docs, chore or refactor), and the optional scope says where.

CommitSemVer bump
fix(api): return 404 for unknown idPATCH
feat(cli): add --json flagMINOR
Any commit with a breaking changeMAJOR
docs, chore, refactor... with no breaking changeNo bump on their own

A breaking change is marked in one of two ways, which can be combined: an exclamation mark just before the colon (refactor(auth)!: ...), or a footer that starts with BREAKING CHANGE: in capital letters. It does not depend on the type: a refactor, a fix or a chore can break the API. A release covers many commits but gets one bump, the highest one any of them asks for. If the last release was 1.3.2 and the new commits include a fix, a feat and a docs, the release is 1.4.0. If one of them is breaking, it is 2.0.0.

Keep a Changelog

A changelog tells the people who use the software what is different in each version. It is not a copy of git log: commit messages document steps for the people who build the code, while a changelog is written for the people who use it. Keep a Changelog 1.1.0 asks for one entry per version, latest first, with the date as YYYY-MM-DD, and an Unreleased section at the top for what is coming. Changes are grouped under six headings:

HeadingUse it for
AddedNew features
ChangedChanges to existing behaviour
DeprecatedFeatures that will be removed soon (the warning)
RemovedFeatures removed now (the removal that was warned about)
FixedBug fixes
SecurityFixes for vulnerabilities
↑ Back to the contents

Supply-chain security in CI

Your pipeline uses code from other people: actions, packages, base images. That code is part of your supply chain, the long road from other people's work to the program in your users' hands. If an attacker slips into one part of the chain, your pipeline and its secrets are in danger. Three simple ideas protect you.

1. Pin actions to a full commit SHA

An action runs inside your job, with access to your code, your GITHUB_TOKEN and the secrets you give it. A tag such as v3, or a branch, is a movable pointer: owner/action@v3 means whatever the owner decides tomorrow. A full-length commit SHA is the only immutable reference. The cost is that updates stop being automatic, so teams pair pinning with a bot that opens pull requests with new SHAs.

ReferenceCan it change under you?
@main (a branch)Yes, on every push
@v3 (a tag)Yes, the owner can move it
@<40-character SHA>No, it is immutable

2. Never paste untrusted text into a script

A ${{ }} expression inside a run step is replaced by its value when GitHub writes the script file, before any shell starts. If the value comes from a user (a pull request title, a branch name, an issue body, a commit message), the user is writing part of your script. This is script injection.

# Risky: the title becomes part of the script
- run: echo "Checking ${{ github.event.pull_request.title }}"

# Safer: the text travels as data in a variable
- run: echo "Checking $TITLE"
  env:
    TITLE: ${{ github.event.pull_request.title }}
Use an intermediate environment variable

3. Be careful with pull_request_target

As you saw, pull_request from a fork is safe by design: no secrets and a read-only token. pull_request_target is the opposite. It runs the workflow of the base repository and gives it secrets, assuming only trusted code will run. If the workflow checks out and runs the head of the pull request, it runs a stranger's code with your secrets, and the assumption is broken.

  • Build and test untrusted code under pull_request, with no privileges.
  • Do privileged work (like commenting or labelling) in a separate workflow that never executes that code.

SLSA and provenance

When you download a package, how do you know it was built from the public source and not changed on the way? SLSA (Supply-chain Levels for Software Artifacts, pronounced 'salsa') answers with build provenance: a signed statement that links an artifact (by its digest, a fingerprint of its content) to the builder, the build process and the source commit.

Build levelWhat you get
L1Provenance exists
L2Provenance is signed by a hosted build platform
L3The platform is hardened so that the build itself cannot forge the provenance
↑ Back to the contents

Putting it together: the pipeline of this repository

Everything you read is used in the file .github/workflows/ci.yml of this repository. Let us read it like a map.

Concepts of this summary found in the real pipeline
IdeaWhere it shows up in ci.yml
EventsRuns on push to main, on every pull_request and by hand with workflow_dispatch
Least privilegeA top-level permissions: contents: read
ConcurrencyA group made of the event name and the ref, with cancel-in-progress: true, so a push does not cancel a manual full run
Pinned runnerruns-on: ubuntu-24.04, not ubuntu-latest
Parallel jobstypescript, formatters, quiz and list-projects run at the same time
Dependent jobmini-projects has needs: list-projects
Matrixformatters has a matrix for five languages with fail-fast: false, and mini-projects has one job per project
Dynamic matrixlist-projects writes JSON to $GITHUB_OUTPUT, and the matrix reads it with fromJson
Quality gatesbiome ci, type check, unit tests, quiz validation and docs:index --check, all by exit code
Safe use of inputThe format command travels in an environment variable, not pasted into the script
push / pull_request
        |
        +--> typescript   (lint, types, tests, docs check)
        +--> formatters   (matrix: 5 languages)
        +--> quiz         (unit + end-to-end)
        +--> list-projects --> mini-projects (matrix, 1 each)

All run in parallel. Any red job blocks the merge when
it is a required status check.
The shape of the pipeline

Common mistakes to avoid

  • Calling something CI when branches live for weeks. Real CI means small merges into the mainline every day.
  • Putting the slowest checks first. Fail fast, with the cheap checks at the front.
  • A constant cache key, which makes a stale cache. A cache key must change with the content.
  • Uploading artifacts with the same name from every matrix job.
  • Granting broad permissions by default, or giving secrets to code from forks.
  • Making a required check skippable (if with a label) or filtered by paths.
  • Rolling back code while forgetting that the database or the queue already changed.
  • Pinning actions to movable tags, or pasting ${{ github.event... }} text straight into run.
↑ Back to the contents