C++ 프로젝트 CI 구성: GitHub Actions 멀티 OS·컴파일러 매트릭스, vcpkg 캐시, 테스트
들어가며: 푸시하면 빌드·테스트까지
40-1에서 vcpkg와 Conan으로 의존성을 관리했다면, 다음 단계는 그 환경을 푸시와 PR마다 자동으로 빌드하고 테스트하는 CI(지속적 통합)입니다. GitHub Actions는 워크플로를 YAML로 정의하고, push나 pull_request 같은 저장소 이벤트에 맞춰 Ubuntu, Windows, macOS 러너에서 job을 실행합니다. C++ 프로젝트에서는 CMake와 컴파일러 조합을 job마다 정의하고, 의존성 빌드 결과를 캐시해 시간을 줄이는 것이 핵심입니다.
이 글에서는 워크플로의 trigger·job·matrix·cache 구조를 짚고, 멀티 OS·멀티 컴파일러 매트릭스에 vcpkg·Conan을 연동하는 예제와 자주 만나는 에러를 다룹니다. 테스트 작성은 Google Test·ctest, 커버리지는 코드 커버리지 글과 함께 보면 좋고, 다른 언어의 CI 구성은 Node.js GitHub Actions·Go go test와 비교해 볼 수 있습니다.
로컬에서는 되는데 CI에서만 실패할 때
“로컬 Windows에서는 빌드되는데 GitHub Actions의 Ubuntu에서만 실패한다”, “vcpkg로 설치한 라이브러리를 CI에서는 찾지 못한다” 같은 문제는 대개 다음 중 하나에서 옵니다.
- 로컬에만 설치된 라이브러리나 툴체인에 의존합니다. 특히 vcpkg를 클래식 모드로 전역 설치해 쓰면 그 패키지는 CI 러너에 없습니다.
- Windows, Linux, macOS는 경로 규칙, 기본 컴파일러, 표준 라이브러리 구현이 다르므로, 한 플랫폼에서만 통과하는 코드(예: 헤더를 간접 include에 기대는 코드)가 생깁니다.
- 캐시 키가 의존성 변경을 반영하지 않아 오래된 캐시를 복원합니다.
- 의존성을 처음부터 빌드하느라 job 시간이 길어집니다.
flowchart LR
subgraph before["CI 없음"]
B1["개발자 A (Windows)"] --> B2[로컬만 빌드]
B3["개발자 B (macOS)"] --> B4[로컬만 빌드]
B5[병합] --> B6["다른 OS에서 빌드 실패"]
end
subgraph after["CI 있음"]
A1[push/PR] --> A2[GitHub Actions]
A2 --> A3[Ubuntu 빌드]
A2 --> A4[Windows 빌드]
A2 --> A5[macOS 빌드]
A3 --> A6["모든 OS 통과 후 병합"]
A4 --> A6
A5 --> A6
end
의존성을 매니페스트(vcpkg.json)나 conanfile로 저장소에 선언하고, 같은 워크플로로 모든 OS에서 빌드하면 이런 차이를 병합 전에 잡을 수 있습니다.
GitHub Actions 기본
.github/workflows/*.yml에 워크플로 파일을 두면 지정한 이벤트(push, pull_request 등)에 따라 실행됩니다. job은 하나의 러너에서 실행되는 단위로 runs-on으로 OS를 지정하고, job 안의 steps는 uses로 기존 액션을 쓰거나 run으로 셸 명령을 실행합니다. 어느 step이든 실패하면 job이 실패로 끝나고, 서로 다른 job은 서로 다른 머신에서 돌기 때문에 파일 시스템을 공유하지 않습니다.
sequenceDiagram participant Dev as 개발자 participant GH as GitHub participant Runner as Actions Runner Dev->>GH: push / pull_request GH->>Runner: 워크플로 트리거 Runner->>Runner: checkout, CMake 설정, 빌드, 테스트 Runner->>GH: 결과 보고 GH->>Dev: PR에 통과/실패 표시
최소 동작 예제
my-cpp-app/
├── .github/
│ └── workflows/
│ └── build.yml
├── CMakeLists.txt
└── src/
└── main.cpp
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(my-cpp-app LANGUAGES CXX)
add_executable(my-app src/main.cpp)
target_compile_features(my-app PRIVATE cxx_std_17)
enable_testing()
add_test(NAME my-app-test COMMAND my-app) # 종료 코드 0이면 통과
// src/main.cpp
#include <iostream>
int main() {
std::cout << "CI에서 빌드·테스트됩니다.\n";
return 0;
}
# .github/workflows/build.yml
name: Build and Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure
run: cmake -B build -DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build
- name: Test
run: ctest --test-dir build --output-on-failure
이 세 파일을 커밋해 main에 푸시하면 첫 워크플로가 돕니다. ctest --test-dir는 CMake 3.20 이상에서 쓸 수 있습니다.
멀티 OS·컴파일러 매트릭스
strategy.matrix에 값 목록을 두면 조합 수만큼 job이 병렬로 생깁니다. OS마다 다른 값은 matrix.include로 조합을 직접 나열하고, fail-fast: false로 두면 한 조합이 실패해도 나머지는 끝까지 실행되어 어느 플랫폼이 문제인지 한 번에 볼 수 있습니다.
# .github/workflows/matrix-build.yml
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
compiler: gcc
cc: gcc
cxx: g++
- os: ubuntu-latest
compiler: clang
cc: clang
cxx: clang++
- os: windows-latest
compiler: msvc
cc: ''
cxx: ''
- os: macos-latest
compiler: apple-clang
cc: clang
cxx: clang++
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Configure
shell: bash
run: |
if [ -n "${{ matrix.cc }}" ]; then
cmake -B build -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER=${{ matrix.cc }} \
-DCMAKE_CXX_COMPILER=${{ matrix.cxx }}
else
cmake -B build -DCMAKE_BUILD_TYPE=Release
fi
- name: Build
run: cmake --build build --config Release
- name: Test
run: ctest --test-dir build --output-on-failure -C Release
Windows에서 shell: bash는 Git Bash를 쓰며, cc가 빈 문자열이라 컴파일러를 지정하지 않는 분기를 탑니다. 그러면 CMake는 기본적으로 Visual Studio 생성기와 MSVC를 고릅니다. Visual Studio 생성기는 여러 구성(Debug/Release)을 한 빌드 트리에 담는 멀티 구성 생성기라서 CMAKE_BUILD_TYPE이 무시되고, 빌드와 테스트에 --config Release/-C Release를 줘야 합니다. 단일 구성 생성기(Makefile, Ninja)에서는 이 옵션이 무시되므로 모든 OS에 함께 써도 됩니다.
멀티 구성 생성기(Visual Studio, Ninja Multi-Config)는 산출물도 build/Release/ 같은 하위 디렉터리에 만듭니다. 아티팩트나 릴리스 경로를 Linux와 같은 규칙으로 잡았다가 Windows job만 “파일 없음”으로 실패하는 경우가 이 차이 때문입니다.
$GITHUB_ENV에 CC·CXX를 써 두는 방법도 있습니다. CMake는 첫 configure 때 이 환경 변수로 컴파일러를 고르므로 이후 단계에 그대로 반영됩니다. 다만 vcpkg를 함께 쓰면 vcpkg 패키지는 트리플렛 설정에 따라 빌드되고 CC·CXX나 -DCMAKE_CXX_COMPILER를 그대로 따르지 않습니다. 컴파일러를 바꿔 의존성까지 검증하려면 커스텀 트리플렛이나 체인로드 툴체인이 필요합니다. macos-14 이후 macOS 러너는 Apple Silicon이라 x86_64 바이너리가 필요하면 트리플렛과 CMAKE_OSX_ARCHITECTURES를 명시해야 합니다.
Ninja 생성기로 MSVC를 쓰고 싶다면 cl.exe가 PATH에 있어야 하므로, 개발자 명령 프롬프트 환경을 설정하는 ilammy/msvc-dev-cmd 같은 액션을 먼저 실행합니다. microsoft/setup-msbuild는 MSBuild만 PATH에 추가하며 cl.exe 환경을 만들어 주지는 않습니다.
- uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
캐시와 테스트
vcpkg 캐시
vcpkg는 패키지를 빌드하면 그 결과를 바이너리 캐시(zip 아카이브)로 저장하고, 같은 패키지·버전·트리플렛·컴파일러 조합을 다시 요청받으면 빌드 대신 이 캐시를 복원합니다. 이 디렉터리를 actions/cache로 보존하면 두 번째 실행부터 의존성 빌드를 건너뛸 수 있습니다. buildtrees나 packages 같은 중간 산출물을 캐시하는 것보다 크기가 작고 정확합니다.
env:
VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Create binary cache dir
shell: bash
run: mkdir -p "$VCPKG_DEFAULT_BINARY_CACHE"
- name: Cache vcpkg binaries
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.vcpkg-cache
key: vcpkg-${{ runner.os }}-${{ matrix.triplet }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }}
restore-keys: |
vcpkg-${{ runner.os }}-${{ matrix.triplet }}-
vcpkg에는 별도의 lock 파일이 없고, 매니페스트의 builtin-baseline(또는 vcpkg-configuration.json의 baseline)이 사용할 포트 버전을 고정합니다. 그래서 캐시 키에는 vcpkg.json과 vcpkg-configuration.json의 해시를 넣습니다. restore-keys는 의존성이 바뀌어 정확히 맞는 캐시가 없을 때 가장 최근의 비슷한 캐시를 복원하는데, vcpkg는 패키지마다 소스 버전·트리플렛·컴파일러·포트 파일을 해시한 ABI 키로 캐시를 찾으므로 바뀌지 않은 패키지만 골라 재사용합니다.
캐시 위치는 VCPKG_BINARY_SOURCES: 'clear;files,${{ github.workspace }}/.vcpkg-cache,readwrite'처럼 공급자를 명시하는 방식으로도 지정할 수 있습니다. 오래된 예제에 흔한 'clear;x-gha,readwrite'(GitHub Actions 캐시를 vcpkg가 직접 사용)는 GitHub가 캐시 서비스의 내부 API를 바꾸면서 동작하지 않게 되었고, vcpkg는 2025년에 이 공급자를 제거했습니다. 어느 날부터 CI가 모든 의존성을 매번 다시 빌드하며 크게 느려졌다면 이 설정부터 확인합니다. 대안은 위의 files 공급자 + actions/cache와, vcpkg 팀이 권장하는 GitHub Packages NuGet 피드(nuget,https://nuget.pkg.github.com/<OWNER>/index.json,readwrite)입니다. 앞쪽은 설정이 쉽지만 저장소당 캐시 용량을 매트릭스 조합들이 나눠 써서 오래된 캐시가 자주 밀려나고, NuGet 피드는 초기 설정(packages: write 권한 토큰으로 소스 등록, Linux·macOS에서는 mono 필요)이 번거로운 대신 용량과 저장소 간 공유 면에서 유리합니다.
바이너리 캐시 대신 설치 결과 디렉터리를 통째로 캐시하는 예제도 많은데, 경로를 틀리기 쉽습니다. 매니페스트 모드에서는 패키지가 vcpkg 루트의 installed/가 아니라 빌드 디렉터리의 vcpkg_installed/에 설치되므로, vcpkg/installed를 캐시하는 Classic 모드 예제를 그대로 쓰면 아무것도 저장되지 않습니다. 설치 디렉터리 캐시는 vcpkg 버전이나 트리플렛이 바뀌어도 키가 같으면 어긋난 결과를 조용히 복원할 수 있어서, 바이너리 캐시 쪽이 안전합니다.
vcpkg를 서브모듈로 두지 않는다면 lukka/run-vcpkg@v11에 vcpkgGitCommitId로 vcpkg 저장소 커밋을 고정하고, 이 값을 vcpkg.json의 builtin-baseline과 같은 SHA로 맞추면 포트 버전이 로컬과 CI에서 일치합니다. runVcpkgInstall: false로 두면 이 액션은 vcpkg를 부트스트랩하고 VCPKG_ROOT만 설정하며, 실제 설치는 configure 단계에서 툴체인이 합니다.
Conan 캐시
- name: Cache Conan
uses: actions/cache@v4
with:
path: ~/.conan2/p
key: conan-${{ runner.os }}-${{ hashFiles('conanfile.txt', 'conan.lock') }}
restore-keys: |
conan-${{ runner.os }}-
Conan 2는 패키지를 ~/.conan2/p에 저장합니다. conan lock create로 만든 conan.lock을 커밋해 두면 버전이 고정되고 캐시 키로도 쓸 수 있습니다. hashFiles는 패턴에 맞는 파일이 하나도 없으면 실패하지 않고 빈 문자열을 돌려주므로, 둘 다 없는 저장소에서는 키가 conan-Linux-로 고정되어 의존성을 바꿔도 같은 캐시가 계속 적중합니다. 반대로 매번 “Cache not found”로 끝난다면 키에 커밋 SHA처럼 실행마다 바뀌는 값이 들어 있지 않은지 확인합니다.
테스트와 실패 알림
ctest --output-on-failure는 실패한 테스트의 출력만 보여 줍니다. CTest 3.21 이상에서는 --output-junit result.xml로 JUnit 형식 결과를 남겨 테스트 리포트 액션에 넘길 수 있습니다. 실패 시 알림은 if: failure() step으로 붙이고, 저장소 설정의 브랜치 보호 규칙에서 “Require status checks to pass”를 켜면 CI가 통과하지 않은 PR은 병합할 수 없습니다.
- name: Notify on failure
if: failure()
run: echo "Build failed" # 실제로는 Slack webhook 호출 등
vcpkg·Conan 멀티 OS 워크플로 예제
vcpkg 매니페스트 모드 + 멀티 OS
vcpkg를 서브모듈로 두고 vcpkg.json으로 의존성을 선언한 경우입니다.
my-app/
├── .github/workflows/ci.yml
├── CMakeLists.txt
├── vcpkg.json
├── vcpkg-configuration.json # baseline (선택)
├── vcpkg/ # git submodule
└── src/main.cpp
# .github/workflows/ci.yml
name: CI (vcpkg)
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
triplet: x64-linux
- os: windows-latest
triplet: x64-windows
- os: macos-latest
triplet: arm64-osx # macos-latest는 Apple Silicon 러너
runs-on: ${{ matrix.os }}
env:
VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Create binary cache dir
shell: bash
run: mkdir -p "$VCPKG_DEFAULT_BINARY_CACHE"
- name: Cache vcpkg binaries
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.vcpkg-cache
key: vcpkg-${{ matrix.triplet }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }}
restore-keys: vcpkg-${{ matrix.triplet }}-
- name: Configure
shell: bash
run: |
cmake -B build -S . \
-DCMAKE_TOOLCHAIN_FILE="${{ github.workspace }}/vcpkg/scripts/buildsystems/vcpkg.cmake" \
-DVCPKG_TARGET_TRIPLET=${{ matrix.triplet }} \
-DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build --config Release
- name: Test
run: ctest --test-dir build --output-on-failure -C Release
툴체인 파일을 지정하면 CMake 설정 단계에서 vcpkg가 vcpkg.json의 의존성을 설치하고, 그 뒤 find_package가 설치된 패키지를 찾습니다.
CMakePresets.json을 쓴다면 OS별 configure preset의 toolchainFile을 $env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake로 두고, 워크플로에서는 cmake --preset, cmake --build --preset, ctest --preset만 호출하면 로컬과 CI가 같은 generator와 빌드 타입을 씁니다. 이때 test preset도 configure preset에 맞춰 나눠야 합니다. 매트릭스 전체에서 ci-test 같은 test preset 하나를 고정해 쓰면, 그 preset이 가리키는 빌드 디렉터리가 해당 job에는 없어서 테스트가 하나도 실행되지 않은 채 통과합니다. configure preset의 binaryDir를 ${sourceDir}/build/${presetName}로 통일해 두면 preset 이름만으로 아티팩트 경로를 만들 수 있습니다.
Conan 2 + 멀티 OS
# .github/workflows/ci.yml
name: CI (Conan)
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Conan
run: pip install conan
- name: Detect profile
run: conan profile detect --force
- name: Cache Conan
uses: actions/cache@v4
with:
path: ~/.conan2/p
key: conan-${{ runner.os }}-${{ hashFiles('conanfile.txt', 'conan.lock') }}
restore-keys: conan-${{ runner.os }}-
- name: Configure
shell: bash
run: |
conan install . --output-folder=build --build=missing -s build_type=Release
cmake -B build -S . \
-DCMAKE_TOOLCHAIN_FILE="${{ github.workspace }}/build/conan_toolchain.cmake" \
-DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build --config Release
- name: Test
run: ctest --test-dir build --output-on-failure -C Release
새 러너에는 Conan 기본 프로필이 없으므로 conan profile detect로 컴파일러와 OS를 감지해 만들어야 합니다. 이 step을 빼면 conan install이 “default profile doesn’t exist” 에러로 실패합니다.
자주 만나는 에러
”Could not find a package configuration file provided by …”
CMake Error at CMakeLists.txt:5 (find_package):
Could not find a package configuration file provided by "spdlog"
vcpkg나 Conan의 툴체인 파일을 CMake에 넘기지 않았거나, 로컬에서 클래식 모드로 설치한 패키지에 기대고 있는 경우입니다. vcpkg는 -DCMAKE_TOOLCHAIN_FILE=.../vcpkg/scripts/buildsystems/vcpkg.cmake를, Conan은 conan install 후 생성된 conan_toolchain.cmake를 지정합니다. 툴체인 파일은 처음 설정할 때만 적용되므로, 이미 만들어진 빌드 디렉터리에 나중에 지정하면 무시됩니다.
vcpkg 서브모듈이 비어 있음
vcpkg를 서브모듈로 쓰는데 checkout에서 submodules: recursive를 빠뜨리면 vcpkg/ 디렉터리가 비어 툴체인 파일을 찾지 못합니다. checkout step에 이 옵션을 추가합니다.
Windows 잡만 컴파일러를 못 찾음
Windows 러너 이미지에는 CMake와 Visual Studio가 설치되어 있지만, cl.exe는 개발자 명령 프롬프트 환경이 설정되어야 PATH에 잡힙니다. Visual Studio 생성기(CMake 기본값)를 쓰면 CMake가 알아서 MSVC를 찾으므로 문제가 없고, Ninja나 Makefile 생성기로 MSVC를 쓸 때만 ilammy/msvc-dev-cmd 같은 액션이 필요합니다. 또 매트릭스의 빈 cc 값을 -DCMAKE_C_COMPILER=처럼 그대로 넘기면 CMake가 컴파일러를 찾지 못하므로, 앞의 예제처럼 값이 있을 때만 옵션을 붙입니다.
캐시가 너무 커짐
GitHub Actions 캐시는 저장소당 총량 한도(기본 10GB)가 있고, 넘으면 오래 쓰지 않은 캐시부터 지워집니다. 에러로 실패하지는 않지만 캐시가 계속 밀려나 히트율이 떨어집니다. vcpkg는 중간 산출물 대신 바이너리 캐시만, 빌드 디렉터리는 꼭 필요할 때만 캐시하고, 매트릭스 조합마다 캐시를 따로 만들면 총량이 빠르게 늘어난다는 점을 고려합니다.
”Could not find preset”
CMakePresets.json에 그 이름이 없거나, preset의 condition(예: hostSystemName이 Windows일 때만 활성화)이 현재 러너에서 false인 경우입니다. cmake --list-presets로 현재 환경에서 보이는 preset을 확인하고, Presets 파일의 version이 러너의 CMake보다 높은 스키마를 요구하지 않는지도 봅니다.
”No space left on device”
vcpkg 빌드 트리와 큰 빌드 디렉터리가 러너 디스크를 채운 경우입니다. 바이너리 캐시가 동작하면 대부분 해소되고, 그래도 부족하면 VCPKG_INSTALL_OPTIONS에 --clean-after-build를 넣어 패키지별 빌드 트리를 지우거나 Ubuntu 러너에 미리 설치된 쓰지 않는 도구 디렉터리를 정리합니다.
job 시간 초과
GitHub 호스팅 러너의 job은 최대 6시간(360분)까지 실행됩니다. 의존성이 많은 프로젝트에서 캐시 없이 처음 빌드하면 이 한도에 가까워질 수 있습니다. 바이너리 캐시를 설정하고, 쓰지 않는 의존성을 정리하고, timeout-minutes로 job별 상한을 짧게 걸어 멈춘 job이 분을 낭비하지 않게 합니다. 더 긴 빌드가 필요하면 self-hosted 러너를 고려합니다.
macOS에서 Xcode 관련 에러
GitHub의 macOS 러너에는 여러 버전의 Xcode가 설치되어 있습니다. 특정 버전이 필요하거나 “invalid active developer path” 같은 에러가 나면 maxim-lobanov/setup-xcode 액션이나 sudo xcode-select -s /Applications/Xcode_<버전>.app으로 사용할 Xcode를 지정합니다. xcode-select --install은 대화형 설치 창을 띄우는 명령이라 CI에서는 쓸 수 없습니다.
CI 시간 줄이기
캐시 키 전략
캐시 키에는 결과에 영향을 주는 것만 넣습니다. OS, 트리플렛이나 컴파일러, 의존성 선언 파일의 해시가 그 대상입니다. restore-keys로 접두사가 같은 이전 캐시를 대체로 복원하게 하면, 의존성 하나를 바꿔도 나머지는 재사용됩니다.
ccache로 컴파일 가속
- name: Setup ccache
uses: hendrikmuhs/ccache-action@v1.2
with:
key: ${{ matrix.os }}-${{ matrix.compiler }}
- name: Configure
run: cmake -B build -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
- name: Build
run: cmake --build build
build/ 디렉터리를 actions/cache로 보존하면 증분 빌드가 될 것 같지만 실제로는 거의 효과가 없습니다. actions/checkout이 소스 파일의 수정 시각을 체크아웃 시점으로 새로 쓰기 때문에, Ninja와 Make는 복원된 오브젝트 파일보다 모든 소스가 새것이라고 판단해 전부 다시 컴파일합니다. 함께 복원된 CMakeCache.txt가 preset이나 컴파일러를 바꾼 뒤에도 이전 값을 남기는 문제도 생깁니다. ccache는 파일 시각이 아니라 소스와 컴파일 옵션의 해시로 결과를 찾으므로 이 문제가 없고, 캐시 키에 소스 파일 해시를 넣을 필요도 없습니다. MSVC는 ccache 지원이 제한적이라 Windows job에는 sccache를 쓰는 경우가 많습니다. 오히려 소스 해시를 키에 넣으면 커밋마다 키가 바뀌어 이전 캐시를 정확히 복원하지 못합니다. OS와 컴파일러처럼 안정적인 값을 키로 쓰면 액션이 실행 끝에 갱신된 캐시를 저장합니다.
불필요한 실행 줄이기
문서만 바뀐 PR에서는 빌드할 필요가 없습니다. 워크플로 트리거에 경로 조건을 걸면 해당 파일이 바뀐 경우에만 실행됩니다.
on:
push:
branches: [main]
paths:
- 'src/**'
- 'CMakeLists.txt'
- 'vcpkg.json'
- '.github/workflows/**'
pull_request:
branches: [main]
paths-ignore:
- 'docs/**'
- '**.md'
브랜치 보호에서 필수 체크로 지정한 워크플로를 경로 조건으로 건너뛰면, 체크가 “대기 중”으로 남아 PR을 병합할 수 없게 될 수 있습니다. 이 경우 워크플로는 항상 실행하고 job 안에서 dorny/paths-filter로 무거운 step만 건너뛰는 방식이 낫습니다. strategy.max-parallel로 동시에 실행할 매트릭스 job 수를 제한할 수도 있습니다.
확장 패턴
job 사이에 빌드 결과 넘기기
job은 서로 다른 러너에서 실행되므로, 빌드 job의 build/ 디렉터리를 테스트 job이 그대로 쓸 수 없습니다. 빌드와 테스트를 같은 job에 두는 것이 가장 단순하고, 나눠야 한다면 아티팩트로 넘깁니다.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
- uses: actions/upload-artifact@v4
with:
name: build-linux
path: build/
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: build-linux
path: build/
- run: chmod +x build/my-app && ctest --test-dir build --output-on-failure
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
steps:
- run: echo "Deploy"
아티팩트로 옮기면 실행 권한이 사라지므로 chmod +x가 필요하고, CTest가 빌드 디렉터리의 절대 경로를 기록하기 때문에 같은 경로(같은 러너 종류와 같은 체크아웃 위치)에서 실행해야 합니다. 배포 자격 증명은 저장소 Secrets에 두고 ${{ secrets.DEPLOY_KEY }}로 참조하며, environment를 지정하면 환경별 비밀과 승인 규칙을 따로 둘 수 있습니다.
여러 매트릭스 job의 결과를 한곳에 모을 때는 아티팩트 이름에 주의합니다. actions/upload-artifact@v4부터는 한 워크플로 실행 안에서 이름이 겹치면 업로드가 실패하므로 build-${{ matrix.os }}처럼 매트릭스 값을 넣고, 받는 쪽에서 pattern: build-*와 merge-multiple: true로 합칩니다. merge-multiple 없이 받으면 아티팩트마다 하위 디렉터리가 생겨 이후 단계의 경로가 어긋납니다. if-no-files-found의 기본값은 warn이라 경로를 잘못 적어도 job이 성공하므로 릴리스용 산출물은 error로 두고, 저장 용량이 플랜 한도에 포함되므로 CI 산출물은 retention-days를 짧게 잡습니다. 실행 권한까지 보존하려면 업로드 전에 tar로 묶습니다.
Sanitizer job
AddressSanitizer와 UndefinedBehaviorSanitizer는 Release 테스트로는 드러나지 않는 메모리 오류를 잡으므로 별도 job으로 둡니다.
sanitize:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Configure
run: >
cmake -B build -DCMAKE_BUILD_TYPE=Debug
-DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer"
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined"
- run: cmake --build build
- name: Test
env:
ASAN_OPTIONS: detect_leaks=1
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
run: ctest --test-dir build --output-on-failure
UBSan은 기본적으로 오류를 출력만 하고 계속 실행하므로, halt_on_error=1(또는 컴파일 옵션 -fno-sanitize-recover=all)이 없으면 테스트가 통과한 것으로 끝날 수 있습니다. ThreadSanitizer는 ASan과 같은 바이너리에 켤 수 없어 job을 하나 더 두고, vcpkg로 받은 의존성은 Sanitizer 없이 빌드되므로 의존성 내부 오류는 계측되지 않는다는 한계도 있습니다.
태그 푸시로 릴리스 배포
v* 태그가 푸시되면 OS별로 빌드해 압축하고, 마지막 job에서 모아 GitHub Release를 만드는 구성입니다.
name: Release
on:
push:
tags: ['v*']
jobs:
build:
strategy:
matrix:
include:
- { os: ubuntu-22.04, bin: build/myapp }
- { os: macos-14, bin: build/myapp }
- { os: windows-2022, bin: build/Release/myapp.exe }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Build
shell: bash
run: cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --config Release
- name: Package
shell: bash
run: |
mkdir -p dist
tar czf "dist/myapp-${{ github.ref_name }}-${{ matrix.os }}.tar.gz" \
-C "$(dirname '${{ matrix.bin }}')" "$(basename '${{ matrix.bin }}')"
- uses: actions/upload-artifact@v4
with:
name: pkg-${{ matrix.os }}
path: dist/
if-no-files-found: error
release:
needs: build
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
pattern: pkg-*
path: dist
merge-multiple: true
- uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: true
Linux와 macOS 바이너리는 이름이 둘 다 myapp이라 그대로 올리면 릴리스 자산 이름이 충돌하므로 OS와 태그를 넣은 압축 파일로 만듭니다. permissions.contents: write가 없으면 기본 GITHUB_TOKEN이 읽기 전용인 저장소에서 “Resource not accessible by integration”으로 실패합니다. 예전 예제의 actions/create-release와 actions/upload-release-asset은 보관(archive) 처리된 액션이라 새 워크플로에는 쓰지 않습니다. 같은 워크플로에서 GHCR로 Docker 이미지를 푸시한다면 job에 packages: write가 필요하고, 이미지 이름은 소문자여야 해서 소유자 이름에 대문자가 있으면 github.repository를 소문자로 바꾸는 단계가 필요합니다. 이미지 구성은 Docker 글(#40-3)에서 다룹니다. git describe로 버전 문자열을 만든다면 checkout에 fetch-depth: 0을 줘야 태그 정보까지 받아집니다(기본값 1은 최신 커밋만 받음).
운영 설정
actions/checkout@main처럼 브랜치를 가리키면 액션이 바뀔 때마다 동작이 달라질 수 있으므로 최소한 @v4 같은 메이저 태그로 고정하고, 서드파티 액션은 owner/action@<커밋 SHA>처럼 전체 SHA로 고정하는 것이 GitHub의 공급망 보안 권고입니다. 버전 갱신은 .github/dependabot.yml에 package-ecosystem: "github-actions"를 등록해 Dependabot에 맡길 수 있습니다.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
concurrency는 같은 브랜치에 연속으로 푸시했을 때 이전 실행을 취소해 분을 아낍니다. 릴리스 워크플로에는 cancel-in-progress를 켜지 않는 편이 안전합니다.
run 안의 ${{ }} 표현식은 셸 스크립트 텍스트에 그대로 치환됩니다. PR 제목처럼 외부인이 정할 수 있는 값을 run: echo "${{ github.event.pull_request.title }}"처럼 넣으면 명령 주입이 가능하므로, env:로 넘기고 셸 변수("$PR_TITLE")로 참조합니다. 로그의 시크릿은 자동 마스킹되지만 인코딩하거나 일부만 잘라 출력하면 마스킹되지 않습니다.
여러 워크플로가 같은 빌드 절차를 쓴다면 on: workflow_call로 입력(예: preset 이름)을 받는 재사용 워크플로를 만들고, 호출하는 쪽에서는 step이 아니라 job 수준에서 uses: ./.github/workflows/build.yml과 with:로 부릅니다.
포맷과 정적 분석
- name: Check formatting
run: find src \( -name '*.cpp' -o -name '*.h' \) -print0 | xargs -0 clang-format --dry-run -Werror
- name: Run clang-tidy
run: |
cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
run-clang-tidy -p build
러너에 설치된 clang-format 버전에 따라 결과가 달라질 수 있으므로, 팀이 쓰는 버전을 명시적으로 설치해 고정하는 편이 좋습니다.
커버리지 업로드
- name: Build with coverage
run: |
cmake -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="--coverage"
cmake --build build
- name: Run tests
run: ctest --test-dir build
- name: Collect coverage
run: |
sudo apt-get install -y lcov
lcov --capture --directory build --output-file coverage.info
lcov --remove coverage.info '/usr/*' --output-file coverage.info
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.info
--coverage로 빌드하고 테스트를 실행하면 .gcda 파일이 생길 뿐이므로, lcov 같은 도구로 리포트 파일을 만든 뒤 업로드해야 합니다. 자세한 내용은 코드 커버리지 글을 참고하십시오.
Self-hosted 러너
빌드가 매우 무겁거나 특수한 하드웨어가 필요하면 자체 서버에 러너를 설치하고 runs-on: [self-hosted, linux, x64]처럼 라벨로 지정합니다. 공개 저장소에서 self-hosted 러너를 쓰면 외부 PR의 코드가 자기 서버에서 실행될 수 있으므로, 공개 저장소에는 권장되지 않습니다.
참고 자료
같이 보면 좋은 글
- C++ 패키지 관리 실무: vcpkg와 Conan으로 외부 라이브러리 의존성 지옥 탈출 [#40-1]
- CMake find_package로 외부 라이브러리 연결하기: Config vs Module 모드와 커스텀 Find 모듈
- C++ 패키지 매니저 vcpkg와 Conan 비교: manifest·트리플렛·프로파일과 CMake 연동
- C++23에서 실제로 쓸 기능
- C++ 컨테이너 기반 개발: Docker로 빌드 환경 표준화 및 배포 이미지 최적화 [#40-3]
자주 묻는 질문 (FAQ)
매트릭스 빌드에서 Windows 잡만 cmake나 컴파일러를 못 찾는 이유는 무엇인가요?
Windows 러너는 Linux·macOS와 셸과 경로 규칙이 다르고, MSVC는 개발자 명령 프롬프트 환경이 설정되어야 cl.exe가 PATH에 잡힙니다. 그래서 Linux 기준으로 작성한 cc/cxx 환경 변수나 bash 전용 스크립트를 그대로 쓰면 Windows 잡에서만 실패하기 쉽습니다. matrix.include로 OS별 컴파일러 설정을 따로 두고, Windows에서는 CMake의 Visual Studio 생성기를 쓰거나 MSVC 환경을 잡아 주는 액션을 쓰는 방식으로 분리하는 것이 좋습니다.
vcpkg와 Conan 중 어떤 걸 CI에 쓰나요?
40-1에서 선택한 패키지 매니저를 그대로 CI에 연동하면 됩니다. vcpkg는 매니페스트 모드와 서브모듈(또는 러너에 설치된 vcpkg), Conan은 pip install conan과 conan profile detect로 CI를 구성합니다. 둘 다 멀티 OS 빌드와 바이너리 캐시를 지원합니다.
이전 글: DevOps for C++ #40-1: vcpkg·Conan
다음 글: [DevOps for C++ #40-3] 컨테이너 기반 개발: Docker로 빌드 환경 표준화 및 배포 이미지 최적화