Skip to content

Instantly share code, notes, and snippets.

@vladiant
Last active July 5, 2026 11:08
Show Gist options
  • Select an option

  • Save vladiant/d4e23b09ef8e4acd40c0a97d9a861f63 to your computer and use it in GitHub Desktop.

Select an option

Save vladiant/d4e23b09ef8e4acd40c0a97d9a861f63 to your computer and use it in GitHub Desktop.
C++ GitHub Actions
name: ci
# Trigger on pushes to all branches and for all pull-requests
on: [push, pull_request]
env:
CMAKE_VERSION: 3.28.2
NINJA_VERSION: 1.10.0
jobs:
build:
name: ${{ matrix.config.name }}
runs-on: ${{ matrix.config.os }}
strategy:
fail-fast: false
matrix:
config:
# GCC-13
- {
name: "Linux GCC 13",
os: ubuntu-22.04,
build_type: Release,
cxx: "g++-13",
gcc_version: 13,
}
# Clang-17
- {
name: "Linux Clang 17",
os: ubuntu-22.04,
build_type: Release,
cxx: "clang++-17",
clang_version: 17,
libcxx: true
}
# # AppleClang
# - {
# name: "macOS Clang",
# os: macos-latest,
# build_type: Release,
# cxx: "clang++",
# }
# MSVC 2019
- {
name: "Windows MSVC 2019",
os: windows-latest,
build_type: Release,
cxx: "cl",
environment_script: "C:/Program Files (x86)/Microsoft Visual Studio/2022/Enterprise/VC/Auxiliary/Build/vcvars64.bat",
}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Download Ninja and CMake
id: cmake_and_ninja
shell: cmake -P {0}
run: |
set(cmake_version $ENV{CMAKE_VERSION})
set(ninja_version $ENV{NINJA_VERSION})
message(STATUS "Using host CMake version: ${CMAKE_VERSION}")
if ("${{ runner.os }}" STREQUAL "Windows")
set(ninja_suffix "win.zip")
set(cmake_suffix "windows-x86_64.zip")
set(cmake_dir "cmake-${cmake_version}-windows-x86_64/bin")
elseif ("${{ runner.os }}" STREQUAL "Linux")
set(ninja_suffix "linux.zip")
set(cmake_suffix "linux-x86_64.tar.gz")
set(cmake_dir "cmake-${cmake_version}-linux-x86_64/bin")
elseif ("${{ runner.os }}" STREQUAL "macOS")
set(ninja_suffix "mac.zip")
set(cmake_suffix "macos-universal.tar.gz")
set(cmake_dir "cmake-${cmake_version}-macos-universal/CMake.app/Contents/bin")
endif()
set(ninja_url "https://github.com/ninja-build/ninja/releases/download/v${ninja_version}/ninja-${ninja_suffix}")
file(DOWNLOAD "${ninja_url}" ./ninja.zip SHOW_PROGRESS)
execute_process(COMMAND ${CMAKE_COMMAND} -E tar xf ./ninja.zip)
set(cmake_url "https://github.com/Kitware/CMake/releases/download/v${cmake_version}/cmake-${cmake_version}-${cmake_suffix}")
file(DOWNLOAD "${cmake_url}" ./cmake.zip SHOW_PROGRESS)
execute_process(COMMAND ${CMAKE_COMMAND} -E tar xf ./cmake.zip)
# preserve it for the next steps
file(TO_CMAKE_PATH "$ENV{GITHUB_WORKSPACE}/${cmake_dir}" cmake_dir)
message("::set-output name=cmake_dir::${cmake_dir}")
if (NOT "${{ runner.os }}" STREQUAL "Windows")
execute_process(
COMMAND chmod +x ninja
COMMAND chmod +x ${cmake_dir}/cmake
)
endif()
- name: Install Clang 17
id: install_clang_17
if: startsWith(matrix.config.os, 'ubuntu') && startsWith(matrix.config.cxx, 'clang++-')
shell: bash
working-directory: ${{ env.HOME }}
run: |
wget https://apt.llvm.org/llvm.sh
chmod +x llvm.sh
sudo ./llvm.sh ${{ matrix.config.clang_version }}
sudo apt-get install -y libunwind-${{ matrix.config.clang_version }}-dev libunwind-${{ matrix.config.clang_version }}
- name: Install g++ 13
id: install_gcc_13
if: startsWith(matrix.config.os, 'ubuntu') && ( matrix.config.cxx == 'g++-13' )
shell: bash
working-directory: ${{ env.HOME }}
env:
CXX: ${{ matrix.config.cxx }}
run: |
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
sudo add-apt-repository ppa:ubuntu-toolchain-r/test
sudo apt-get update
sudo apt-get install g++-${{ matrix.config.gcc_version }}
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-13 100
- name: Install libc++
id: install_libcxx
if: matrix.config.libcxx
shell: bash
working-directory: ${{ env.HOME }}
env:
CXX: ${{ matrix.config.cxx }}
run: |
sudo apt-get install libc++-${{ matrix.config.clang_version }}-dev libc++abi-${{ matrix.config.clang_version }}-dev
- name: Setup MSVC Dev
if: "startsWith(matrix.config.os, 'Windows')"
uses: ilammy/msvc-dev-cmd@v1
- name: Configure
id: cmake_configure
shell: cmake -P {0}
run: |
set(ENV{CXX} ${{ matrix.config.cxx }})
if ("${{ runner.os }}" STREQUAL "Windows")
execute_process(
COMMAND "${{ matrix.config.environment_script }}" && set
OUTPUT_FILE environment_script_output.txt
)
set(cxx_flags "/permissive- /EHsc")
file(STRINGS environment_script_output.txt output_lines)
foreach(line IN LISTS output_lines)
if (line MATCHES "^([a-zA-Z0-9_-]+)=(.*)$")
set(ENV{${CMAKE_MATCH_1}} "${CMAKE_MATCH_2}")
endif()
endforeach()
endif()
set(path_separator ":")
if ("${{ runner.os }}" STREQUAL "Windows")
set(path_separator ";")
endif()
set(ENV{PATH} "$ENV{GITHUB_WORKSPACE}${path_separator}$ENV{PATH}")
if ("x${{ matrix.config.libcxx }}" STREQUAL "xtrue")
set(cxx_flags "${cxx_flags} -stdlib=libc++ -Wno-unused-command-line-argument")
set(link_flags "${link_flags} -lc++abi")
endif()
execute_process(
COMMAND ${{ steps.cmake_and_ninja.outputs.cmake_dir }}/cmake
-S .
-B build
-G Ninja
-D CMAKE_BUILD_TYPE=${{ matrix.config.build_type }}
-D CMAKE_MAKE_PROGRAM:STRING=ninja
-D "CMAKE_CXX_FLAGS:STRING=${cxx_flags}"
-D "CMAKE_EXE_LINKER_FLAGS:STRING=${link_flags}"
${{ matrix.config.cmake_args }}
RESULT_VARIABLE result
)
if (NOT result EQUAL 0)
message(FATAL_ERROR "Bad exit from cmake configure status")
endif()
- name: Build
shell: cmake -P {0}
continue-on-error: false
run: |
set(ENV{NINJA_STATUS} "[%f/%t %o/sec] ")
if ("${{ runner.os }}" STREQUAL "Windows")
execute_process(
COMMAND "${{ matrix.config.environment_script }}" && set
OUTPUT_FILE environment_script_output.txt
)
set(cxx_flags "/permissive- /EHsc")
file(STRINGS environment_script_output.txt output_lines)
foreach(line IN LISTS output_lines)
if (line MATCHES "^([a-zA-Z0-9_-]+)=(.*)$")
set(ENV{${CMAKE_MATCH_1}} "${CMAKE_MATCH_2}")
endif()
endforeach()
endif()
set(path_separator ":")
if ("${{ runner.os }}" STREQUAL "Windows")
set(path_separator ";")
endif()
set(ENV{PATH} "$ENV{GITHUB_WORKSPACE}${path_separator}$ENV{PATH}")
execute_process(
COMMAND ${{ steps.cmake_and_ninja.outputs.cmake_dir }}/cmake --build build
RESULT_VARIABLE result
)
if (NOT result EQUAL 0)
message(FATAL_ERROR "Bad exit status from building")
endif()
name: cmake
on:
push:
paths:
- "**.cpp"
- "**.ixx"
- "**.cmake"
- "**/CMakeLists.txt"
- ".github/workflows/cmake.yml"
workflow_dispatch:
# avoid wasted runs
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CTEST_NO_TESTS_ACTION: error
CTEST_PARALLEL_LEVEL: 0
CMAKE_BUILD_PARALLEL_LEVEL: 4
HOMEBREW_NO_AUTO_UPDATE: 1
jobs:
gcc-new:
runs-on: ubuntu-latest
timeout-minutes: 5
strategy:
matrix:
cxx: [g++-12, g++-13, g++-14, clang++-18]
env:
CXX: ${{ matrix.cxx }}
steps:
- uses: actions/checkout@v4
- run: cmake --workflow debug
- run: cmake --workflow release
- uses: actions/upload-artifact@v4
if: success()
with:
name: ${{ matrix.cxx }}_features.json
path: build/features.json
gcc-old:
runs-on: ubuntu-22.04
timeout-minutes: 5
strategy:
matrix:
cxx: [g++-9, g++-10, g++-11]
env:
CXX: ${{ matrix.cxx }}
steps:
- uses: actions/checkout@v4
- run: cmake --workflow debug
- run: cmake --workflow release
- uses: actions/upload-artifact@v4
if: success()
with:
name: ${{ matrix.cxx }}_features.json
path: build/features.json
mac:
runs-on: macos-latest
timeout-minutes: 5
env:
HOMEBREW_NO_AUTO_CLEANUP: 1
CXX: ${{ matrix.cxx }}
strategy:
matrix:
cxx: [clang++, g++-14]
steps:
- uses: actions/checkout@v4
- run: cmake --workflow debug
- run: cmake --workflow release
- uses: actions/upload-artifact@v4
if: success()
with:
name: ${{ runner.os }}-${{ matrix.cxx }}_features.json
path: build/features.json
windows-msvc:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- run: cmake --workflow msvc
- uses: actions/upload-artifact@v4
if: success()
with:
name: ${{ runner.os }}-MSVC_features.json
path: build/features.json
windows-gcc:
runs-on: windows-latest
timeout-minutes: 10
steps:
- uses: msys2/setup-msys2@v2
id: msys2
with:
update: true
install: >-
mingw-w64-ucrt-x86_64-gcc
# need GCC install to get latest G++
- name: Put MSYS2_MinGW64 on PATH
run: echo "${{ steps.msys2.outputs.msys2-location }}/ucrt64/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- uses: actions/checkout@v4
- run: cmake --workflow debug
- run: cmake --workflow release
- uses: actions/upload-artifact@v4
if: success()
with:
name: ${{ runner.os }}-GCC_features.json
path: build/features.json
- name: upload CMakeConfigureLog.yaml
if: failure() && hashFiles('build/CMakeFiles/CMakeConfigureLog.yaml') != ''
uses: actions/upload-artifact@v4
with:
name: ${{ runner.os }}-CMakeConfigureLog.yaml
path: build/CMakeFiles/CMakeConfigureLog.yaml
# This is a basic workflow to help you get started with Actions
# workflow - цепочка действий
# Имя процесса Билдится на всех типах 📦 🐍
name: CMake Build Matrix
# Controls when the action will run. Triggers the workflow on push
on:
push:
pull_request:
release:
# tags:
# - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
name: ${{ matrix.config.name }}
runs-on: ${{ matrix.config.os }} # будет запускаться по очереди на всех типах машин
strategy:
fail-fast: false
matrix:
config:
- {
name: "Windows Latest MSVC",
os: windows-latest,
artifact: "windows_msvc.7z",
build_type: "Release",
cc: "cl",
cxx: "cl",
environment_script: "C:/Program Files (x86)/Microsoft Visual Studio/2019/Enterprise/VC/Auxiliary/Build/vcvars64.bat",
archiver: "7z a",
generators: "Visual Studio 16 2019"
}
- {
name: "Windows Latest MinGW",
os: windows-latest,
artifact: "windows_mingw.7z",
build_type: "Release",
cc: "gcc",
cxx: "g++",
archiver: "7z a",
generators: "Ninja"
}
- {
name: "Ubuntu_Latest_GCC",
os: ubuntu-latest,
artifact: "ubuntu_gcc.7z",
build_type: "Release",
cc: "gcc",
cxx: "g++",
archiver: "7z a",
generators: "Ninja"
}
- {
name: "Ubuntu_GCC_9",
os: ubuntu-latest,
artifact: "ubuntu_gcc9.7z",
build_type: "Release",
cc: "gcc",
cxx: "g++",
archiver: "7z a",
generators: "Ninja"
}
- {
name: "macOS Latest Clang",
os: macos-latest,
artifact: "macos_clang.7z",
build_type: "Release",
cc: "clang",
cxx: "clang++",
archiver: "7za a",
generators: "Ninja"
}
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
- name: Print env
run: |
echo github.event.action: ${{ github.event.action }}
echo github.event_name: ${{ github.event_name }}
- name: Install dependencies on windows
if: startsWith(matrix.config.os, 'windows')
run: |
choco install ninja cmake
ninja --version
cmake --version
# cmd "${{ matrix.config.environment_script }}"
- name: Install dependencies on ubuntu
if: startsWith(matrix.config.name, 'Ubuntu_Latest_GCC')
run: |
sudo apt-get update
sudo apt-get install ninja-build cmake
ninja --version
cmake --version
gcc --version
- name: Install dependencies on ubuntu9
if: startsWith(matrix.config.name, 'Ubuntu_GCC_9')
run: |
echo Update gcc-9 =======================================================================
echo gcc version before
gcc --version
sudo add-apt-repository ppa:ubuntu-toolchain-r/test
sudo apt-get update
sudo apt-get install ninja-build cmake gcc-9 g++-9
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-9 90 --slave /usr/bin/g++ g++ /usr/bin/g++-9 --slave /usr/bin/gcov gcov /usr/bin/gcov-9
echo gcc version after
gcc --version
echo Update ninja =======================================================================
echo ninja version before
ninja --version
# wget https://github.com/ninja-build/ninja/releases/download/v1.10.0/ninja-linux.zip
wget https://github.com/ninja-build/ninja/releases/latest/download/ninja-linux.zip
sudo unzip ninja-linux.zip -d /usr/local/bin/
sudo update-alternatives --install /usr/bin/ninja ninja /usr/local/bin/ninja 1 --force
echo ninja version after
ninja --version
echo Update cmake =======================================================================
echo cmake version before
cmake --version
# curl --silent "https://api.github.com/repos/Kitware/CMake/releases/latest" | sed -n 's/.*tag_name":\s"\(.*\)".*/\1/p' | head -2
# wget https://github.com/Kitware/CMake/releases/latest/download/cmake-3.16.5-Linux-x86_64.sh
cmake_version=$(curl --silent "https://api.github.com/repos/Kitware/CMake/releases/latest" | sed -n 's/.*tag_name":\s"\(.*\)".*/\1/p' | head -2 | cut -c 2-)
echo cmake download latest v$cmake_version version
wget https://github.com/Kitware/CMake/releases/download/v$cmake_version/cmake-$cmake_version-Linux-x86_64.sh
chmod +x cmake-$cmake_version-Linux-x86_64.sh
sudo mkdir /opt/cmake
sudo ./cmake-$cmake_version-Linux-x86_64.sh --prefix=/opt/cmake --skip-license
sudo update-alternatives --install /usr/bin/cmake cmake /opt/cmake/bin/cmake 1 --force
echo cmake version after
cmake --version
- name: Install dependencies on macos
if: startsWith(matrix.config.os, 'macos')
run: |
brew install p7zip cmake ninja
ninja --version
cmake --version
- name: Configure
shell: bash
run: |
mkdir build
mkdir instdir
cmake \
-S . \
-B . \
-DCMAKE_BUILD_TYPE=${{ matrix.config.build_type }} \
-G "${{ matrix.config.generators }}" \
-DCMAKE_INSTALL_PREFIX:PATH=instdir
- name: Build
shell: bash
run: cmake --build . --config ${{ matrix.config.build_type }}
- name: Install Strip
shell: bash
run: cmake --install . --strip
- name: Pack
shell: bash
working-directory: instdir
run: |
ls -laR
${{ matrix.config.archiver }} ../${{ matrix.config.artifact }} .
- name: Upload
uses: actions/upload-artifact@v1
with:
path: ./${{ matrix.config.artifact }}
name: ${{ matrix.config.artifact }}
- name: Upload release asset
if: github.event_name == 'release' && (github.event.action == 'published' || github.event.action == 'created')
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ./${{ matrix.config.artifact }}
asset_name: ${{ matrix.config.artifact }}.zip
asset_content_type: application/zip
name: C++ CI
on: [push]
jobs:
short_fuzzing:
runs-on: ubuntu-latest
steps:
- name: Build fuzzers
id: build
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master
with:
oss-fuzz-project-name: 'uwebsockets'
language: c++
- name: Run fuzzers
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master
with:
oss-fuzz-project-name: 'uwebsockets'
language: c++
fuzz-seconds: 600
- name: Upload crash
uses: actions/upload-artifact@v4
if: failure() && steps.build.outcome == 'success'
with:
name: artifacts
path: ./out/artifacts
build_windows:
runs-on: windows-latest
steps:
- name: Clone source
run: git clone --recursive https://github.com/uNetworking/uWebSockets.git
- name: Install libuv
run: |
vcpkg install libuv:x64-windows
cp C:\vcpkg\installed\x64-windows\bin\uv.dll uWebSockets\uv.dll
- uses: ilammy/msvc-dev-cmd@v1
- name: Build examples
run: |
cd uWebSockets
$Env:WITH_ZLIB='0'; $ENV:WITH_LTO='0'; $Env:CC='clang';
$ENV:CFLAGS='-I C:\vcpkg\installed\x64-windows\include';
$ENV:LDFLAGS='-L C:\vcpkg\installed\x64-windows\lib';
$ENV:CXX='clang++'; $ENV:EXEC_SUFFIX='.exe'; $ENV:WITH_LIBUV='1'; nmake
ls
- name: Run smoke test
run: |
cd uWebSockets
iwr https://deno.land/x/install/install.ps1 -useb | iex
Start-Process -NoNewWindow .\Crc32
sleep 1
deno run --allow-net tests\smoke.mjs
Stop-Process -Name Crc32
build_linux:
runs-on: ubuntu-latest
steps:
- name: Clone source
run: git clone --recursive https://github.com/uNetworking/uWebSockets.git
- name: Build source
run: cmake -S uWebSockets/libdeflate -B uWebSockets/libdeflate && make -C uWebSockets/libdeflate && WITH_ASAN=1 WITH_LIBDEFLATE=1 make -C uWebSockets
- name: List binaries
run: ls uWebSockets
- name: Install Deno
run: curl -fsSL https://deno.land/x/install/install.sh | sh
- name: Run smoke test
run: make -C uWebSockets/tests smoke
- name: Run compliance test
run: make -C uWebSockets/tests compliance
- name: Run unit tests
run: make -C uWebSockets/tests
- name: Autobahn|Testsuite
run: ~/.deno/bin/deno run -A --unstable uWebSockets/autobahn/server-test.js
build_osx:
runs-on: macos-latest
steps:
- name: Clone source
run: git clone --recursive https://github.com/uNetworking/uWebSockets.git
- name: Build source
run: make -C uWebSockets
- name: List binaries
run: ls uWebSockets
- name: Install Deno
run: curl -fsSL https://deno.land/x/install/install.sh | sh
- name: Run smoke test
run: make -C uWebSockets/tests smoke
- name: Run unit tests
run: make -C uWebSockets/tests

