- https://github.com/andreasfertig/programming-with-cpp20
- https://github.com/scivision/Cpp23-examples
- https://github.com/uNetworking/uWebSockets
- https://gist.github.com/NickNaso/ff50ecd38becbadafb1c40a5f2a49c87
- https://github.com/hailiang194/NeoSekaiEngine
- https://www.kdab.com/github-actions-for-cpp-and-qt/
- https://github.com/MiKom/QtQuickApp
- https://github.com/AshleyRoll/arquebus - .clang-format , .clang-tidy , .cmake-format.py , .pre-commit-config.yaml
-
-
Save vladiant/d4e23b09ef8e4acd40c0a97d9a861f63 to your computer and use it in GitHub Desktop.
| 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/).
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:
- 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.
- 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.
- 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."
- Controlled delivery — deploys need to be traceable, serialized, permission-scoped, and visible in the UI, especially for staging, production, and Kubernetes.
- Maintainability —
.gitlab-ci.ymlhas 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.
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
- deployThe 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.
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 --versionThis 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.shSame idea for variables:
sensitive-check:
inherit:
variables: false
script:
- ./run-isolated-check.shRather than fighting inheritance, make it explicit where a job deliberately departs from shared behavior.
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.
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 prodBetter:
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 prodThis is far easier to read, review, and debug — especially for deploys, secret handling, external APIs, or release automation.
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.
Two different things are at play:
workflow:rulesdecides whether a pipeline gets created at all.- job-level
rulesdecide 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_BRANCHLogic: 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.
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.shThis makes a pipeline predictable — a job runs because the source explicitly matches, not because variables happened to line up.
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 testWithout 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.
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 lintLarge legacy projects can migrate gradually, but the hybrid state shouldn't become permanent.
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 testThis 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 testThe same template can then be included across multiple projects, and the job only appears where it's relevant.
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_BRANCHThis 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 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 ]]"
fiThis 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.
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 buildneeds: [] tells GitLab the job doesn't have to wait for previous stages and can start immediately — useful for fast checks that should fail early.
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 lintFailing early isn't just about speed — it's about feedback quality, since developers learn faster exactly what they broke.
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.
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 lintUnnecessary artifacts mean network noise, storage cost, and per-job delay.
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.shIf 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.shThis matters especially for large configurations built around rules:changes.
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 testBut 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.
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.
retry is appropriate when a runner, registry, network, or other unstable infrastructure layer fails.
Bad:
test:
retry: 2
script:
- npm testThis can mask genuine test problems. Better to scope retries narrowly:
test:
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
script:
- npm testFlaky tests should be fixed, not endlessly re-run.
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 testThis 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.
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.shThis can conflict with stages/default already defined in the consuming project. Better:
.security-scan-base:
image: alpine:3.20
script:
- ./scan.shOr, better still, package it as a CI/CD component with inputs.
The older approach — a shared repo of includable files:
include:
- project: platform/ci-templates
ref: main
file: node.ymlworks, 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 testIncluding it:
include:
- component: $CI_SERVER_FQDN/platform/ci-components/unit-tests@1.2.0
inputs:
stage: verify
job-prefix: backendIf a component or pipeline template uses inputs, set defaults wherever possible.
Bad:
spec:
inputs:
environment:
description: "Target environment"
---
deploy:
environment: $[[ inputs.environment ]]
script:
- ./deploy.shIf 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.
Floating references are the enemy of reproducibility.
Bad:
include:
- component: $CI_SERVER_FQDN/platform/ci-components/build@~latestBetter:
include:
- component: $CI_SERVER_FQDN/platform/ci-components/build@1.4.2Stricter 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.
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.
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 testBetter:
spec:
inputs:
job-name:
default: component-test
---
"$[[ inputs.job-name ]]":
script:
- npm testIf a component might be included multiple times, dynamic names via inputs are almost mandatory.
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-componentThat 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.
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.
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.
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.
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
- deployThen 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 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.
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.
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: mirrorstrategy: 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.
"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.
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 lintFor deploys and irreversible operations, usually the opposite:
deploy-production:
interruptible: false
script:
- ./deploy.sh productionAuto-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: alwaysProtected branches often need a more conservative policy — a release pipeline can matter for the audit trail even if a new commit arrives.
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 || trueIf 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.
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/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.
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/rubyThe 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.
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/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.
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.
Artifacts shouldn't live forever "just in case":
build:
artifacts:
paths:
- dist/
expire_in: 7 daysTest 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 daysToo short a retention breaks debugging and the MR UI; too long bloats storage. Aim for balance.
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.txtThat makes the result part of the review cycle instead of a hidden file in job output.
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.
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=truePros: 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 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.
Bad:
ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken=$NPM_TOKENThe 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 ciIn 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.
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-slimFor a Dockerfile:
ARG CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX
FROM ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:22-bookworm-slimEspecially valuable when many projects pull the same base images.
Don't deploy production from latest.
Bad:
docker build -t $CI_REGISTRY_IMAGE:latest .
docker push $CI_REGISTRY_IMAGE:latestBetter:
docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHAOptionally 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_SLUGBut deploys should reference an immutable tag or digest, not a floating latest.
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.
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.jsonTier 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.
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.
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: stagingThis 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.
Don't rely only on the environment's name — state the tier explicitly:
environment:
name: production
deployment_tier: productionSupported values: production, staging, testing, development, other. Useful for analytics and governance, especially in large orgs where environment naming varies.
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_URLThis makes the review environment clickable in the UI.
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.
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.shThis protects against odd races between deploy and cleanup.
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/'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 productionEnvironment-level permissions are then configured in the GitLab UI.
Two simultaneous production deploys is a bad idea.
deploy-production:
stage: deploy
resource_group: production
environment:
name: production
script:
- ./deploy.sh productionresource_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.
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.
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.
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.
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.
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.
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.
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.
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.
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_BRANCHThis keeps branch pipelines for branches without an MR, while the MR pipeline becomes primary once an MR opens.
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.
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.
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.
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.
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.
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.
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.
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_KEYBetter still: don't keep a long-lived cloud key in GitLab at all — get temporary access via OIDC instead.
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.shIn 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.)