Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install deps
run: sudo apt-get update && sudo apt-get install -y cmake clang-format clang-tidy
run: >
sudo apt-get update &&
sudo apt-get install -y cmake clang-format clang-tidy
- name: Format check
run: ./scripts/format.sh --check
- name: Build
run: mkdir -p build && cd build && cmake .. && make
run: ./scripts/build.sh
- name: Lint check
run: ./scripts/lint.sh
- name: Test
run: cd build && ctest --output-on-failure
run: ./scripts/test.sh
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ build/
install/

# Doxygen
html/
docs/generated/

# Testing
Testing/
Expand Down
49 changes: 49 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Build & Test Commands

```bash
./scripts/build.sh # Debug build with tests enabled
./scripts/test.sh # Run all tests
./scripts/package.sh # Release build + CPack packaging
./scripts/format.sh # Apply clang-format
./scripts/format.sh --check # Verify formatting (CI mode)
./scripts/lint.sh # Run clang-tidy (build first — needs compile_commands.json)
./scripts/coverage.sh # Generate lcov coverage report
./scripts/docs.sh # Generate Doxygen documentation
```

Pre-commit hooks run on **pre-push** (not pre-commit): `pre-commit install --hook-type pre-push`

All scripts auto-delegate to Docker when run outside the container (`docker run --rm`). The delegation logic lives in `scripts/docker/exec.sh`, sourced by each script via `scripts/env.sh`. Inside the container or CI (`CI=true`), scripts run directly with no overhead. See `docs/ci-container-delegation.md` for details on the CI strategy and a GHCR upgrade path.

## Architecture

This is a C++ project template using **CMake >= 3.20** and **C++17**. Each build target lives in its own `src/<name>/` subdirectory with its own `CMakeLists.txt`, registered via `add_subdirectory()` in the root CMakeLists.txt.

The template demonstrates four library patterns that build on each other:

- **Static library** (`example_static`) — simplest case, compiled and linked at build time
- **Shared library** (`example_shared`) — uses RPATH (`$ORIGIN/../lib`) so the binary finds `.so` files relative to itself at runtime, making the install relocatable
- **Interface library** (`example_interface`) — header-only, no compiled output; uses `INTERFACE` visibility so dependents get the include paths automatically
- **Public/Private visibility** (`example_public_private`) — demonstrates how `PUBLIC` includes propagate to dependents while `PRIVATE` includes stay internal

The **plugin system** (`example_plugin_loader` + `example_plugin_impl`) shows runtime loading via `dlopen()`. Plugins implement a C-compatible API defined in `plugin_api.hpp` and must export `create_plugin()` as `extern "C"`. The loader discovers plugin `.so` files via RPATH.

The main executable (`src/main/`) links against all libraries and demonstrates their usage together.

Tests use **GoogleTest v1.14.0** (fetched via `FetchContent`). Test files follow the pattern `tests/test_<target_name>.cpp` and are discovered via `gtest_discover_tests()`. The `tests/test_helpers.hpp` provides an `OutputCapture` utility for testing stdout.

## Code Style

Enforced by `.clang-format` and `.clang-tidy` — CI rejects non-conforming code.

- Google C++ style base, **4-space indent**, **100-char column limit**, K&R braces
- Pointer alignment: left (`int* ptr`)
- Naming: `lower_case` for variables/members, `CamelCase` for classes/structs
- Headers use `#pragma once` (not traditional include guards)
- Includes: sorted and grouped (main header, then system, then project)
- Use `target_include_directories` and `target_link_libraries` with correct CMake visibility (PUBLIC/PRIVATE/INTERFACE) — never raw compiler/linker flags
- Shared libraries must configure RPATH — never hardcode absolute paths
2 changes: 1 addition & 1 deletion docs/Doxyfile
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ PROJECT_LOGO =
# entered, it will be relative to the location where doxygen was started. If
# left blank the current directory will be used.

OUTPUT_DIRECTORY =
OUTPUT_DIRECTORY = docs/generated

# If the CREATE_SUBDIRS tag is set to YES then doxygen will create up to 4096
# sub-directories (in 2 levels) under the output directory of each output format
Expand Down
147 changes: 147 additions & 0 deletions docs/ci-container-delegation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# CI and Container Delegation