GitLab CI/CD Best Practices: From workflow:rules and Caching to OIDC, BuildKit, Review Environments, and Secure Runners

English-language reference notes based on the Habr article "Best Practices по GitLab CI/CD" (habr.com/ru/articles/1052024/), by user casssuzy. This is a paraphrased summary, not a literal translation — code samples are reproduced as-is since they are functional reference snippets, but the surrounding explanations are restated in original wording.

Companion piece: Best Practices for Dockerfiles (habr.com/ru/articles/1041784/).

Why bother thinking about GitLab CI/CD at all

Most pipelines start as a trivial stages: list with build/test/deploy. That's fine at first, but as a project grows the pipeline turns into a real engineering system: conditional execution, multiple pipeline types, caches, artifacts, secrets, review environments, child pipelines, container builds, deployment locks, approvals, security checks, release jobs, and shared templates across dozens of repos.

A poorly designed pipeline rarely breaks outright — it degrades gradually: first it gets slow, then confusing, then expensive, then dangerous. A 40-minute wait turns into duplicate pipelines for branches and MRs, then a job accidentally picks up a production secret, then two deploys race against the same environment, then a shared template silently changed on main and broke every consuming project.

A well-built pipeline solves several problems at once:

  1. Reproducibility — the same commit should go through a predictable pipeline; behavior that depends on stale cache state, a moving template, or an implicit variable is a risk.
  2. Fast feedback — developers should learn quickly whether they broke code, style, tests, the build, or the deploy; the later an obvious error surfaces, the more expensive it is.
  3. Security — the pipeline touches source code, tokens, artifacts, the registry, cloud accounts, and environments, so it has to be protected as part of the software supply chain, not treated as "just automation."
  4. Controlled delivery — deploys need to be traceable, serialized, permission-scoped, and visible in the UI, especially for staging, production, and Kubernetes.
  5. Maintainability.gitlab-ci.yml has to be readable not just by its author but by developers, DevOps, SRE, security people, and newcomers.

