# Continuous Integration Pipeline ## Overview The Pill CI (Continuous Integration) pipeline is built on **GitHub Actions** and runs in the [Pillware/Pill](https://github.com/Pillware/Pill) repository. It automatically validates every code change pushed to the repository - formatting, linting, native builds, WASM builds, binary size tracking, performance benchmarks, and full launcher action tests - to catch regressions before they reach users. ### What Problems It Solves Pill is a game engine with many moving parts: a launcher CLI, native and WASM build targets, example projects, and an asset pipeline. Manual testing of all combinations is error-prone and time-consuming. The CI pipeline ensures every contributor gets immediate feedback and the `main` branch stays releasable. | Problem | How CI Helps | | ------------------------- | --------------------------------------------------------------- | | Code style drift | `cargo fmt` enforces consistent formatting on every push | | Silent bugs | `cargo clippy -D warnings` treats all lints as errors | | Broken builds | Every example project is compiled in release mode | | WASM size bloat | Budget check fails if the `.wasm` binary exceeds 0.4999 MB | | Performance regressions | City benchmark runs headlessly, 3× per push | | Launcher regressions | Full action suite (create/build/run/docs/link) exercised weekly | | Environment inconsistency | Docker image provides identical build environment everywhere | ## High-Level Architecture ### Execution Flow ```mermaid flowchart TD PUSH[Git Push] --> IMAGE{CI Image
Changed?} IMAGE -->|Yes| BUILD_IMG[Build Docker Image] BUILD_IMG --> PUSH_GHCR[Push to GHCR] IMAGE -->|No| PARALLEL PUSH_GHCR --> PARALLEL PARALLEL[Fan-out: Parallel Jobs] --> FMT[1. Code Formatting] PARALLEL --> CLIPPY[2. Code Linting] PARALLEL --> NATIVE[3. Native Build + Size] PARALLEL --> WASM[4. WASM Build + Budget] PARALLEL --> PERF[5. Performance Benchmark] ``` Every push triggers two independent pipelines: 1. **`ci-build-image.yml`** - rebuilds the Docker CI image if the Dockerfile changed, pushes it to GitHub Container Registry (GHCR). 2. **`ci-basic-tests.yml`** - runs 5 fast tests in parallel. Each job pulls the pre-built Docker image (zero setup time), restores cached Rust build artifacts, and executes a test script. Two additional workflows run on schedules: 3. **`ci-examples-tests.yml`** (daily) - builds all 6 Pill example projects + 2 standalone crates. 4. **`ci-pill_launcher-tests.yml`** (daily) - exhaustive Pill Launcher action tests. ### Relationship Between Workflows and Scripts ``` .github/workflows/ci-basic-tests.yml └── runs: bash devops/tests/run_basic_tests.sh .github/workflows/ci-examples-tests.yml └── runs: bash devops/tests/run_examples_tests.sh .github/workflows/ci-pill_launcher-tests.yml └── runs: bash devops/tests/run_pill_launcher_tests.sh │ ├── source devops/common.sh (shared helpers) └── cd $PROJECT_ROOT (auto-discovered) ``` The YAML workflows define **when** and **where** tests run. The shell scripts define **what** gets tested and **how** results are reported. `common.sh` is the shared library sourced by every script - it provides binary discovery, project-root resolution, stale-file cleanup, size reporting, and colored pass/fail/skip output. ## Repository Structure ``` .github/ └── workflows/ ├── ci-basic-tests.yml # Fast checks on every push (5 parallel jobs) ├── ci-build-image.yml # Builds & pushes Docker CI image ├── ci-examples-tests.yml # Daily: build all examples └── ci-pill_launcher-tests.yml # Daily: full Pill Launcher action suite devops/ ├── common.sh # Shared library sourced by all test scripts ├── tests/ │ ├── Dockerfile # Debian Bookworm-based CI image definition │ ├── run_basic_tests.sh # 5 fast checks (fmt, clippy, builds, benchmark) │ ├── run_examples_tests.sh # Builds all example projects │ └── run_pill_launcher_tests.sh # Exhaustive launcher action tests └── utils/ └── generate_documentation.sh # Standalone rustdoc generation utility ``` ## Detailed Test Scripts Documentation ### `run_basic_tests.sh` | Property | Detail | | ----------------- | ------------------------------------------------------------------------------------- | | **Purpose** | Fast validation of code quality, build integrity, binary size, and performance | | **When executed** | Every push (`ci-basic-tests.yml`), or manually via CLI | | **Invocation** | `bash devops/tests/run_basic_tests.sh [all\|]` | | **Prerequisites** | Rust toolchain (cargo, rustfmt, clippy), wasm-pack, git, compiled PillLauncher binary | | **Exit codes** | 0 = all checks passed; 1 = one or more checks failed | **Outputs:** Colored console output with per-step PASS/FAIL/SKIP, binary size JSON reports, benchmark statistics JSON. #### Code Formatting Runs `cargo fmt --all` across the engine workspace, then uses `git diff` to detect any files that rustfmt would have changed. The diff excludes `engine/Cargo.toml` and example `Cargo.toml` files - the launcher rewrites workspace paths in these during builds (`NO_PATH` → absolute), so formatting differences there are not real issues. If `git diff` produces any output, the check fails with: ``` FAIL code formatting - rustfmt produced changes - run 'cargo fmt' ``` #### Code Linting Runs `cargo clippy --all --manifest-path engine/Cargo.toml -- -D warnings`. All clippy lints are treated as errors - even a single `clippy::needless_borrow` fails the check. This catches dead code, suspicious arithmetic, inefficient patterns, and dozens of other categories before they reach code review. If clippy exits non-zero, the first 300 characters of output are included in the failure message. #### Build Native Cube Example + Build Size Report Compiles `examples/cube` in release mode via `PillLauncher build -p examples/cube -c release --clean`. This validates: - The launcher can link a project into the engine workspace - All engine crates (`pill_core`, `pill_engine`, `pill_renderer`, `pill_runtime`, `pill_native`) compile together - The project compiles as a workspace member and links against engine DLLs - Artifacts are correctly staged to `build/release/data/` After a successful build, `print_size_report()` walks `build/release/data/` and emits a JSON snapshot of every file: ```json { "total_mb": 10.6995, "file_count": 4, "files": [ {"file": "pill_runtime.dll", "mb": 10.0322}, {"file": "project.dll", "mb": 0.3335}, {"file": "pill_engine.dll", "mb": 0.3102}, {"file": "pill_core.dll", "mb": 0.0236} ] } ``` Sizes are measured in mebibytes (MiB, $1\ \text{MiB} = 1\,048\,576\ \text{bytes}$) to 4 decimal places via `wc -c` + `awk`. The report is printed to the console log - a sudden jump (e.g. `pill_runtime.dll` from ~10 MB to ~15 MB) is immediately visible when reviewing a failed run. If the build fails (missing dependency, compile error), the check is skipped rather than failed - the build step's own failure already surfaces the problem. #### Build WASM Cube Example + WASM Size Report + WASM Size Guard Compiles `examples/cube` for WASM via `PillLauncher build -p examples/cube -t web -c release --clean`. This validates the full WASM pipeline: workspace preparation → cargo build with `wasm32-unknown-unknown` target → `wasm-pack build` → `wasm-bindgen` → `wasm-opt -Oz`. After the build, the script: 1. **Verifies the `.wasm` artifact** exists at `build/wasm/pill_web_app_bg.wasm` 2. **Measures its size** with `wc -c` and converts to MiB 3. **Enforces a hard budget** - the `.wasm` must not exceed **0.4990 MiB (523 239 bytes)** If the budget is exceeded: ``` FAIL WASM size budget - 0.5234 MB exceeds 0.4999 MB limit ``` This budget was chosen because the Pill WebGPU runtime, compiled with `opt-level = "z"` + `lto = "fat"` + `wasm-opt -Oz`, fits comfortably under 0.5 MiB. Exceeding it means something significant was added - a new dependency, a codegen regression, or a missing optimization - and needs investigation. The launcher also supports `--max-wasm-size ` (enforced in `wasm_target.rs`, release only) and `--wasm-analyze` (runs `twiggy` for per-function size breakdown). **4. Dev server smoke test:** The script starts `PillLauncher run -t web` in the background, waits up to 30 seconds for the server to bind on port 8080, and uses `curl` to verify three key files are served: - `/` (index.html) - `/pill_web_app.js` - `/pill_web_app_bg.wasm` If any file returns a non-2xx response or the server fails to start, the smoke test fails. This catches cases where the WASM build succeeds but the output is broken or incomplete. #### Performance Benchmark The performance benchmark measures frame-time consistency by running `examples/city` - a dense simulation with 10 000 citizens - through 5 000 frames (1 000 warmup, 4 000 measured). The compiled executable self-reports per-frame timing statistics as JSON and auto-exits after the last frame. **How it works:** 1. **Build once:** `PillLauncher build -p examples/city -c release --clean --headless --additional-features project/benchmark_headless` (or `project/benchmark_windowed` for windowed) 2. **Run 3 times:** The compiled executable is launched directly (bypassing PillLauncher) to avoid launcher overhead in the measurements 3. **Extract JSON:** Each run prints a JSON line with per-frame stats - the script parses `average_ms`, `median_ms`, `min_ms`, `max_ms`, `range_ms`, and `stddev_ms` 4. **Aggregate:** Across the 3 runs, the script computes min, max, and average for each statistic **Modes:** | Mode | Feature flag | When used | | -------- | ---------------------------- | ---------------------------------------------------------- | | Windowed | `project/benchmark_windowed` | Windows (always), Linux/macOS when `$DISPLAY` is available | | Headless | `project/benchmark_headless` | CI/Docker (no display), fallback if GPU init fails | The `--headless` launcher flag automatically enables `pill_native/headless`, `pill_runtime/headless`, and `pill_engine/headless` features, while `--additional-features` adds project-level features like `project/benchmark_headless`. The script auto-detects the environment: Windows uses windowed mode; Linux/macOS checks for `$DISPLAY` / `$WAYLAND_DISPLAY`, tries windowed first, then falls back to headless if the GPU is unavailable. **Output (per run):** ```json {"average_ms":1.800,"median_ms":1.700,"min_ms":1.500,"max_ms":2.500,"range_ms":1.000,"stddev_ms":0.200} ``` **Aggregated summary:** ```json { "mode": "headless", "runs": 3, "stats": { "average_ms": {"min": 1.700, "max": 1.900, "avg": 1.800}, "median_ms": {"min": 1.600, "max": 1.800, "avg": 1.700}, "min_ms": {"min": 1.400, "max": 1.600, "avg": 1.500}, "max_ms": {"min": 2.300, "max": 2.800, "avg": 2.500}, "range_ms": {"min": 0.900, "max": 1.200, "avg": 1.000}, "stddev_ms": {"min": 0.180, "max": 0.220, "avg": 0.200} } } ``` A run is considered passed if at least one of the three iterations completes successfully. --- ### `run_examples_tests.sh` | Property | Detail | | ----------------- | --------------------------------------------------------------------- | | **Purpose** | Verify all Pill example projects compile successfully in release mode | | **When executed** | Daily schedule (`ci-examples-tests.yml`), or manually | | **Invocation** | `bash devops/tests/run_examples_tests.sh [all\|]` | | **Prerequisites** | Rust toolchain, compiled PillLauncher binary | **Examples tested:** | Type | Projects | | ----------------- | --------------------------------------------------------------------------------------------------------------- | | Pill projects | `examples/cube`, `examples/floating_pills`, `examples/italian_brainrot`, `examples/city`, `examples/pill_tunel` | | Standalone crates | `examples/net_minimal/client`, `examples/net_minimal/server` | Each Pill project is built via `PillLauncher build -p -c release`. After each build, `print_size_report()` emits a binary size JSON for the `build/release/data/` directory - the same format as the native build check above. Standalone crates are built directly with `cargo build --release`. **Outputs:** Per-project build status (PASS/FAIL/SKIP), binary size JSON for each Pill project. --- ### `run_pill_launcher_tests.sh` | Property | Detail | | ----------------- | ------------------------------------------------------------------ | | **Purpose** | Exhaustively test every PillLauncher action and flag combination | | **When executed** | Daily schedule + manual dispatch (`ci-pill_launcher-tests.yml`) | | **Invocation** | `bash devops/tests/run_pill_launcher_tests.sh [all\|]` | | **Prerequisites** | Rust toolchain, wasm-pack, compiled PillLauncher binary | **Test groups:** | # | Group | What it validates | | --- | ----------- | --------------------------------------------------------------------------------------- | | 1 | Basics | `--help`, `--version`, no-args error, unknown subcommand, invalid flags | | 2 | Create | Scaffold a project, verify file structure, duplicate detection, missing `--name` error | | 3 | Build | Native debug/release/hot-reload, `--clean`, WASM, `--max-wasm-size`, short flags | | 4 | Cargo | Passthrough: `--version`, `check`, `fmt --check`, `clippy`, error on empty/bad commands | | 5 | Assets | Asset pipeline (incremental, `--clean`, short flag) | | 6 | Docs | `PillLauncher docs -o `, output directory verification | | 7 | Run | Native debug/release/hot-reload, `--clean`, WASM, `--wasm-port`, passthrough args | | 8 | Hot-reload | Start in background, touch source file, verify process survives | | 9 | Link/Unlink | Add/remove project from engine workspace members, idempotency | Tests use a temporary workspace under `$TMPDIR/pill-ci-tests-*`. Scaffolded projects are created, built, and discarded. Native run tests use `timeout 10s` to prevent hanging. Tests **skip rather than fail** when optional tooling is missing (WASM, PlantUML) - the CI Docker image has all tools pre-installed, so skips only occur in local development. **Outputs:** Detailed PASS/FAIL/SKIP per test case, summary with counts. --- ### `common.sh` (shared library) | Property | Detail | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Purpose** | Shared infrastructure sourced by all test scripts | | **Key functions** | `find_project_root`, `find_launcher`, `fix_stale_workspace_members`, `print_size_report`, `kill_server_on_port`, `report_pass`/`report_fail`/`report_skip`, `invoke_launcher`, `assert_ok`, `assert_fail`, `print_summary` | | **Key variables** | `$PROJECT_ROOT`, `$pill_launcher_bin`, `$test_workspace_root` | ## Docker Support Each GitHub Actions job runs in a fresh virtual machine. Installing Rust, system libraries (`libasound2`, `libudev`), Slang shader compiler, PlantUML, and wasm-pack takes 2–3 minutes per job. By pre-building all dependencies into a Docker image, setup time drops to ~5 seconds per job (just pulling cached layers). ### The CI Image Defined in `devops/tests/Dockerfile`: - **Base:** `rust:1.92-slim-bookworm` (Debian Bookworm, glibc) - **System packages:** `mold` (fast linker), `clang` (C linker), `libasound2-dev` (audio), `libudev-dev` (gamepad), `pkg-config`, `plantuml`, `curl`, `git`, `ca-certificates` - **Rust components:** `rustfmt`, `clippy`, `wasm32-unknown-unknown` target - **Tools:** `wasm-pack` (v0.13.1), Slang shader compiler (`slangc` v2025.21.2) **Entrypoint:** On container start, the entrypoint script: 1. Touches `.wgsl` shader files so `build.rs` skips HLSL→WGSL conversion 2. Fixes any absolute host paths in `engine/Cargo.toml` (left over from Windows builds) PillLauncher itself is pre-compiled into the image - see [Prebuilt Pill Launcher](#prebuilt-pill-launcher) below. The image is rebuilt automatically when the Dockerfile changes and pushed to `ghcr.io/pillware/pill-ci:latest`. ## Prebuilt Pill Launcher The CI Docker image uses a **multi-stage build** that compiles PillLauncher during `docker build` and bakes the binary directly into the image. This eliminates the ~20-second launcher compilation step that used to run on every container start. **How it works:** 1. **Stage 1 (builder):** Copies the `engine/` source into a Rust container, runs `cargo build --release` for `pill_launcher`, and produces the binary at a known path. 2. **Stage 2 (runtime):** Copies only the compiled `PillLauncher` binary from Stage 1 into `/usr/local/bin/PillLauncher` and sets `ENV PILL_LAUNCHER_BIN=/usr/local/bin/PillLauncher`. 3. **At container start:** The entrypoint script no longer builds anything - it only touches WGSL shaders and fixes stale Cargo.toml paths (both instant operations). 4. **Test scripts:** `common.sh` reads `$PILL_LAUNCHER_BIN` first, so all test scripts discover the pre-built binary immediately with zero setup time. **When the image rebuilds:** The `ci-build-image.yml` workflow triggers whenever the Dockerfile, the PillLauncher source (`engine/pill_launcher/**`), or the workflow itself changes. This ensures the baked-in binary is always up to date with the latest launcher code. **Result:** Container startup went from ~20s (launcher build) to <1s (path fixup only). Tests begin executing immediately after the image is pulled. ## Running the Pipeline Locally ### Windows **Prerequisites:** - Git Bash (included with Git for Windows) - Rust toolchain: `https://rustup.rs` - wasm-pack: `cargo install wasm-pack` - Python 3 (for `http.server` - optional, dev server smoke test tries PillLauncher first) **Setup:** ```bash # Clone and enter repo git clone https://github.com/Pillware/Pill cd Pill-Engine # Build the launcher (required by all test scripts) cargo build --release --manifest-path engine/pill_launcher/Cargo.toml ``` **Run all fast checks:** ```bash cd devops bash tests/run_basic_tests.sh ``` **Run a single check:** ```bash bash tests/run_basic_tests.sh build_wasm_cube_example ``` **Build all examples:** ```bash bash tests/run_examples_tests.sh ``` **Expected output:** Colored PASS/FAIL/SKIP lines, ending with a summary table and exit code (0 = success). **Common issues:** - `PillLauncher binary not found` → Build it with the cargo command above - `cargo metadata` errors → Run `cargo fmt` once to fix stale workspace paths, or let `common.sh` auto-fix them - Port 8080 in use → `taskkill /F /IM PillLauncher.exe` ### Linux **Prerequisites:** ```bash sudo apt-get install -y pkg-config libasound2-dev libudev-dev plantuml mold clang curl # Install Rust: https://rustup.rs cargo install wasm-pack ``` **Setup & run:** ```bash git clone https://github.com/Pillware/Pill cd Pill-Engine # Build the launcher (required by all test scripts) cargo build --release --manifest-path engine/pill_launcher/Cargo.toml # Run tests from devops directory bash devops/tests/run_basic_tests.sh ``` **Headless benchmarking:** The performance benchmark auto-detects missing `$DISPLAY` and `$WAYLAND_DISPLAY`, then uses `--headless` mode. No xvfb needed. ### macOS The pipeline is **not officially tested on macOS**. The test scripts should work with Homebrew-installed dependencies (`pkg-config`, `alsa-lib` may not be available). Native builds may fail due to missing Linux-specific libraries. Docker is the recommended approach for macOS. ### Running in Docker The Docker image provides an identical environment to CI, ensuring reproducible results. **Linux:** ```bash docker run --rm -v "$PWD:/src" -w /src \ ghcr.io/pillware/pill-ci:latest \ bash devops/tests/run_basic_tests.sh ``` **Windows (PowerShell):** ```powershell docker run --rm -v "${PWD}:/src" -w /src ` ghcr.io/pillware/pill-ci:latest ` bash devops/tests/run_basic_tests.sh ``` **Windows (CMD):** ```cmd docker run --rm -v "%cd%:/src" -w /src ghcr.io/pillware/pill-ci:latest bash devops/tests/run_basic_tests.sh ``` **macOS:** ```bash docker run --rm -v "$PWD:/src" -w /src \ ghcr.io/pillware/pill-ci:latest \ bash devops/tests/run_basic_tests.sh ``` **What each flag does:** - `--rm` - remove container after exit - `-v "$PWD:/src"` - mount current directory as `/src` inside container - `-w /src` - set working directory - `ghcr.io/pillware/pill-ci:latest` - pre-built CI image with PillLauncher already compiled - Remaining args - the test script to run **Building the image locally (optional):** ```bash # Linux / macOS / Git Bash docker build -t pill-ci -f devops/tests/Dockerfile . docker run --rm -v "$PWD:/src" -w /src pill-ci bash devops/tests/run_basic_tests.sh ``` ```powershell # Windows PowerShell docker build -t pill-ci -f devops/tests/Dockerfile . docker run --rm -v "${PWD}:/src" -w /src pill-ci bash devops/tests/run_basic_tests.sh ``` ```cmd REM Windows CMD docker build -t pill-ci -f devops/tests/Dockerfile . docker run --rm -v "%cd%:/src" -w /src pill-ci bash devops/tests/run_basic_tests.sh ``` **Note for Windows users:** PillLauncher is pre-compiled into the image at `/usr/local/bin/PillLauncher` - no runtime compilation occurs. The previous "Text file busy" issue (caused by building the launcher on a Windows-mounted volume) is eliminated. ## Understanding the Results ### Console Output Every test result is printed as: ``` PASS FAIL - SKIP - ``` - **PASS** - the check succeeded - **FAIL** - the check failed; the reason explains what went wrong - **SKIP** - the check could not run (missing dependency, tool not installed, etc.) After all checks, a summary table is printed: ``` ======================================== (1/8) PASS - code formatting (2/8) PASS - code linting ... ======================================== Results: 6 passed, 1 failed, 1 skipped (8 total) ======================================== ``` ### Binary Size Reports Build steps output JSON reports like: ```json { "total_mb": 10.6995, "file_count": 4, "files": [ {"file": "pill_runtime.dll", "mb": 10.0322}, {"file": "project.dll", "mb": 0.3335} ] } ``` ### Benchmark Output The performance benchmark produces per-run statistics: ```json { "mode": "headless", "runs": 3, "stats": { "average_ms": {"min": 1.700, "max": 1.900, "avg": 1.800}, ... } } ``` ### Exit Codes | Code | Meaning | | ---- | ------------------------- | | 0 | All checks passed | | 1 | One or more checks failed | ## GitHub Actions Integration ### Workflows | Workflow | Trigger | Container | What it does | | ---------------------------- | ------------------------------- | ---------------------------- | ------------------------------- | | `ci-basic-tests.yml` | Every push | `ghcr.io/.../pill-ci:latest` | 5 fast checks in parallel | | `ci-build-image.yml` | Dockerfile changes, manual | None (builds image) | Builds & pushes CI Docker image | | `ci-examples-tests.yml` | Daily 03:30 UTC, manual | `ghcr.io/.../pill-ci:latest` | Builds all 8 examples | | `ci-pill_launcher-tests.yml` | Weekly Sunday 04:00 UTC, manual | `ghcr.io/.../pill-ci:latest` | Full launcher action suite | ### How They Invoke Test Scripts All workflows follow the same pattern: 1. Checkout code 2. Download pre-built PillLauncher artifact to `engine/pill_launcher/target/release/` 3. Set `PILL_LAUNCHER_BIN` implicitly via the artifact path (which `common.sh` discovers) 4. Run: `bash devops/tests/