The Problem With Most CI Guides
You search "GitHub Actions unit tests", find a guide, copy the YAML, and it works — until:
- A docs-only PR triggers a 20-minute build
- Tests pass in CI but crash in production
- One flaky test fails randomly and blocks your whole team
These are real problems. Here's how to solve them.
1. Path Filtering — Skip Builds When Nothing Changed
- name: Detect changes
id: changes
uses: dorny/paths-filter@v3
with:
filters: |
code:
- '**/*.cpp'
- '**/*.h'
- '**/*.hpp'
- '**/CMakeLists.txt'
- name: Run Tests
if: steps.changes.outputs.code == 'true'
run: make run-tests
Real impact: A typo fix in README.md used to burn 20 minutes of runner time. Now it passes in under 5 seconds.
2. Build and Test Inside Docker
Runner environment drift is the silent killer of reproducibility. The Ubuntu version changes, a system library gets updated, and suddenly your green CI is red for no reason you can explain.
- name: Build Docker image with tests enabled
run: |
docker build . -f Dockerfile -t myapp-ci \
--build-arg WITH_TESTS=true
- name: Run tests inside container
run: |
mkdir -p ./test-output
docker run --rm \
-v ./test-output:/app/build/Testing/Temporary \
myapp-ci \
ctest --test-dir /app/build --output-on-failure
Why mount ./test-output? When tests fail, you need the logs. Without the mount, they disappear when the container exits.
3. Run ASAN and TSAN — Your Code Isn't as Safe as You Think
Most C++ bugs that reach production wouldn't make it past:
- AddressSanitizer (ASAN) — buffer overflows, use-after-free, memory leaks
- ThreadSanitizer (TSAN) — data races, deadlocks
jobs:
release:
uses: ./.github/workflows/test.yml
with:
preset: release
asan:
uses: ./.github/workflows/test.yml
with:
preset: asan
tsan:
needs: asan # stagger — both are CPU-heavy
uses: ./.github/workflows/test.yml
with:
preset: tsan
⚠️ Don't run ASAN and TSAN simultaneously. They compete for the same runner resources and you'll get false failures from starvation, not real bugs. Use
needs:to sequence them.
4. Retry Only the Failing Tests — Not the Whole Suite
Flaky tests exist. Denying this costs your team hours per week. The wrong fix is marking tests DISABLED_ or ignoring them. The right fix:
- name: Run Tests
run: ctest --test-dir build --output-on-failure --parallel $(nproc)
- name: Retry Failed Tests
if: failure()
run: |
ctest --test-dir build \
--rerun-failed \
--output-on-failure \
--repeat until-pass:3
--rerun-failed is a CTest built-in. It reads the last run's results and only executes what failed. A 20-minute full suite retry becomes a 90-second targeted retry.
5. Debug Verbose Output — Without Polluting Normal Runs
Verbose test output in every run is thousands of lines of noise. But when you're debugging at 2am, you need every byte.
- name: Run Tests
if: runner.debug != '1'
run: ctest --test-dir build --output-on-failure
- name: Run Tests (verbose)
if: runner.debug == '1'
run: ctest --test-dir build --verbose
To enable debug mode: GitHub Actions → Re-run jobs → Enable debug logging. No YAML changes, no new commits. Just flip a switch.
6. Cancel Redundant Runs With One Config Block
You push a fix, realize you forgot something, push again. Now you have two CI runs for the same branch. The first one is waste.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Two lines at the top of every workflow. The new push kills the old run immediately. Zero cost, zero thought, instant win.
Summary
CI is code. Treat it with the same discipline: no waste, informative failures, easy to maintain.






Top comments (0)