The core idea: CI/CD isn't a dumping ground for bash commands — it's a layer of the project's engineering architecture.


1. Pipeline architecture and basic YAML hygiene

Keep the root .gitlab-ci.yml short and declarative

The root file is the entry point — from it you should be able to quickly see what stages exist, what pipeline types are created, where shared templates live, which jobs belong to lint/test/build/deploy, which job dependencies matter, and which rules apply to branches, MRs, tags, and schedules.

Avoid a giant root file full of bash and near-duplicate jobs. Prefer composing it from includes:

include:
  - local: .gitlab/ci/lint.yml
  - local: .gitlab/ci/test.yml
  - local: .gitlab/ci/build.yml
  - local: .gitlab/ci/deploy.yml

stages:
  - lint
  - test
  - build
  - deploy

The root file should read like a map, not a junk drawer — details belong in local includes, components, or templates, while top-level logic stays readable.

Use default for genuinely shared settings — don't dump everything into it

default is meant for things that really are common across most jobs: base image, retry policy, cache, tags, before_script, timeout, etc.

default:
  image: node:22-bookworm-slim
  interruptible: true
  before_script:
    - node --version
    - npm --version

This beats copy-pasting the same image and before_script into every job. But the opposite extreme — stuffing everything into default — increases the chance that some job inherits things it shouldn't. If a job shouldn't inherit default, say so explicitly:

release:
  inherit:
    default: false
  image: alpine:3.20
  script:
    - ./release.sh

Same idea for variables:

sensitive-check:
  inherit:
    variables: false
  script:
    - ./run-isolated-check.sh

Rather than fighting inheritance, make it explicit where a job deliberately departs from shared behavior.

Don't hardcode main, master, or project-specific conventions

Shared templates and components shouldn't bake in branch names, group names, environments, or registries unless necessary.

Bad:

rules:
  - if: $CI_COMMIT_BRANCH == "main"

Better:

rules:
  - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

$CI_DEFAULT_BRANCH keeps configuration portable, which matters a lot for shared templates and components used across projects with different default branches. If a template genuinely depends on a specific branching model, document that explicitly — otherwise it'll look generic but break in projects organized differently.

Write shell blocks so they can actually be reviewed

CI/CD often turns into a bash program hiding inside YAML, so the shell portions deserve the same care as regular code.

Bad:

script:
  - apk add curl jq bash && curl -sSL https://example.com/script.sh | bash && ./deploy.sh prod

Better:

script:
  - |
    set -euo pipefail

    apk add --no-cache curl jq bash

    curl -fsSLo /tmp/script.sh https://example.com/script.sh
    chmod +x /tmp/script.sh

    /tmp/script.sh
    ./deploy.sh prod

This is far easier to read, review, and debug — especially for deploys, secret handling, external APIs, or release automation.

Validate CI configuration before merge

Mistakes in .gitlab-ci.yml are nasty because they break the verification mechanism itself, not the application. CI Lint, expression checks, and review of CI file changes should be a standard part of the process.

Protect changes to CI configuration with CODEOWNERS and protected-branch policy — anyone who can edit .gitlab-ci.yml can effectively decide which commands run and which secrets a job can see.

