From 6f11ff51a3b1cabfbf2f843443abfff4281e08f9 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Tue, 10 Mar 2026 23:33:41 -0700 Subject: [PATCH 01/14] feat: add env.sh and docker/exec.sh for container delegation Introduce scripts/env.sh with colored logging helpers and project path resolution, and scripts/docker/exec.sh with a delegate_to_container function that transparently re-executes scripts inside a disposable Docker container (docker run --rm) when invoked from the host. --- scripts/docker/exec.sh | 75 ++++++++++++++++++++++++++++++++++++++++++ scripts/env.sh | 51 ++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 scripts/docker/exec.sh create mode 100644 scripts/env.sh diff --git a/scripts/docker/exec.sh b/scripts/docker/exec.sh new file mode 100644 index 0000000..562c00a --- /dev/null +++ b/scripts/docker/exec.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +############################################################################### +# Container delegation for project scripts +# +# When a script is invoked outside the container, this module re-executes it +# inside a disposable Docker container (docker run --rm) and exits. +# If already inside the container, it returns immediately and the calling +# script continues as normal. +# +# How it works: +# +# Host shell Container shell +# ---------- ---------------- +# ./scripts/build.sh +# source env.sh +# source docker/exec.sh +# delegate_to_container +# docker run --rm ... build.sh --> ./scripts/build.sh +# exit $? source env.sh +# source docker/exec.sh +# delegate_to_container +# sees /.dockerenv +# return 0 +# cmake ... +# make ... +# <-- exits (container destroyed) +# +# The host starts a subprocess (docker run) that runs the SAME script from +# scratch. The second invocation detects /.dockerenv, skips delegation, +# and executes the real work. The host waits for the exit code and forwards it. +# The container is destroyed automatically after the script finishes (--rm). +# +# Requires env.sh to be sourced first (for logging helpers and PROJECT_ROOT). +# +# Usage (from any project script, after sourcing env.sh): +# source "$SCRIPT_DIR/docker/exec.sh" +# delegate_to_container "$@" +############################################################################### + +IMAGE_NAME="cpp-dev:latest" +CONTAINER_WORKDIR="/workspaces/${PROJECT_NAME}" + +delegate_to_container() { + # Already inside the container -- nothing to do + if [ -f /.dockerenv ]; then + return 0 + fi + + # Resolve the calling script path relative to project root + local caller_script="${BASH_SOURCE[1]}" + local script_relative + script_relative="$(realpath --relative-to="$PROJECT_ROOT" "$caller_script")" + + log_docker "Running outside container -- delegating to Docker..." + + # Build the image if it does not exist yet + if ! docker image inspect "$IMAGE_NAME" &> /dev/null; then + log_docker "Image '${IMAGE_NAME}' not found. Building it first..." + "${PROJECT_ROOT}/scripts/docker/build_image.sh" + fi + + # Run the script inside a disposable container + docker run --rm \ + --hostname cpp-devcontainer \ + --env "HOST_UID=$(id -u)" \ + --env "HOST_GID=$(id -g)" \ + --env TERM=xterm-256color \ + --volume "$PROJECT_ROOT:${CONTAINER_WORKDIR}" \ + --workdir "${CONTAINER_WORKDIR}" \ + "$IMAGE_NAME" \ + bash "${CONTAINER_WORKDIR}/${script_relative}" "$@" + + exit $? +} diff --git a/scripts/env.sh b/scripts/env.sh new file mode 100644 index 0000000..cd17d3f --- /dev/null +++ b/scripts/env.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +############################################################################### +# Environment setup for all project scripts +# +# Provides: +# - Colored output and logging helpers +# - Project path resolution +# +# Usage (source from other scripts): +# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# source "$SCRIPT_DIR/env.sh" +############################################################################### + +# ============================================================================== +# Color definitions (disabled when output is not a terminal) +# ============================================================================== +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + BLUE='\033[0;34m' + CYAN='\033[0;36m' + BOLD='\033[1m' + RESET='\033[0m' +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + CYAN='' + BOLD='' + RESET='' +fi + +# ============================================================================== +# Logging helpers +# ============================================================================== +log_info() { echo -e "${GREEN}[INFO]${RESET} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +log_error() { echo -e "${RED}[ERROR]${RESET} $*"; } +log_step() { echo -e "${CYAN}[STEP]${RESET} $*"; } +log_docker() { echo -e "${BLUE}[CONTAINER]${RESET} $*"; } + +# ============================================================================== +# Project paths +# +# SCRIPT_DIR must be set by the calling script before sourcing env.sh. +# ============================================================================== +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_NAME="$(basename "$PROJECT_ROOT")" From 6da56601491284f6840b15e14f0af9ebbf1c544a Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Tue, 10 Mar 2026 23:33:53 -0700 Subject: [PATCH 02/14] feat: update all scripts to use env.sh and container delegation Each script now sources env.sh for colored logging and docker/exec.sh for automatic container delegation. Plain echo statements replaced with log_info, log_step, log_warn, and log_error helpers. --- scripts/build.sh | 19 ++++++++++++++----- scripts/coverage.sh | 40 ++++++++++++++++++++-------------------- scripts/docs.sh | 15 +++++++++++---- scripts/format.sh | 21 +++++++++++++-------- scripts/lint.sh | 17 +++++++++++++---- scripts/package.sh | 29 ++++++++++++++++------------- 6 files changed, 87 insertions(+), 54 deletions(-) diff --git a/scripts/build.sh b/scripts/build.sh index d5c03a9..9f5d036 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -5,24 +5,33 @@ 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 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/env.sh" +source "$SCRIPT_DIR/docker/exec.sh" +delegate_to_container "$@" + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- BUILD_DIR="build" INSTALL_DIR="install" -# Create and enter build directory mkdir -p "$BUILD_DIR" cd "$BUILD_DIR" -# Run CMake with Debug build type by default (for development) +log_step "Configuring CMake (Debug)..." cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX="../$INSTALL_DIR" .. -# 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." diff --git a/scripts/coverage.sh b/scripts/coverage.sh index f6b1a81..ae488e7 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -5,44 +5,44 @@ 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 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/env.sh" +source "$SCRIPT_DIR/docker/exec.sh" +delegate_to_container "$@" + +# --------------------------------------------------------------------------- +# Coverage +# --------------------------------------------------------------------------- BUILD_DIR="build" INSTALL_DIR="install" -echo "Building with code coverage enabled..." - -# Create and enter build directory mkdir -p "$BUILD_DIR" cd "$BUILD_DIR" -# Run CMake with coverage enabled +log_step "Configuring CMake with coverage enabled..." cmake -DCMAKE_BUILD_TYPE=Debug -DENABLE_COVERAGE=ON -DCMAKE_INSTALL_PREFIX="../$INSTALL_DIR" .. -# 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/coverage_report/" +log_info "Open build/coverage_report/index.html in your browser to view the report." diff --git a/scripts/docs.sh b/scripts/docs.sh index 5104282..58e27a3 100755 --- a/scripts/docs.sh +++ b/scripts/docs.sh @@ -5,18 +5,25 @@ set -e # Generate Doxygen documentation # # Generates HTML documentation from source code comments. +# When run outside the container, delegates execution to Docker automatically. # # Usage: # ./scripts/docs.sh ############################################################################### SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +source "$SCRIPT_DIR/env.sh" +source "$SCRIPT_DIR/docker/exec.sh" +delegate_to_container "$@" + +# --------------------------------------------------------------------------- +# Documentation +# --------------------------------------------------------------------------- DOCS_DIR="${PROJECT_ROOT}/docs" -echo "[INFO] Generating Doxygen documentation..." -echo "[INFO] Doxygen version: $(doxygen --version)" +log_info "Generating Doxygen documentation..." +log_info "Doxygen version: $(doxygen --version)" -# Run doxygen from project root doxygen "$DOCS_DIR/Doxyfile" +log_info "Documentation generation complete." diff --git a/scripts/format.sh b/scripts/format.sh index 06f12f0..4679b48 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -5,6 +5,7 @@ set -e # Format all C++ source/header files using clang-format # # Formats all C++ files in the project according to .clang-format config. +# When run outside the container, delegates execution to Docker automatically. # # Usage: # ./scripts/format.sh - Format files in-place @@ -12,15 +13,19 @@ set -e ############################################################################### SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +source "$SCRIPT_DIR/env.sh" +source "$SCRIPT_DIR/docker/exec.sh" +delegate_to_container "$@" -# Check if --check flag is passed +# --------------------------------------------------------------------------- +# Format +# --------------------------------------------------------------------------- CHECK_MODE=false if [[ "$1" == "--check" ]]; then CHECK_MODE=true - echo "[INFO] Running clang-format in check mode (no modifications)..." + log_info "Running clang-format in check mode (no modifications)..." else - echo "[INFO] Running clang-format on source files..." + log_info "Running clang-format on source files..." fi EXTENSIONS=("*.cpp" "*.hpp" "*.cc" "*.h" "*.cxx" "*.hxx") @@ -32,17 +37,17 @@ for ext in "${EXTENSIONS[@]}"; do for f in $FILES; do FOUND=true if $CHECK_MODE; then - echo "Checking $f" + log_step "Checking $f" clang-format --dry-run --Werror "$f" else - echo "Formatting $f" + log_step "Formatting $f" clang-format -i "$f" fi done done if ! $FOUND; then - echo "[INFO] No files found to format." + log_warn "No files found to format." else - echo "[INFO] clang-format complete." + log_info "clang-format complete." fi diff --git a/scripts/lint.sh b/scripts/lint.sh index af6c86f..e29c6c9 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -5,24 +5,33 @@ set -e # Run clang-tidy static analysis # # Runs clang-tidy over all C++ source files using compile_commands.json. +# When run outside the container, delegates execution to Docker automatically. # # Usage: # ./scripts/lint.sh ############################################################################### +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/env.sh" +source "$SCRIPT_DIR/docker/exec.sh" +delegate_to_container "$@" + +# --------------------------------------------------------------------------- +# Lint +# --------------------------------------------------------------------------- BUILD_DIR="build" TIDY_BIN=$(command -v clang-tidy || true) if [ -z "$TIDY_BIN" ]; then - echo "[ERROR] clang-tidy not found. Install it first." + log_error "clang-tidy not found. Install it first." exit 1 fi -echo "[INFO] Running clang-tidy over source files..." +log_info "Running clang-tidy over source files..." find src/ tests/ -type f \( -name '*.cpp' -o -name '*.cxx' -o -name '*.cc' \) | while read -r file; do - echo "[TIDY] $file" + log_step "$file" clang-tidy "$file" -p "$BUILD_DIR" || true done -echo "[INFO] clang-tidy lint complete." +log_info "clang-tidy lint complete." diff --git a/scripts/package.sh b/scripts/package.sh index b1304c3..ed97cd3 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -5,35 +5,38 @@ set -e # Package the project for distribution # # Builds in Release mode and creates distributable packages. +# When run outside the container, delegates execution to Docker automatically. # # Usage: # ./scripts/package.sh ############################################################################### -# Set build directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/env.sh" +source "$SCRIPT_DIR/docker/exec.sh" +delegate_to_container "$@" + +# --------------------------------------------------------------------------- +# Package +# --------------------------------------------------------------------------- BUILD_DIR="build" INSTALL_DIR="install" -echo "[INFO] Building project..." - -# Create and enter build directory mkdir -p "$BUILD_DIR" cd "$BUILD_DIR" -# Run CMake with Release build type by default (disable coverage for packaging) +log_step "Configuring CMake (Release)..." cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_COVERAGE=OFF -DCMAKE_INSTALL_PREFIX="../$INSTALL_DIR" .. -# 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 -echo "[INFO] Creating packages..." - -# Create packages using CPack +log_step "Creating packages with CPack..." cpack -echo "[INFO] Build and packaging complete!" -echo "[INFO] Packages created:" -ls -la *.tar.gz *.zip *.deb 2>/dev/null || echo "No packages found" +log_info "Build and packaging complete." +log_info "Packages created:" +ls -la *.tar.gz *.zip *.deb 2>/dev/null || log_warn "No packages found." From 02a4a75b2c4306e3e35543b3971a5d3dbedf3c4c Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Tue, 10 Mar 2026 23:34:09 -0700 Subject: [PATCH 03/14] fix: set Doxygen OUTPUT_DIRECTORY to docs/generated Doxygen was outputting html/ and latex/ at the project root because OUTPUT_DIRECTORY was empty. Set it to docs/generated/ to keep generated docs contained. Update .gitignore accordingly. --- .gitignore | 2 +- docs/Doxyfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 0feb08f..2fdb18f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,7 @@ build/ install/ # Doxygen -html/ +docs/generated/ # Testing Testing/ diff --git a/docs/Doxyfile b/docs/Doxyfile index 0a802cf..99281ac 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -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 From 19e1a833ee412ce26cf5489b3fb09bead1547e61 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Tue, 10 Mar 2026 23:56:25 -0700 Subject: [PATCH 04/14] fix: use absolute paths and portable path resolution in scripts - Add SCRIPT_DIR guard in env.sh to catch missing/invalid values - Replace GNU-specific realpath --relative-to with portable bash string substitution in docker/exec.sh - Add cd "$PROJECT_ROOT" to all scripts so they work from any working directory - Use absolute paths for CMAKE_INSTALL_PREFIX instead of relative ../ --- scripts/build.sh | 6 ++++-- scripts/coverage.sh | 4 +++- scripts/docker/exec.sh | 12 +++++++++--- scripts/docs.sh | 4 ++-- scripts/env.sh | 5 +++++ scripts/lint.sh | 2 ++ scripts/package.sh | 6 ++++-- 7 files changed, 29 insertions(+), 10 deletions(-) diff --git a/scripts/build.sh b/scripts/build.sh index 9f5d036..8e1d7f0 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -19,6 +19,8 @@ delegate_to_container "$@" # --------------------------------------------------------------------------- # Build # --------------------------------------------------------------------------- +cd "$PROJECT_ROOT" + BUILD_DIR="build" INSTALL_DIR="install" @@ -26,12 +28,12 @@ mkdir -p "$BUILD_DIR" cd "$BUILD_DIR" log_step "Configuring CMake (Debug)..." -cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX="../$INSTALL_DIR" .. +cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX="${PROJECT_ROOT}/$INSTALL_DIR" "$PROJECT_ROOT" log_step "Building with $(nproc) cores..." make -j"$(nproc)" -log_step "Installing to ../$INSTALL_DIR" +log_step "Installing to ${PROJECT_ROOT}/$INSTALL_DIR" make install log_info "Build complete." diff --git a/scripts/coverage.sh b/scripts/coverage.sh index ae488e7..8488071 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -19,6 +19,8 @@ delegate_to_container "$@" # --------------------------------------------------------------------------- # Coverage # --------------------------------------------------------------------------- +cd "$PROJECT_ROOT" + BUILD_DIR="build" INSTALL_DIR="install" @@ -26,7 +28,7 @@ mkdir -p "$BUILD_DIR" cd "$BUILD_DIR" log_step "Configuring CMake with coverage enabled..." -cmake -DCMAKE_BUILD_TYPE=Debug -DENABLE_COVERAGE=ON -DCMAKE_INSTALL_PREFIX="../$INSTALL_DIR" .. +cmake -DCMAKE_BUILD_TYPE=Debug -DENABLE_COVERAGE=ON -DCMAKE_INSTALL_PREFIX="${PROJECT_ROOT}/$INSTALL_DIR" "$PROJECT_ROOT" log_step "Building with $(nproc) cores..." make -j"$(nproc)" diff --git a/scripts/docker/exec.sh b/scripts/docker/exec.sh index 562c00a..864aec0 100644 --- a/scripts/docker/exec.sh +++ b/scripts/docker/exec.sh @@ -47,10 +47,16 @@ delegate_to_container() { return 0 fi - # Resolve the calling script path relative to project root + # Resolve the calling script path relative to project root (portable) local caller_script="${BASH_SOURCE[1]}" - local script_relative - script_relative="$(realpath --relative-to="$PROJECT_ROOT" "$caller_script")" + local script_absolute + script_absolute="$(cd "$(dirname "$caller_script")" && pwd)/$(basename "$caller_script")" + local script_relative="${script_absolute#"$PROJECT_ROOT"/}" + + if [ "$script_relative" = "$script_absolute" ]; then + log_error "Script '${caller_script}' is not under PROJECT_ROOT '${PROJECT_ROOT}'." + return 1 + fi log_docker "Running outside container -- delegating to Docker..." diff --git a/scripts/docs.sh b/scripts/docs.sh index 58e27a3..db80cde 100755 --- a/scripts/docs.sh +++ b/scripts/docs.sh @@ -19,11 +19,11 @@ delegate_to_container "$@" # --------------------------------------------------------------------------- # Documentation # --------------------------------------------------------------------------- -DOCS_DIR="${PROJECT_ROOT}/docs" +cd "$PROJECT_ROOT" log_info "Generating Doxygen documentation..." log_info "Doxygen version: $(doxygen --version)" -doxygen "$DOCS_DIR/Doxyfile" +doxygen docs/Doxyfile log_info "Documentation generation complete." diff --git a/scripts/env.sh b/scripts/env.sh index cd17d3f..d718a18 100644 --- a/scripts/env.sh +++ b/scripts/env.sh @@ -47,5 +47,10 @@ log_docker() { echo -e "${BLUE}[CONTAINER]${RESET} $*"; } # # SCRIPT_DIR must be set by the calling script before sourcing env.sh. # ============================================================================== +if [ -z "${SCRIPT_DIR:-}" ] || [ ! -d "$SCRIPT_DIR" ]; then + echo "[ERROR] SCRIPT_DIR is not set or does not exist. Set it before sourcing env.sh." >&2 + return 1 +fi + PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECT_NAME="$(basename "$PROJECT_ROOT")" diff --git a/scripts/lint.sh b/scripts/lint.sh index e29c6c9..62c499c 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -19,6 +19,8 @@ delegate_to_container "$@" # --------------------------------------------------------------------------- # Lint # --------------------------------------------------------------------------- +cd "$PROJECT_ROOT" + BUILD_DIR="build" TIDY_BIN=$(command -v clang-tidy || true) diff --git a/scripts/package.sh b/scripts/package.sh index ed97cd3..d17457a 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -19,6 +19,8 @@ delegate_to_container "$@" # --------------------------------------------------------------------------- # Package # --------------------------------------------------------------------------- +cd "$PROJECT_ROOT" + BUILD_DIR="build" INSTALL_DIR="install" @@ -26,12 +28,12 @@ mkdir -p "$BUILD_DIR" cd "$BUILD_DIR" log_step "Configuring CMake (Release)..." -cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_COVERAGE=OFF -DCMAKE_INSTALL_PREFIX="../$INSTALL_DIR" .. +cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_COVERAGE=OFF -DCMAKE_INSTALL_PREFIX="${PROJECT_ROOT}/$INSTALL_DIR" "$PROJECT_ROOT" log_step "Building with $(nproc) cores..." make -j"$(nproc)" -log_step "Installing to ../$INSTALL_DIR" +log_step "Installing to ${PROJECT_ROOT}/$INSTALL_DIR" make install log_step "Creating packages with CPack..." From af84b8bc2e1388f70c01ac01a25f0514ed8593cd Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Wed, 11 Mar 2026 09:18:44 -0700 Subject: [PATCH 05/14] fix: use printf for logging and improve SCRIPT_DIR validation in env.sh Switch logging helpers from echo -e to printf for portable escape handling. Route log_warn and log_error to stderr so pipelines stay clean. Split the SCRIPT_DIR guard into separate checks with descriptive error messages and wrap PROJECT_ROOT resolution in an error-checked subshell. --- scripts/env.sh | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/scripts/env.sh b/scripts/env.sh index d718a18..13aa984 100644 --- a/scripts/env.sh +++ b/scripts/env.sh @@ -35,22 +35,33 @@ fi # ============================================================================== # Logging helpers +# +# - printf is used instead of echo -e for portable escape handling +# - log_warn and log_error write to stderr so pipelines stay clean # ============================================================================== -log_info() { echo -e "${GREEN}[INFO]${RESET} $*"; } -log_warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } -log_error() { echo -e "${RED}[ERROR]${RESET} $*"; } -log_step() { echo -e "${CYAN}[STEP]${RESET} $*"; } -log_docker() { echo -e "${BLUE}[CONTAINER]${RESET} $*"; } +log_info() { printf '%b %s\n' "${GREEN}[INFO]${RESET}" "$*"; } +log_warn() { printf '%b %s\n' "${YELLOW}[WARN]${RESET}" "$*" >&2; } +log_error() { printf '%b %s\n' "${RED}[ERROR]${RESET}" "$*" >&2; } +log_step() { printf '%b %s\n' "${CYAN}[STEP]${RESET}" "$*"; } +log_docker() { printf '%b %s\n' "${BLUE}[CONTAINER]${RESET}" "$*"; } # ============================================================================== # Project paths # # SCRIPT_DIR must be set by the calling script before sourcing env.sh. # ============================================================================== -if [ -z "${SCRIPT_DIR:-}" ] || [ ! -d "$SCRIPT_DIR" ]; then - echo "[ERROR] SCRIPT_DIR is not set or does not exist. Set it before sourcing env.sh." >&2 +if [ -z "${SCRIPT_DIR:-}" ]; then + log_error "SCRIPT_DIR is not set. Please set SCRIPT_DIR before sourcing env.sh." + return 1 +fi + +if [ ! -d "$SCRIPT_DIR" ]; then + log_error "SCRIPT_DIR '$SCRIPT_DIR' does not exist or is not a directory." return 1 fi -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +if ! PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"; then + log_error "Failed to determine PROJECT_ROOT from SCRIPT_DIR '$SCRIPT_DIR'." + return 1 +fi PROJECT_NAME="$(basename "$PROJECT_ROOT")" From 0c73143cb21e20f1ea0ad71c582e6a4f45e8a9b2 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Wed, 11 Mar 2026 09:18:53 -0700 Subject: [PATCH 06/14] fix: use absolute paths in coverage.sh for invocation from any directory Anchor BUILD_DIR and INSTALL_DIR to PROJECT_ROOT so the script works correctly when invoked from outside the repository root. --- scripts/coverage.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 8488071..75b2bed 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -21,14 +21,14 @@ delegate_to_container "$@" # --------------------------------------------------------------------------- cd "$PROJECT_ROOT" -BUILD_DIR="build" -INSTALL_DIR="install" +BUILD_DIR="${PROJECT_ROOT}/build" +INSTALL_DIR="${PROJECT_ROOT}/install" mkdir -p "$BUILD_DIR" cd "$BUILD_DIR" log_step "Configuring CMake with coverage enabled..." -cmake -DCMAKE_BUILD_TYPE=Debug -DENABLE_COVERAGE=ON -DCMAKE_INSTALL_PREFIX="${PROJECT_ROOT}/$INSTALL_DIR" "$PROJECT_ROOT" +cmake -DCMAKE_BUILD_TYPE=Debug -DENABLE_COVERAGE=ON -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" "$PROJECT_ROOT" log_step "Building with $(nproc) cores..." make -j"$(nproc)" @@ -46,5 +46,5 @@ lcov --remove coverage.info '*/_deps/*' --output-file coverage.info --ignore-err genhtml coverage.info --output-directory coverage_report -log_info "Coverage report generated in build/coverage_report/" -log_info "Open build/coverage_report/index.html in your browser to view the report." +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." From 438b2d8fccd4124aff7feaafeec7183a8f9ee249 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Wed, 11 Mar 2026 09:19:01 -0700 Subject: [PATCH 07/14] fix: add Docker preflight checks in exec.sh before delegation Verify Docker is installed and the daemon is reachable before attempting container delegation, providing clear error messages on failure. --- scripts/docker/exec.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/docker/exec.sh b/scripts/docker/exec.sh index 864aec0..2162117 100644 --- a/scripts/docker/exec.sh +++ b/scripts/docker/exec.sh @@ -58,6 +58,17 @@ delegate_to_container() { return 1 fi + # Preflight: verify Docker is installed and the daemon is reachable + if ! command -v docker &> /dev/null; then + log_error "Docker is not installed. Please install Docker to use container delegation." + return 1 + fi + + if ! docker version &> /dev/null; then + log_error "Docker daemon is not reachable. Is the Docker service running?" + return 1 + fi + log_docker "Running outside container -- delegating to Docker..." # Build the image if it does not exist yet From afcefe325e95cda5937e119d6e73ae28f3b0b1d8 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Wed, 11 Mar 2026 10:54:39 -0700 Subject: [PATCH 08/14] fix: use single find with null-delimited iteration in format.sh Consolidate multiple per-extension find calls into a single invocation with grouped -name predicates. Use -print0 with read -d '' to safely handle filenames containing whitespace or special characters. --- scripts/format.sh | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/scripts/format.sh b/scripts/format.sh index 4679b48..05e0167 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -28,23 +28,20 @@ else log_info "Running clang-format on source files..." fi -EXTENSIONS=("*.cpp" "*.hpp" "*.cc" "*.h" "*.cxx" "*.hxx") FOUND=false -for ext in "${EXTENSIONS[@]}"; do - FILES=$(find "$PROJECT_ROOT/src" "$PROJECT_ROOT/tests" -type f -name "$ext" 2>/dev/null) - - for f in $FILES; do - FOUND=true - if $CHECK_MODE; then - log_step "Checking $f" - clang-format --dry-run --Werror "$f" - else - log_step "Formatting $f" - clang-format -i "$f" - fi - done -done +while IFS= read -r -d '' f; do + FOUND=true + if $CHECK_MODE; then + log_step "Checking $f" + clang-format --dry-run --Werror "$f" + else + log_step "Formatting $f" + clang-format -i "$f" + fi +done < <(find "$PROJECT_ROOT/src" "$PROJECT_ROOT/tests" -type f \ + \( -name '*.cpp' -o -name '*.hpp' -o -name '*.cc' -o -name '*.h' -o -name '*.cxx' -o -name '*.hxx' \) \ + -print0 2>/dev/null) if ! $FOUND; then log_warn "No files found to format." From eaa9ccd9aca1e53ff53b588ee56ee619f50b63f3 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Wed, 11 Mar 2026 11:00:53 -0700 Subject: [PATCH 09/14] fix: use absolute paths in lint.sh and remove redundant tool check Use absolute PROJECT_ROOT-based paths for src, tests, and the build directory. Remove the TIDY_BIN preflight check since container delegation guarantees clang-tidy is always available. --- scripts/build.sh | 8 ++++---- scripts/lint.sh | 10 ++-------- scripts/package.sh | 8 ++++---- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/scripts/build.sh b/scripts/build.sh index 8e1d7f0..dcca1c9 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -21,19 +21,19 @@ delegate_to_container "$@" # --------------------------------------------------------------------------- cd "$PROJECT_ROOT" -BUILD_DIR="build" -INSTALL_DIR="install" +BUILD_DIR="${PROJECT_ROOT}/build" +INSTALL_DIR="${PROJECT_ROOT}/install" mkdir -p "$BUILD_DIR" cd "$BUILD_DIR" log_step "Configuring CMake (Debug)..." -cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX="${PROJECT_ROOT}/$INSTALL_DIR" "$PROJECT_ROOT" +cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" "$PROJECT_ROOT" log_step "Building with $(nproc) cores..." make -j"$(nproc)" -log_step "Installing to ${PROJECT_ROOT}/$INSTALL_DIR" +log_step "Installing to $INSTALL_DIR" make install log_info "Build complete." diff --git a/scripts/lint.sh b/scripts/lint.sh index 62c499c..392a69f 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -21,17 +21,11 @@ delegate_to_container "$@" # --------------------------------------------------------------------------- cd "$PROJECT_ROOT" -BUILD_DIR="build" -TIDY_BIN=$(command -v clang-tidy || true) - -if [ -z "$TIDY_BIN" ]; then - log_error "clang-tidy not found. Install it first." - exit 1 -fi +BUILD_DIR="${PROJECT_ROOT}/build" log_info "Running clang-tidy over source files..." -find src/ tests/ -type f \( -name '*.cpp' -o -name '*.cxx' -o -name '*.cc' \) | while read -r file; do +find "${PROJECT_ROOT}/src" "${PROJECT_ROOT}/tests" -type f \( -name '*.cpp' -o -name '*.cxx' -o -name '*.cc' \) | while read -r file; do log_step "$file" clang-tidy "$file" -p "$BUILD_DIR" || true done diff --git a/scripts/package.sh b/scripts/package.sh index d17457a..19998c0 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -21,19 +21,19 @@ delegate_to_container "$@" # --------------------------------------------------------------------------- cd "$PROJECT_ROOT" -BUILD_DIR="build" -INSTALL_DIR="install" +BUILD_DIR="${PROJECT_ROOT}/build" +INSTALL_DIR="${PROJECT_ROOT}/install" mkdir -p "$BUILD_DIR" cd "$BUILD_DIR" log_step "Configuring CMake (Release)..." -cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_COVERAGE=OFF -DCMAKE_INSTALL_PREFIX="${PROJECT_ROOT}/$INSTALL_DIR" "$PROJECT_ROOT" +cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_COVERAGE=OFF -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" "$PROJECT_ROOT" log_step "Building with $(nproc) cores..." make -j"$(nproc)" -log_step "Installing to ${PROJECT_ROOT}/$INSTALL_DIR" +log_step "Installing to $INSTALL_DIR" make install log_step "Creating packages with CPack..." From 6784943bf3fb1ca00f5a94b07b939d813c25ddc5 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Wed, 11 Mar 2026 12:28:32 -0700 Subject: [PATCH 10/14] fix: add sourcing guard to env.sh Detect when env.sh is executed directly instead of sourced and exit with a clear error message, preventing confusing "return" errors. --- scripts/env.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/env.sh b/scripts/env.sh index 13aa984..9089cea 100644 --- a/scripts/env.sh +++ b/scripts/env.sh @@ -12,6 +12,13 @@ # source "$SCRIPT_DIR/env.sh" ############################################################################### +# Guard: this file must be sourced, not executed directly +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + echo "[ERROR] env.sh must be sourced, not executed directly." >&2 + echo "Usage: source \"$0\"" >&2 + exit 1 +fi + # ============================================================================== # Color definitions (disabled when output is not a terminal) # ============================================================================== From ef1484461fea52407f163a974511362051d2d0ed Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Wed, 11 Mar 2026 12:32:40 -0700 Subject: [PATCH 11/14] fix: propagate clang-tidy failures in lint.sh Track lint failures and exit non-zero when clang-tidy reports issues so CI and pre-push hooks can catch them. Also switch from piped while-loop to process substitution so the failure flag propagates correctly. --- scripts/lint.sh | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/lint.sh b/scripts/lint.sh index 392a69f..133080b 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -25,9 +25,18 @@ BUILD_DIR="${PROJECT_ROOT}/build" log_info "Running clang-tidy over source files..." -find "${PROJECT_ROOT}/src" "${PROJECT_ROOT}/tests" -type f \( -name '*.cpp' -o -name '*.cxx' -o -name '*.cc' \) | while read -r file; do +LINT_FAILED=0 + +while IFS= read -r file; do log_step "$file" - clang-tidy "$file" -p "$BUILD_DIR" || true -done + if ! clang-tidy "$file" -p "$BUILD_DIR"; then + LINT_FAILED=1 + fi +done < <(find "${PROJECT_ROOT}/src" "${PROJECT_ROOT}/tests" -type f \( -name '*.cpp' -o -name '*.cxx' -o -name '*.cc' \)) + +if [ "$LINT_FAILED" -ne 0 ]; then + log_error "clang-tidy found issues." + exit 1 +fi log_info "clang-tidy lint complete." From a7aed4869630cf52f3476adeb4762d3cfe63f08b Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Wed, 11 Mar 2026 12:42:33 -0700 Subject: [PATCH 12/14] fix: use delegation scripts in CI to fix path mismatch The CI workflow ran cmake directly on the host, producing compile_commands.json with host paths. When lint.sh delegated to Docker, clang-tidy could not find those paths inside the container. Fix by using ./scripts/build.sh (which delegates to Docker) instead of raw cmake commands, ensuring all steps share the same filesystem context. Add scripts/test.sh for consistent test execution via Docker delegation. Remove manual apt-get installs since tools come from the container image. --- .github/workflows/ci.yml | 6 ++--- CLAUDE.md | 49 ++++++++++++++++++++++++++++++++++++++++ scripts/test.sh | 34 ++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 CLAUDE.md create mode 100755 scripts/test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44ffab3..3d8f2fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,13 +5,11 @@ jobs: runs-on: ubuntu-24.04 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: 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 \ No newline at end of file + run: ./scripts/test.sh \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bf6c08c --- /dev/null +++ b/CLAUDE.md @@ -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, scripts run directly with no overhead. + +## Architecture + +This is a C++ project template using **CMake >= 3.20** and **C++17**. Each build target lives in its own `src//` 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_.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 diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..6ff4d0c --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -e + +############################################################################### +# Run project tests +# +# Runs ctest on the build directory with verbose output on failure. +# When run outside the container, delegates execution to Docker automatically. +# +# Usage: +# ./scripts/test.sh +############################################################################### + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/env.sh" +source "$SCRIPT_DIR/docker/exec.sh" +delegate_to_container "$@" + +# --------------------------------------------------------------------------- +# Test +# --------------------------------------------------------------------------- +BUILD_DIR="${PROJECT_ROOT}/build" + +if [ ! -d "$BUILD_DIR" ]; then + log_error "Build directory not found. Run ./scripts/build.sh first." + exit 1 +fi + +cd "$BUILD_DIR" + +log_step "Running tests..." +ctest --output-on-failure + +log_info "All tests passed." From 3eb176bd4dcddfd0ea3b15fa887712f557aeb03a Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Wed, 11 Mar 2026 13:11:31 -0700 Subject: [PATCH 13/14] fix: skip Docker delegation in CI and restore apt-get deps CI environments (GitHub Actions) set CI=true and provide their own toolchain. Skip container delegation in that case to avoid path mismatches between host-generated build artifacts and container paths. --- .github/workflows/ci.yml | 4 ++++ scripts/docker/exec.sh | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d8f2fa..cd15e2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ jobs: runs-on: ubuntu-24.04 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 diff --git a/scripts/docker/exec.sh b/scripts/docker/exec.sh index 2162117..374a266 100644 --- a/scripts/docker/exec.sh +++ b/scripts/docker/exec.sh @@ -47,6 +47,11 @@ delegate_to_container() { return 0 fi + # CI environments provide their own toolchain -- skip delegation + if [ "${CI:-}" = "true" ]; then + return 0 + fi + # Resolve the calling script path relative to project root (portable) local caller_script="${BASH_SOURCE[1]}" local script_absolute From fa11cb45fc4d24d411bb8c06cc49853029aeb901 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Wed, 11 Mar 2026 13:23:29 -0700 Subject: [PATCH 14/14] docs: add CI and container delegation guide with GHCR upgrade path Document the current CI approach (apt-get + CI=true skip), explain why Docker delegation is skipped in CI (path mismatch prevention), and provide step-by-step instructions for upgrading to GHCR container images as an alternative for production projects. --- CLAUDE.md | 2 +- docs/ci-container-delegation.md | 147 ++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 docs/ci-container-delegation.md diff --git a/CLAUDE.md b/CLAUDE.md index bf6c08c..735140a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 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, scripts run directly with no overhead. +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 diff --git a/docs/ci-container-delegation.md b/docs/ci-container-delegation.md new file mode 100644 index 0000000..ab379d7 --- /dev/null +++ b/docs/ci-container-delegation.md @@ -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)