## How Script Delegation Works

All project scripts (`build.sh`, `test.sh`, `format.sh`, `lint.sh`, etc.) auto-delegate to Docker when run outside the container. The delegation logic in `scripts/docker/exec.sh` checks three conditions in order:

1. **Inside a container** (`/.dockerenv` exists) — run directly, no delegation
2. **CI environment** (`CI=true`) — run directly, tools provided by the runner
3. **Developer host** — delegate to Docker via `docker run --rm`

```
Developer host Container / CI runner
-------------- ---------------------
./scripts/build.sh
source env.sh
source docker/exec.sh
delegate_to_container
/.dockerenv? No
CI=true? No
docker run --rm ... build.sh --> ./scripts/build.sh
exit $? delegate_to_container
/.dockerenv? Yes
return 0
cmake / make / ...
<-- exits
```

## Current CI Approach

The CI workflow installs tools directly on the GitHub Actions runner and skips Docker delegation:

```yaml
# .github/workflows/ci.yml
steps:
- uses: actions/checkout@v4
- name: Install deps
run: >
sudo apt-get update &&
sudo apt-get install -y cmake clang-format clang-tidy
- name: Format check
run: ./scripts/format.sh --check
- name: Build
run: ./scripts/build.sh
- name: Lint check
run: ./scripts/lint.sh
- name: Test
run: ./scripts/test.sh
```

GitHub Actions sets `CI=true` automatically, so `delegate_to_container` returns immediately and scripts run directly on the runner. This is fast, requires no Docker setup, and works out-of-the-box for anyone who forks the template.

### Why not Docker in CI?

An earlier approach ran some CI steps directly on the host and others via Docker delegation. This caused path mismatches: `compile_commands.json` generated on the host contained runner paths (`/home/runner/work/...`), but `clang-tidy` ran inside a Docker container with different paths (`/workspaces/...`), causing crashes. The current approach avoids this by running everything in the same context.

## Alternative: GHCR Container Image

For production projects that require identical toolchains in CI and local development, you can publish the dev container image to GitHub Container Registry (GHCR) and use it as the CI job container.

GHCR is free for public repositories (unlimited storage and bandwidth).

### Setup

**1. Add a workflow to build and push the image** (`.github/workflows/docker-image.yml`):

```yaml
name: Docker Image
on:
push:
branches: [main]
paths:
- 'Dockerfile'
- 'scripts/docker/entrypoint.sh'
- '.github/workflows/docker-image.yml'
workflow_dispatch:

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}/cpp-dev

jobs:
build-and-push:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
```

**2. Update the CI workflow** to use the published image:

```yaml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-24.04
container:
image: ghcr.io/${{ github.repository }}/cpp-dev:latest
steps:
- uses: actions/checkout@v4
- name: Format check
run: ./scripts/format.sh --check
- name: Build
run: ./scripts/build.sh
- name: Lint check
run: ./scripts/lint.sh
- name: Test
run: ./scripts/test.sh
```

### Why this works without code changes

When GitHub Actions runs a job with `container:`, it creates `/.dockerenv` inside the container. The first check in `delegate_to_container` detects this and skips delegation. The `CI=true` check is never reached, so both guards coexist without conflict.

### Trade-offs

| | apt-get (current) | GHCR container |
|---|---|---|
| CI speed | Fast | Fast (pre-built image) |
| Tool consistency | Runner versions (minor drift possible) | Identical to local dev |
| Fork setup | Zero — works immediately | Must trigger image build first |
| Maintenance | 1 workflow | 2 workflows |

### First-time setup for GHCR

1. Push the `docker-image.yml` workflow to `main`
2. Go to Actions tab and manually trigger "Docker Image" (`workflow_dispatch`)
3. Go to Packages tab and ensure the image visibility matches the repo (public/private)
4. Update `ci.yml` to use `container:` as shown above
5. Remove the `apt-get install` step (tools come from the image)
27 changes: 19 additions & 8 deletions scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,35 @@ set -e
# Build the project using CMake
#
# Builds in Debug mode by default and installs to the install/ directory.
# When run outside the container, delegates execution to Docker automatically.
#
# Usage:
# ./scripts/build.sh
###############################################################################