.gitlab-ci.yml       @platform-team @security-team
.gitlab/ci/**        @platform-team @security-team

This can feel like overkill for a small project, but it's normal hygiene for a production system.


2. rules, workflow:rules, and controlling pipeline creation

Control pipeline creation with workflow:rules

Two different things are at play:

  • workflow:rules decides whether a pipeline gets created at all.
  • job-level rules decide whether a particular job is included once a pipeline already exists.

If you only manage job-level rules, pipelines can still be created in situations you don't want, leading to duplicate branch/MR pipelines, wasted runner minutes, and a confusing check history.

A solid baseline pattern for branch + MR workflows:

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"
      when: never
    - if: $CI_COMMIT_BRANCH

Logic: run on MR pipelines; skip the push-triggered branch pipeline if an MR is already open for that branch; otherwise run the branch pipeline. The && $CI_PIPELINE_SOURCE == "push" condition matters because triggered, API, scheduled, and downstream pipelines can also carry $CI_COMMIT_BRANCH, and without checking the source you could accidentally block them too. This way GitLab avoids running both a branch pipeline and an MR pipeline on the same commit, without breaking other pipeline types.

Use CI_PIPELINE_SOURCE as the primary logic switch

CI_PIPELINE_SOURCE tells you where a pipeline came from: push, MR, schedule, API, trigger, parent pipeline, and so on.

In this article, web pipelines for branches and tags are covered generically via $CI_COMMIT_BRANCH/$CI_COMMIT_TAG conditions rather than a separate rule. external and external_pull_request_event sources are deliberately left out — if you integrate with external pull requests, add explicit rules for those via CI_PIPELINE_SOURCE. Rarer sources like chat, webide, and security_orchestration_policy are also out of scope here; they warrant their own dedicated rules.

nightly-tests:
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  script:
    - ./run-nightly-tests.sh

mr-checks:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  script:
    - ./run-mr-checks.sh

This makes a pipeline predictable — a job runs because the source explicitly matches, not because variables happened to line up.

Don't end rules with a wide-open when: always and no protective workflow

A common cause of duplicate pipelines is job-level rules ending in an overly broad fallback.

Bad:

test:
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - when: always
  script:
    - npm test

Without restricting pipeline creation at the workflow level, such a job can end up in multiple pipeline types — a push to a branch with an open MR can spawn both a branch pipeline and an MR pipeline. Restrict pipeline creation first via workflow:rules, then configure individual jobs.

Don't mix rules and only/except

only/except is legacy syntax. It still shows up in older projects, but new configurations should use rules and workflow:rules.

The problem isn't just that rules is more flexible — it's that mixing the two models leads to inconsistent default behavior, making it hard to tell why one job showed up in a pipeline and another didn't. A subtle trap: jobs without rules behave by default like except: merge_requests. So if some jobs use MR-oriented rules while others have no rules at all, a single push to a branch with an open MR can spawn two pipelines — a branch pipeline for the rule-less jobs and an MR pipeline for the others.

Target state:

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"
      when: never
    - if: $CI_COMMIT_BRANCH

lint:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - npm run lint

Large legacy projects can migrate gradually, but the hybrid state shouldn't become permanent.

Use rules:changes, compare_to, and exists

Not every job needs to run on every change — a docs-only change shouldn't usually trigger backend tests, and a frontend change shouldn't usually trigger a Go build.

backend-tests:
  rules:
    - changes:
        - backend/**/*
        - go.mod
        - go.sum
  script:
    - go test ./...

frontend-tests:
  rules:
    - changes:
        - frontend/**/*
        - package-lock.json
  script:
    - npm test

This matters most for monorepos — rules:changes avoids running half the pipeline for no reason. Worth knowing: GitLab limits the number of rules:changes checks and caps the number of paths/patterns per rules:changes block, which can matter on very large change sets — keep this logic from sprawling into dozens of disconnected conditions.

For comparing against a specific base, use compare_to, but be aware that in merged-results pipelines the comparison base may be a temporary merge commit, so rules can match more broadly than in a regular branch pipeline.

exists is handy for generic templates:

node-tests:
  rules:
    - exists:
        - package.json
  script:
    - npm test

The same template can then be included across multiple projects, and the job only appears where it's relevant.

Handle draft MRs separately

For large projects, it's worth skipping heavy checks on draft MRs.

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_DRAFT == "true"
      when: never
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"
      when: never
    - if: $CI_COMMIT_BRANCH

This saves runner minutes and reduces noise, as long as the team agrees that "draft" really does mean "don't run the full CI yet."

Version note: $CI_MERGE_REQUEST_DRAFT was introduced in GitLab 17.10. On older self-managed GitLab, fall back to matching $CI_MERGE_REQUEST_TITLE with a regex.

For manual runs, prefer typed pipeline inputs over free-form variables

For manual pipelines, reusable templates, and components, CI/CD inputs give a stricter contract than arbitrary pipeline variables: a value type, a default, an allowed-values list, regex validation, a description, and validation at pipeline-creation time. GitLab is gradually steering manual runs toward pipeline inputs rather than free-form pipeline variables — for a deploy, rollback, or maintenance trigger contract, inputs is usually safer and more predictable.

spec:
  inputs:
    target_environment:
      default: staging
      options:
        - staging
        - production
      description: "Where to deploy"
    run_migrations:
      type: boolean
      default: false
      description: "Whether to run migrations"
---
deploy:
  script:
    - ./deploy.sh "$[[ inputs.target_environment ]]"
    - |
      if [ "$[[ inputs.run_migrations ]]" = "true" ]; then
        ./migrate.sh "$[[ inputs.target_environment ]]"
      fi

This makes it much harder to launch a pipeline with bad parameters — useful for manual deploys, releases, rollbacks, and maintenance jobs.

Version note: CI/CD inputs became generally available in GitLab 17.0; features like spec:inputs:rules and array-type inputs arrived later, so check version support before relying on newer input features on self-managed GitLab.


3. DAG, needs, parallelism, matrices, and fast checks

Don't build the whole pipeline on stages alone

The classic stage model is simple but has a real limitation: jobs in the next stage wait for the entire previous stage to finish, even if a long-running test job has nothing to do with a docs build.

needs lets you describe real dependencies between jobs instead of relying on coarse stage ordering:

lint:
  stage: lint
  needs: []
  script:
    - npm run lint

unit-tests:
  stage: test
  needs: []
  script:
    - npm test

build:
  stage: build
  needs:
    - unit-tests
  script:
    - npm run build

needs: [] tells GitLab the job doesn't have to wait for previous stages and can start immediately — useful for fast checks that should fail early.

Put fast-failing checks as early as possible

Syntax, formatting, schema, commit-message, and basic smoke checks should fail as early as possible. It's wasteful for a pipeline to spend 30 minutes building an image and running integration tests only to then fail on prettier or a YAML lint.

stages:
  - validate
  - test
  - build
  - deploy

lint-yaml:
  stage: validate
  needs: []
  script:
    - yamllint .

lint-code:
  stage: validate
  needs: []
  script:
    - npm run lint

Failing early isn't just about speed — it's about feedback quality, since developers learn faster exactly what they broke.

Analyze the critical path, not "average job time"

If a pipeline takes 40 minutes, you don't necessarily need to optimize every job — find the critical path, the dependency chain that determines the minimum time to a result. A 15-minute job that blocks nothing important matters less than a 5-minute job sitting early in the chain that delays the whole deploy.

Look at the pipeline graph, the needs graph, job/stage durations, queue wait time, flaky jobs, image pull time, dependency-install time, and artifact upload/download time/storage. Optimizing without measurement tends to be guesswork.

When using needs, plan artifacts explicitly

With a pure stage-based pipeline, GitLab can automatically pass artifacts from the previous stage. With a DAG built on needs, you need to be explicit:

build:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/

deploy:
  stage: deploy
  needs:
    - job: build
      artifacts: true
  script:
    - ./deploy.sh dist/

If artifacts aren't needed, don't download them:

lint:
  stage: test
  dependencies: []
  script:
    - npm run lint

Unnecessary artifacts mean network noise, storage cost, and per-job delay.

Use needs:optional when the depended-on job isn't always created

If a job depends on another job that sometimes doesn't appear due to rules, the pipeline may fail to start.

build-docs:
  rules:
    - changes:
        - docs/**/*
  script:
    - ./build-docs.sh

publish-docs:
  needs:
    - build-docs
  script:
    - ./publish-docs.sh

If build-docs doesn't make it into the pipeline, the dependency becomes a problem. Use an optional dependency:

publish-docs:
  needs:
    - job: build-docs
      optional: true
  script:
    - ./publish-docs.sh

This matters especially for large configurations built around rules:changes.

Only parallelize what can genuinely run in parallel

parallel and parallel:matrix speed up tests, builds, and checks across runtime versions.

test:
  stage: test
  parallel:
    matrix:
      - NODE_VERSION: ["20", "22"]
        OS: ["debian", "alpine"]
  image: node:${NODE_VERSION}
  script:
    - npm test

But parallelism isn't free — with only two runners and 40 jobs created, most will just sit in a queue, adding complexity without cutting duration. Parallelism should match runner capacity.

Use needs:parallel:matrix for precise matrix dependencies

Sometimes a downstream job should wait for one specific matrix combination rather than the whole matrix — e.g., an image build for linux/amd64 should only wait on the linux/amd64 test job, not the entire test matrix. needs:parallel:matrix makes the DAG more precise and reduces unnecessary delays.

Use retry only for infrastructure failures, not logic errors

retry is appropriate when a runner, registry, network, or other unstable infrastructure layer fails.

Bad:

test:
  retry: 2
  script:
    - npm test

This can mask genuine test problems. Better to scope retries narrowly:

test:
  retry:
    max: 2
    when:
      - runner_system_failure
      - stuck_or_timeout_failure
  script:
    - npm test

Flaky tests should be fixed, not endlessly re-run.


4. Reuse: extends, templates, components, and inputs

Don't copy-paste identical YAML across jobs

If several jobs share the same structure, factor it out via a hidden job and extends:

.node-job:
  image: node:22
  before_script:
    - npm ci

lint:
  extends: .node-job
  script:
    - npm run lint

test:
  extends: .node-job
  script:
    - npm test

This beats copy-paste — updating the Node.js version or install method means changing one place. !reference is useful for more surgical reuse, but don't build unreadable YAML tricks on top of it: reuse should reduce complexity, not hide it.

Separate pipeline templates from job templates

A pipeline template can define global things — stages, workflow, default, overall architecture. A job template should be much more careful: it gets embedded into someone else's pipeline and shouldn't unexpectedly change global project behavior.

Bad job template:

stages:
  - test

default:
  image: node:22

security-scan:
  stage: test
  script:
    - ./scan.sh

This can conflict with stages/default already defined in the consuming project. Better:

.security-scan-base:
  image: alpine:3.20
  script:
    - ./scan.sh

Or, better still, package it as a CI/CD component with inputs.

Move to CI/CD components for wide reuse

The older approach — a shared repo of includable files:

include:
  - project: platform/ci-templates
    ref: main
    file: node.yml

works, but has problems: it's hard to discover what templates exist, hard to version, easy to accidentally include a moving main, documentation often lives elsewhere (or nowhere), and changing a shared template can silently break dozens of projects.

CI/CD components are a better fit for platform-level reuse — they come with a catalog, versioning, spec:inputs, documentation, and a more explicit contract.

spec:
  inputs:
    stage:
      default: test
      description: "Stage for unit tests"
    job-prefix:
      default: app
      description: "Job name prefix to avoid conflicts"
---
"$[[ inputs.job-prefix ]]-unit-tests":
  stage: $[[ inputs.stage ]]
  script:
    - npm test

Including it:

include:
  - component: $CI_SERVER_FQDN/platform/ci-components/unit-tests@1.2.0
    inputs:
      stage: verify
      job-prefix: backend

Give inputs sensible defaults

If a component or pipeline template uses inputs, set defaults wherever possible.

Bad:

spec:
  inputs:
    environment:
      description: "Target environment"
---
deploy:
  environment: $[[ inputs.environment ]]
  script:
    - ./deploy.sh

If such a pipeline is triggered automatically without the input supplied, it may fail. Better:

spec:
  inputs:
    environment:
      default: staging
      description: "Target environment"
---
deploy:
  environment: $[[ inputs.environment ]]
  script:
    - ./deploy.sh $[[ inputs.environment ]]

Inputs beat arbitrary pipeline variables because they describe a contract: what parameters exist, what's expected, and what the default is.

Pin components to a tag or SHA

Floating references are the enemy of reproducibility.

Bad:

include:
  - component: $CI_SERVER_FQDN/platform/ci-components/build@~latest

Better:

include:
  - component: $CI_SERVER_FQDN/platform/ci-components/build@1.4.2

Stricter still:

include:
  - component: $CI_SERVER_FQDN/platform/ci-components/build@e3262fdd0914fa823210cdb79a8c421e2cef79d8

~latest and partial semver like 1 or 1.2 are acceptable if you deliberately want automatic updates from the catalog, but it's still a moving target — ~latest can pull in breaking changes. For production-critical automation, prefer a pinned release tag or SHA.

Use include:local when related files must resolve from the same commit

If a component includes additional local files within its own project, prefer include:local so related config pieces all come from one Git SHA — this avoids the risk of mixing files from different versions.

Avoid name collisions in components

A pipeline and a component get merged into a final configuration. If a component job is called test and the project already has a test job, the outcome can be unexpected.

Bad:

test:
  script:
    - npm test

Better:

spec:
  inputs:
    job-name:
      default: component-test
---
"$[[ inputs.job-name ]]":
  script:
    - npm test

If a component might be included multiple times, dynamic names via inputs are almost mandatory.

Test components before publishing a version

A component is code, and it needs tests. Good pattern: in the component project's own CI, include the component by its current commit SHA:

include:
  - component: $CI_SERVER_FQDN/platform/ci-components/my-component@$CI_COMMIT_SHA
    inputs:
      job-name: test-current-component

That way you're testing exactly the commit you're about to release. If the component needs sample files, test fixtures, or a test Dockerfile, keep them alongside it, and test real side effects — not just "the job starts," but whether artifacts get created and reports get published correctly.

Publish a changelog and migration notes

If a shared component is used by dozens of projects, a breaking change in it isn't local — it's a potential incident. At minimum, maintain: a README with examples, documented inputs, a changelog, migration notes for breaking changes, a semver approach, component tests, and a clear support policy for older versions. A CI/CD platform deserves the same care as a library or internal SDK.

Treat any third-party component as part of the software supply chain

Including someone else's component means letting their YAML run commands in your pipeline. Before using one, check: what commands it runs, what secrets the job can see, what caches/artifacts it touches, what runner tags it needs, whether it needs privileged Docker, what tokens/permissions it requests, how it's versioned, and whether it has tests and docs. A convenient component that demands a privileged runner and broad tokens can be worse than a simple local job.

For include:project and similar mechanisms, use a full 40-character SHA or a release tag — treat main, ~latest, and other moving references as a supply-chain compromise, not a neutral default.

Use integrity for include:remote

include:remote is convenient when CI config lives at an external URL, but from a supply-chain standpoint it's the riskiest inclusion method — you're pulling in external YAML and letting it influence your pipeline.

Security nuance: include:remote only supports unauthenticated public HTTP/HTTPS GET. Nested includes execute without context, as a public user, so only public projects/templates are reachable, and variables aren't available inside nested includes — another reason to use integrity and minimize external include dependencies.

If include:remote is unavoidable, pin the content with integrity:

include:
  - remote: 'https://gitlab.com/example-project/-/raw/main/.gitlab-ci.yml'
    integrity: 'sha256-L3/GAoKaw0Arw6hDCKeKQlV1QPEgHYxGBHsH4zG1IY8='

If the external file's content changes and the hash no longer matches, the pipeline should fail rather than silently run new third-party YAML. This doesn't replace reviewing the external template, but it does pin down what you're actually including.

Version note: include:integrity arrived in GitLab 17.9 and won't work on older self-managed installs.

For include:remote, also consider include:cache, which caches the external content for a TTL and reduces HTTP requests — a tradeoff between speed and freshness, since a longer cache window raises the odds of temporarily using a stale external config.

Version note: include:cache arrived as an experimental feature in GitLab 18.9 and became generally available in GitLab 19.0.


5. Parent/child and multi-project pipelines

Start with a simple architecture

Don't start a new project with parent/child pipelines, multi-project pipelines, dynamic child pipelines, and a complex matrix of includes. Start simple:

stages:
  - lint
  - test
  - build
  - deploy

Then add workflow:rules, needs, caching, reports — and only move to decomposition once the configuration genuinely outgrows simplicity. Complex CI/CD architecture is justified when it solves a real problem: a monorepo, dozens of services, different release cadences, separate teams, separate trust boundaries.

Parent/child pipelines for decomposing a monorepo

Parent/child pipelines work well when components live in one repo but have separate CI files.

backend:
  trigger:
    include: .gitlab/ci/backend.yml
    strategy: mirror
  rules:
    - changes:
        - backend/**/*

frontend:
  trigger:
    include: .gitlab/ci/frontend.yml
    strategy: mirror
  rules:
    - changes:
        - frontend/**/*

This way the backend pipeline only runs on backend changes, and the frontend pipeline only on frontend changes.

strategy: mirror makes the trigger job's status mirror the downstream pipeline's actual status. Older configs often use strategy: depend, but mirror is the better choice for new pipelines since it tracks the real downstream state more closely.

Version note: strategy: mirror arrived in GitLab 18.2 — older self-managed installs need strategy: depend or an upgrade.

If a child pipeline produces JUnit, code-quality, Terraform, metrics, or security reports, verify they actually surface in the MR widget — the trigger job needs to wait on the child pipeline via strategy: mirror/depend, or the parent pipeline can finish first while the reports stay hidden in the child.

Version notes: report visibility from child pipelines in MR widgets arrived in GitLab 18.6; security reports from child pipelines in GitLab 18.9. A specific nuance for coverage: coverage_report from a child pipeline annotates the MR diff but doesn't become a regular report at the parent-pipeline level — worth distinguishing "MR widget/diff" from "parent-level report."