# Set build directory
BUILD_DIR="build"
INSTALL_DIR="install"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/env.sh"
source "$SCRIPT_DIR/docker/exec.sh"
delegate_to_container "$@"

# ---------------------------------------------------------------------------
# Build
# ---------------------------------------------------------------------------
cd "$PROJECT_ROOT"

BUILD_DIR="${PROJECT_ROOT}/build"
INSTALL_DIR="${PROJECT_ROOT}/install"

# Create and enter build directory
mkdir -p "$BUILD_DIR"
cd "$BUILD_DIR"

# Run CMake with Debug build type by default (for development)
cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX="../$INSTALL_DIR" ..
log_step "Configuring CMake (Debug)..."
cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" "$PROJECT_ROOT"

# Build all targets with all cores
log_step "Building with $(nproc) cores..."
make -j"$(nproc)"

# Install to ../install
log_step "Installing to $INSTALL_DIR"
make install

log_info "Build complete."
46 changes: 24 additions & 22 deletions scripts/coverage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,46 @@ set -e
# Build and run code coverage analysis
#
# Builds with coverage enabled, runs tests, and generates HTML coverage report.
# When run outside the container, delegates execution to Docker automatically.
#
# Usage:
# ./scripts/coverage.sh
###############################################################################

# Set build directory
BUILD_DIR="build"
INSTALL_DIR="install"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/env.sh"
source "$SCRIPT_DIR/docker/exec.sh"
delegate_to_container "$@"

echo "Building with code coverage enabled..."
# ---------------------------------------------------------------------------
# Coverage
# ---------------------------------------------------------------------------
cd "$PROJECT_ROOT"

BUILD_DIR="${PROJECT_ROOT}/build"
INSTALL_DIR="${PROJECT_ROOT}/install"

# Create and enter build directory
mkdir -p "$BUILD_DIR"
cd "$BUILD_DIR"

# Run CMake with coverage enabled
cmake -DCMAKE_BUILD_TYPE=Debug -DENABLE_COVERAGE=ON -DCMAKE_INSTALL_PREFIX="../$INSTALL_DIR" ..
log_step "Configuring CMake with coverage enabled..."
cmake -DCMAKE_BUILD_TYPE=Debug -DENABLE_COVERAGE=ON -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" "$PROJECT_ROOT"

# Build all targets with all cores
log_step "Building with $(nproc) cores..."
make -j"$(nproc)"

# Run tests to generate coverage data
log_step "Running tests..."
make test

# Generate coverage report with lcov
echo "Generating coverage report..."
export LC_ALL=C # Fix locale warnings
log_step "Generating coverage report..."
export LC_ALL=C
lcov --capture --directory . --output-file coverage.info --ignore-errors mismatch
lcov --remove coverage.info '/usr/*' --output-file coverage.info --ignore-errors unused # Remove system files
lcov --remove coverage.info '*/build/*' --output-file coverage.info --ignore-errors unused # Remove build files
lcov --remove coverage.info '*/tests/*' --output-file coverage.info --ignore-errors unused # Remove test files
lcov --remove coverage.info '*/_deps/*' --output-file coverage.info --ignore-errors unused # Remove external deps
lcov --remove coverage.info '/usr/*' --output-file coverage.info --ignore-errors unused
lcov --remove coverage.info '*/build/*' --output-file coverage.info --ignore-errors unused
lcov --remove coverage.info '*/tests/*' --output-file coverage.info --ignore-errors unused
lcov --remove coverage.info '*/_deps/*' --output-file coverage.info --ignore-errors unused

# Generate HTML report
genhtml coverage.info --output-directory coverage_report

echo "Coverage report generated in build/coverage_report/"
echo "Open build/coverage_report/index.html in your browser to view the report"

# Optional: Install the project
# make install
log_info "Coverage report generated in ${BUILD_DIR}/coverage_report/"
log_info "Open ${BUILD_DIR}/coverage_report/index.html in your browser to view the report."
Loading
Loading