This beats one enormous .gitlab-ci.yml with everything jumbled together.

Mind child-pipeline depth limits

Don't treat child pipelines as an infinite tree. If you're several levels deep and it's getting hard to trace what triggers what, you may be solving the wrong problem. If the boundary has become cross-project or organizational, a multi-project pipeline is usually the more logical move.

Multi-project pipelines for multi-repo systems

A cross-project pipeline is needed when an upstream project must trigger a pipeline in another project — e.g., a library triggers consumer checks, an infra repo triggers an application repo's deploy, or release orchestration ties several services together.

trigger-service-b:
  trigger:
    project: platform/service-b
    branch: $CI_DEFAULT_BRANCH
    strategy: mirror

strategy: mirror matters here for the same reason: the upstream trigger job should reflect the actual downstream result, not just the fact that a downstream pipeline was created.

If a downstream pipeline needs to pull artifacts specifically from an MR pipeline, don't pass a plain branch name into needs:project — pass CI_MERGE_REQUEST_REF_PATH, or you risk pulling artifacts from the latest branch pipeline instead of the intended MR.

Keep the trust boundary in mind: the downstream pipeline runs in another project with its own settings, permissions, and rules. Understand who can trigger the upstream pipeline, what permissions the downstream run gets, what tokens/artifacts are passed, whether the downstream code can be trusted, and how failures get traced across projects. A cross-project pipeline is an access-model decision, not just YAML convenience.


6. Performance, caching, artifacts, and cost

Start optimization with measurement

"CI is slow" doesn't explain anything by itself — figure out exactly where the time goes: checkout, image pull, dependency install, tests, image build, artifact upload/download, runner queueing, a slow registry, network latency, or flaky retries. Look at job/stage durations, pipeline analytics, queue wait time, runner metrics, storage usage, and the critical path. Without measurement it's easy to optimize the wrong thing.

Use auto-cancel and interruptible

On an active branch, older pipelines often lose relevance after a new commit — but if not cancelled, they keep occupying runners.

For safely abortable jobs:

lint:
  interruptible: true
  script:
    - npm run lint

For deploys and irreversible operations, usually the opposite:

deploy-production:
  interruptible: false
  script:
    - ./deploy.sh production

Auto-cancel can be managed at the workflow level:

workflow:
  auto_cancel:
    on_new_commit: interruptible
    on_job_failure: all
  rules:
    - if: $CI_COMMIT_REF_PROTECTED == "true"
      auto_cancel:
        on_new_commit: none
        on_job_failure: none
    - when: always

Protected branches often need a more conservative policy — a release pipeline can matter for the audit trail even if a new commit arrives.

Tune Git checkout strategy to the task

Large repos can lose minutes just fetching sources. In most cases, fetch beats a full clone, and shallow clones help too:

variables:
  GIT_STRATEGY: fetch
  GIT_DEPTH: "20"

Nuances: too small a GIT_DEPTH can break changelog generation, semantic versioning, and Git-dependent tooling; GIT_STRATEGY: fetch is only safe on a shared environment if you trust all its users; for cleanup/stop jobs after a branch is deleted, GIT_STRATEGY: none or empty works if sources aren't needed:

stop-review:
  image: registry.example.com/platform/helm-kubectl:1.30.2
  variables:
    GIT_STRATEGY: none
  script:
    - helm uninstall "app-$CI_COMMIT_REF_SLUG" --namespace review || true

If cleanup logic lives in a repo script like ./destroy-review.sh, don't set GIT_STRATEGY: none — without checkout, that file simply won't be in the working directory.

Separate cache from artifacts

They solve different problems. Cache is for dependencies and reusable data (npm cache, bundler cache, Maven/Gradle repos). Artifacts are for a specific job's output (build result, JUnit report, coverage report, binary, Terraform plan, package).

Don't use cache to pass build output between jobs — cache can be overwritten, go stale, or come from a different branch.

build:
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 7 days

deploy:
  needs:
    - job: build
      artifacts: true
  script:
    - ./deploy.sh dist/

Key your cache off the lockfile

cache:
  key:
    files:
      - package-lock.json
  paths:
    - .npm/

If the lockfile hasn't changed, dependencies haven't either, so the cache can be reused. For branch-specific data, a branch-based key works:

cache:
  key: cache-$CI_COMMIT_REF_SLUG
  paths:
    - .cache/

The key thing is not to stack different kinds of data under the same key.

Use fallback cache keys

A new branch's first pipeline is often slow because there's no cache for it yet. Fallback keys let it use the default branch's cache:

cache:
  - key: cache-$CI_COMMIT_REF_SLUG
    fallback_keys:
      - cache-$CI_DEFAULT_BRANCH
      - cache-default
    paths:
      - vendor/ruby

The job checks the current branch's cache first, then the default branch, then a generic fallback — a good balance between isolation and a fast warm start. A global fallback cache needs cleanup and oversight, though, or it'll turn into a dumping ground.

Don't reuse one cache key for different paths

Bad — two jobs writing different content under the same key:

cache:
  key: deps
  paths:
    - node_modules/
cache:
  key: deps
  paths:
    - vendor/bundle/

This produces odd misses, overwrites, and flaky behavior. Separate them:

cache:
  key: npm-$CI_COMMIT_REF_SLUG
  paths:
    - .npm/
cache:
  key: ruby-$CI_COMMIT_REF_SLUG
  paths:
    - vendor/bundle/

Multiple runners need a distributed cache

If job A's cache lives on one runner host and job B lands on another, the local cache may be useless. Reasonable options: a dedicated runner for related jobs, multiple runners backed by object-storage-based distributed cache, a shared network cache for identical runners, or autoscaling runners with a correctly configured cache backend. Object storage needs a lifecycle policy, or cache storage grows unchecked.

Separate caches for protected and unprotected refs

Cache can itself be a supply-chain risk: if an untrusted branch can prepare a cache that a protected branch later consumes, that's dangerous. Separate caches reduce the risk, even at the cost of a lower hit rate — for the path to production, security wins.

Manage artifact lifetime

Artifacts shouldn't live forever "just in case":

build:
  artifacts:
    paths:
      - dist/
    expire_in: 7 days

Test reports often need when: always so the report is available even when the job fails:

test:
  script:
    - npm test -- --reporter=junit
  artifacts:
    when: always
    reports:
      junit: junit.xml
    paths:
      - junit.xml
    expire_in: 14 days

Too short a retention breaks debugging and the MR UI; too long bloats storage. Aim for balance.

Use artifacts:expose_as for reviewer-facing files

If a job produces an HTML report, Terraform plan, preview summary, or similar file a reviewer should open, don't make them dig through the artifact archive:

terraform-plan:
  script:
    - terraform plan -out=tfplan
    - terraform show -no-color tfplan > plan.txt
  artifacts:
    expose_as: "Terraform plan"
    paths:
      - plan.txt

That makes the result part of the review cycle instead of a hidden file in job output.


7. Docker image builds, BuildKit, Dependency Proxy, and registry caching

Don't default to an insecure privileged build process

Classic Docker-in-Docker is convenient but usually needs a privileged container and a Docker daemon — risky, especially on shared or reusable runners. For security-sensitive projects, look at rootless BuildKit, Buildah, Podman, or another lower-privilege model. This doesn't mean docker buildx is always bad — it means the runner model should match the actual risk.

Rootless BuildKit for a GitLab-native, more secure build

build-image:
  image:
    name: moby/buildkit:rootless
    entrypoint: [""]
  stage: build
  variables:
    BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
    CACHE_IMAGE: $CI_REGISTRY_IMAGE:cache
  before_script:
    - mkdir -p ~/.docker
    - |
      cat > ~/.docker/config.json <<EOF
      {
        "auths": {
          "$CI_REGISTRY": {
            "username": "$CI_REGISTRY_USER",
            "password": "$CI_REGISTRY_PASSWORD"
          }
        }
      }
      EOF
  script:
    - |
      buildctl-daemonless.sh build \
        --frontend dockerfile.v0 \
        --local context=. \
        --local dockerfile=. \
        --import-cache type=registry,ref=$CACHE_IMAGE \
        --export-cache type=registry,ref=$CACHE_IMAGE \
        --output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA,push=true

Pros: no privileged Docker daemon, registry-backed caching works, fits a disposable CI environment well, smaller blast radius than classic DinD on a shared runner. Cons: auth setup is less familiar, the team needs to understand BuildKit, and not every legacy Docker process maps over 1:1.

Inline cache vs. registry cache

Inline cache is simpler:

docker build \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  --cache-from "$CI_REGISTRY_IMAGE:latest" \
  -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .

For complex multi-stage builds, a registry cache often works better:

docker buildx build \
  --cache-from type=registry,ref=$CI_REGISTRY_IMAGE:buildcache \
  --cache-to type=registry,ref=$CI_REGISTRY_IMAGE:buildcache,mode=max \
  -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
  --push .

Inline cache is a good quick start; registry cache scales better for mature pipelines that need the build cache to live independently of the final image.

Don't pass secrets into a Docker build via ARG, ENV, or COPY

Bad:

ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN

The secret can leak into layer history, build logs, or cache. Use BuildKit secret mounts instead:

# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

In CI:

docker buildx build \
  --secret id=npmrc,src=.npmrc \
  -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .

A secret should only exist for the duration of the specific build step, never end up in the final image.

Use Dependency Proxy for base images

Base images are often pulled from Docker Hub or other external registries, which means rate limits, network latency, dependence on external availability, and extra pull time. GitLab's Dependency Proxy caches upstream images at the group level.

image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:22-bookworm-slim

For a Dockerfile:

ARG CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX
FROM ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:22-bookworm-slim

Especially valuable when many projects pull the same base images.

Tag images reproducibly

Don't deploy production from latest.

Bad:

docker build -t $CI_REGISTRY_IMAGE:latest .
docker push $CI_REGISTRY_IMAGE:latest

Better:

docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

Optionally push convenience tags too:

docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG

But deploys should reference an immutable tag or digest, not a floating latest.

Kaniko is no longer best practice for new pipelines

Kaniko was long popular for daemonless image builds, but it's now better viewed as something to migrate away from rather than a standard for new pipelines. Existing working Kaniko jobs don't need to be ripped out overnight, but a planned CI/CD platform refresh is a good moment to move to rootless BuildKit, Buildah, Podman, or another supported tool. The core principle: don't build a new platform on a tool that's no longer the current recommendation.

After building an image, add SBOM, signing, and policy checks

Building the image isn't the end of the supply chain. If the pipeline just builds and immediately ships to production, several questions go unanswered: what dependencies are in the image, what CVEs exist in the base image and packages, who built it and in which pipeline, can the artifact's provenance be proven, and is this image even allowed to deploy under internal policy.

A minimally mature process: build with an immutable tag/digest; generate an SBOM (e.g., CycloneDX); run container scanning; sign the image (Cosign, Notation, or another accepted tool); store provenance/attestation if required; gate production deploys on passing minimum checks.

sbom:
  stage: test
  script:
    - ./generate-sbom.sh > gl-sbom.cdx.json
  artifacts:
    reports:
      cyclonedx:
        - gl-sbom.cdx.json
    paths:
      - gl-sbom.cdx.json

Tier note: GitLab's artifacts:reports:cyclonedx integration is an Ultimate-tier feature. Without that tier, you can still store the SBOM as a regular artifact and use it in external policy checks, just without the native GitLab UI integration.

The exact toolset depends on the company, but the principle holds: production deploys should rely on more than just "the build succeeded" — they should rely on verifiable information about the image.

Don't forget a cleanup policy for the registry and build cache

Dependency Proxy, registry cache, and image tags speed up CI/CD, but without a cleanup policy they quickly become a storage problem. Worth tracking: how long temporary/review image tags live, how often the build cache is cleaned, how long a cache image like $CI_REGISTRY_IMAGE:buildcache is retained, whether old branch tags get removed after branches close, whether there's a separate policy for release tags and production images, and whether the registry is growing faster than the team notices. A good model: production/release artifacts live long and are managed deliberately, while review/temp/build-cache artifacts have a clear TTL and automatic cleanup.


8. Environments, review environments, and controlled deploys

Treat environments as a first-class object

A deploy job without environment is just a logged command. A deploy job with environment becomes part of GitLab's delivery model.

deploy-staging:
  stage: deploy
  script:
    - ./deploy.sh staging
  environment:
    name: staging
    url: https://staging.example.com
    deployment_tier: staging

This gives GitLab a real picture of what was deployed where, providing deploy history, environment links, an environments dashboard, protected environments, approvals, cleanup, and delivery visualization.

Set deployment_tier explicitly

Don't rely only on the environment's name — state the tier explicitly:

environment:
  name: production
  deployment_tier: production

Supported values: production, staging, testing, development, other. Useful for analytics and governance, especially in large orgs where environment naming varies.

For dynamic environments, return the actual URL via dotenv

Sometimes an environment's URL is only known after deploy — e.g., a PaaS or Kubernetes ingress generates it dynamically. The job can write the URL to a dotenv report:

deploy-review:
  script:
    - ./deploy-review.sh
    - echo "DYNAMIC_ENVIRONMENT_URL=https://$CI_COMMIT_REF_SLUG.review.example.com" >> deploy.env
  artifacts:
    reports:
      dotenv: deploy.env
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: $DYNAMIC_ENVIRONMENT_URL

This makes the review environment clickable in the UI.

Review environments need on_stop and auto_stop_in

A review environment with no cleanup path becomes future resource sprawl.

deploy-review:
  stage: deploy
  script:
    - ./deploy-review.sh
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: https://$CI_ENVIRONMENT_SLUG.example.com
    on_stop: stop-review
    auto_stop_in: 1 week
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

stop-review:
  stage: deploy
  script:
    - ./destroy-review.sh
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop
  when: manual
  allow_failure: true
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

stop-review is left optional here via allow_failure: true, convenient for UI-driven cleanup without blocking the pipeline — if your policy requires mandatory cleanup before proceeding, use a blocking manual job instead. Deploy and stop jobs need matching rules, or GitLab won't be able to stop the environment properly.

Keep deploy and stop jobs in the same resource_group for clean UI stops

deploy-review:
  resource_group: review/$CI_COMMIT_REF_SLUG
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    on_stop: stop-review
  script:
    - ./deploy-review.sh

stop-review:
  resource_group: review/$CI_COMMIT_REF_SLUG
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop
  script:
    - ./destroy-review.sh

This protects against odd races between deploy and cleanup.

Configure route maps for review environments

For frontend, docs, and static sites, a review environment is more useful if reviewers can jump from a changed file straight to the matching page rather than just the app root.

Example .gitlab/route-map.yml:

- source: /docs\/(.*)\.md/
  public: '/docs/\1/'

Protect production and staging via protected environments

A protected branch protects code; a protected environment protects the deploy. Production usually needs limits on who can deploy, who can approve a deploy, which branches/tags can access production secrets, and which runners can execute the deploy job.

deploy-production:
  stage: deploy
  environment:
    name: production
    deployment_tier: production
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  script:
    - ./deploy.sh production

Environment-level permissions are then configured in the GitLab UI.

Serialize deploys via resource_group

Two simultaneous production deploys is a bad idea.

deploy-production:
  stage: deploy
  resource_group: production
  environment:
    name: production
  script:
    - ./deploy.sh production

resource_group guarantees jobs sharing a resource group won't run in parallel — important for Kubernetes deploys, Terraform apply, database migrations, release publishing, and any mutable environment.

Pick process_mode to match release policy

resource_group supports different processing modes: oldest_first (safer for sequential continuous delivery), newest_first (drops stale deploy jobs faster but requires idempotency), and newest_ready_first (a middle ground for ready jobs). If deploys aren't idempotent, the more aggressive modes can leave things in an unpredictable state.

For downstream deploy processes, keep the lock on the trigger job

If the actual deploy happens in a downstream pipeline, the lock needs to persist until the downstream process finishes. Otherwise the parent pipeline's trigger job finishes quickly, the resource_group frees up, and the next deploy starts while downstream is still running. Use trigger:strategy: mirror or an equivalent approach where the parent waits on the downstream pipeline.

Avoid deadlocks between parent and child pipelines

A dangerous pattern: parent and child pipelines compete for the same resource_group, especially under oldest_first — the parent can end up waiting on the child while the child waits on a resource the parent is holding. Rule of thumb: if you lock across parent/child pipelines, design it deliberately rather than copying resource_group onto multiple pipeline levels without understanding the queueing implications.

Guard against outdated deploy jobs

An old deploy shouldn't land in production after a newer one. Typical failure mode: pipeline A starts deploying, pipeline B finishes faster and deploys a newer commit, then pipeline A resumes and rolls production back to the older commit. Outdated-deployment protection reduces this risk — but if you rely on manually re-running old deploy jobs for rollbacks, you need a deliberate rollback policy on top of that.

Don't rely on environment-scoped variables in rules or include

Environment-scoped variables are great for splitting secrets per environment (production secrets only for production, review secrets only for review/*, etc.), but don't rely on them inside rules or include — at pipeline-validation time they may not yet be resolved. If you need environment-scoped variable access before a full deploy, use environment actions (prepare, verify, access) instead of fighting GitLab's evaluation order.

Add manual_confirmation for dangerous manual jobs

A manual job alone isn't always sufficient protection — someone can click the wrong button, especially with several similar deploy/stop jobs nearby.

deploy-production:
  stage: deploy
  script:
    - ./deploy.sh production
  environment:
    name: production
  when: manual
  manual_confirmation: "Deploy to production?"

A commonly confused nuance: when: manual outside rules defaults to an optional manual job (allow_failure: true), while when: manual inside rules defaults to blocking (allow_failure: false). If you want an "optional manual" job inside rules, set allow_failure: true explicitly.

Useful for production deploys, stopping production, deleting review environments, Terraform apply, database migrations, and release publishing. manual_confirmation doesn't replace protected environments and approvals — it's just extra protection against an accidental UI click.

Version notes: manual_confirmation arrived in GitLab 17.1; stop-job support for environments arrived in GitLab 18.3 — check support before relying on this in shared templates on older self-managed GitLab.

Think through deploy freezes and rollback policy

Production needs not just a deploy mechanism but rules for when deploys are forbidden and how to roll back. Deploy freezes matter for holidays, release windows, end of reporting periods, migrations, external dependencies, or business-agreed freeze periods. Rollback should be explicit too — options include re-running an old deploy job, a new pipeline against an old commit SHA, deploying a previous immutable image tag, a dedicated rollback job, or rolling back via a Git tag or release metadata. For sensitive projects, it's risky to allow unrestricted re-runs of old deploy jobs, since that can accidentally roll production back to an unsuitable state — agree in advance on what counts as a normal rollback process, who triggers it, and what approvals are needed.

Consider the GitLab Agent for Kubernetes for Kubernetes deploys

If GitLab CI/CD deploys to Kubernetes, you don't have to store a long-lived kubeconfig as a plain CI/CD secret — the GitLab Agent for Kubernetes is often the better model. The Agent gives the pipeline Kubernetes context, with access controlled via ci_access, projects, groups, RBAC, and impersonation. The job gets $KUBECONFIG and can select the right context and run kubectl.

deploy-kubernetes:
  image: registry.example.com/platform/kubectl:1.30.2
  script:
    - kubectl config use-context path/to/agent/project:agent-name
    - kubectl apply -f k8s/

This doesn't replace protected environments, approvals, dedicated production runners, or scoped RBAC, but it beats handing out one shared kubeconfig to every deploy job. Note the deliberately pinned image version — for kubectl, helm, kustomize, and similar tools, avoid latest, or the same deploy job can start behaving differently after an image update.


9. MRs, merged-results pipelines, and merge trains

Shift primary verification to MR pipelines for active development

A branch pipeline reflects the branch's state; an MR pipeline reflects the change in the context of the MR. For teams with an active review process, the MR pipeline usually matters more — it's the one tied to UI integration, widgets, approvals, and reviewer feedback.

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"
      when: never
    - if: $CI_COMMIT_BRANCH

This keeps branch pipelines for branches without an MR, while the MR pipeline becomes primary once an MR opens.

Merged-results pipelines test as if the MR were already merged

A regular MR pipeline tests the source branch, but the target branch can change in the meantime — the MR can be green while the default branch goes red right after merge. A merged-results pipeline creates a temporary merge commit between source and target and tests that instead.

Useful when there are many parallel MRs, the default branch changes often, integration conflicts surface late, or it's important to catch problems before merge. Nuance: rules:changes:compare_to can behave differently in a merged-results pipeline, since the comparison base is a temporary merge commit.

To distinguish a regular detached MR pipeline, a merged-results pipeline, and a merge-train pipeline, check CI_MERGE_REQUEST_EVENT_TYPE, which takes the values detached, merged_result, and merge_train — useful when heavy checks or report publishing should only run for a specific MR pipeline type.

Merge trains for high-merge-volume workflows

If the default branch merges frequently, even a merged-results pipeline may not be enough — while one MR is being checked, others may merge ahead of it. A merge train queues MRs and checks each one in the context of the changes already ahead of it in the queue, reducing the "every MR is green individually but main goes red after merge" scenario.

Enable merge trains alongside MR pipelines and merged-results pipelines

Merge trains shouldn't be flipped on as a standalone magic switch — they work properly in combination with MR pipelines and merged-results pipelines. Otherwise MRs can behave unpredictably: getting stuck, being checked in the wrong context, or needing manual intervention.

Use auto-merge with merge trains

In a high-merge-volume workflow, Set to auto-merge is convenient — the MR correctly joins the queue and GitLab merges it once checks pass. Without it, people end up manually watching for the moment a pipeline turns green, which brings the chaos right back.

Keep release jobs, tags, and versioning as a separate process

A release job shouldn't be an accidental extension of a regular deploy job — releasing has its own responsibility: creating a tag, release metadata, a changelog, release files, a package, an image tag, or another version-fixing point.

Common patterns: release created on a Git tag push; release created after merge to the default branch; a separate prepare job collects metadata while a release job publishes; the release job creates release files and artifact links; version bumping and changelog generation happen as a separate controlled step.

Example, triggered by a tag:

release-job:
  stage: release
  image: registry.gitlab.com/gitlab-org/cli:latest
  rules:
    - if: $CI_COMMIT_TAG
  script:
    - echo "Create release for $CI_COMMIT_TAG"
  release:
    tag_name: '$CI_COMMIT_TAG'
    description: '$CI_COMMIT_TAG'

Double-check the top-level workflow:rules: if it doesn't permit tag pipelines, a job-level if: $CI_COMMIT_TAG condition alone won't help, since the tag pipeline simply won't be created.

If the release job itself creates the tag, watch out for ending up with two pipelines: one on the default branch that creates the release and tag, and a second tag pipeline triggered by that new tag. Explicitly block the release job in the tag pipeline if that's the case:

release-job:
  rules:
    - if: $CI_COMMIT_TAG
      when: never
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - echo "Create release"

GitLab's official examples often use registry.gitlab.com/gitlab-org/cli:latest, but for production-critical release automation, pin a specific image version or use an internal pinned image with the release tooling. For production, a release should reference an immutable image tag or digest, not latest or a floating branch tag.


10. Security, secrets, OIDC, Vault, and CI_JOB_TOKEN

Treat the pipeline as part of the software supply chain

The pipeline can read source code, build artifacts, publish Docker images, obtain secrets, call cloud APIs, deploy to Kubernetes, push tags, create releases, and trigger downstream pipelines — it's not a helper script, it's part of the supply chain. Consequences: don't trust arbitrary includes/components without review, scope tokens tightly, secure runners, separate protected from unprotected processes, don't hand production secrets to ordinary test jobs, and never run untrusted forked code with access to the parent project's secrets.

Keep highly sensitive secrets in an external secrets manager

CI/CD variables are convenient, but for important secrets prefer an external secrets manager — Vault, a cloud secrets manager, or another provider. Baseline minimum for GitLab variables: masked, hidden, protected, scoped to specific environments, minimal scope, and rotated regularly. But the real target state for cloud and production secrets is short-lived credentials issued via OIDC or on-demand secret delivery to the job.

Understand the difference between regular variables and secrets:

Ordinary variables are often available to a job by default, while secrets: are explicitly requested by the job — an important distinction, since the more explicitly a job requests a secret, the easier it is to review that access.

Bad:

variables:
  AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY

Better still: don't keep a long-lived cloud key in GitLab at all — get temporary access via OIDC instead.

Use OIDC ID tokens instead of static cloud keys

A static cloud key in CI/CD has a long blast radius — it has to be stored, rotated, scoped, and hopefully never leaks. The OIDC approach is better: the job gets an ID token, and the cloud provider or Vault validates the claims and issues temporary credentials.

Example for Vault:

deploy:
  id_tokens:
    VAULT_ID_TOKEN:
      aud: https://vault.example.com
  script:
    - vault write auth/jwt/login role=gitlab-ci jwt="$VAULT_ID_TOKEN"
    - ./deploy.sh

In the trust policy, prefer stable claims like project_id and namespace_id over path-based values — a project or group can be renamed, but its ID stays stable. The same OIDC principle applies to cloud federation generally: AWS, GCP, Azure, Yandex Cloud, or another provider validating token claims.


(Note: the source article continues with further sections on runner hardening and isolation, reporting/observability, a complete example .gitlab-ci.yml, a rollout plan, and a conclusion. The fetched content above covers roughly the first two-thirds of the article. Let me know if you'd like me to fetch and summarize the remaining sections as well.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment