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
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,62 @@ jobs:
- name: clang-tidy
run: cmake --build build --target run-clang-tidy

# -------- Release contract (Linux only) --------
#
# Every other job builds `Dev`, so the suite's `#ifdef NDEBUG` bodies compile to nothing: the
# cases run, pass, and assert precisely nothing. This job is the only place they are real code.
# Linux alone, because what they assert — what a writer returns once NDEBUG removes its
# assertion — does not vary by platform.
#
# It runs the SHARED stage body rather than restating the commands, so the Docker replica
# (`tools/ci/run-local-ci.sh release-contract`) and this job cannot drift.

release-contract:
name: release contract (Linux)
runs-on: ubuntu-latest
env:
VCPKG_ROOT: ${{ github.workspace }}/vcpkg
steps:
- uses: actions/checkout@v4

- name: Install system packages
run: |
sudo apt-get update
# See the clang-tidy job above for why autotools are here: glfw3 3.5.1's `pthread-stubs`
# dependency is autotools-only on Linux.
sudo apt-get install -y ninja-build cmake libvulkan-dev \
xorg-dev libxinerama-dev libxcursor-dev libglu1-mesa-dev pkg-config \
autoconf autoconf-archive automake libtool

- name: Checkout vcpkg
uses: actions/checkout@v4
with:
repository: microsoft/vcpkg
path: vcpkg

- name: Bootstrap vcpkg
run: ./vcpkg/bootstrap-vcpkg.sh

# The same cache path as the Dev jobs, which is the point: the vcpkg-release preset points
# VCPKG_INSTALLED_DIR back at build/vcpkg_installed, so this job reuses those ports instead
# of building a second copy of every one of them under build-release/.
- name: Cache vcpkg packages
uses: actions/cache@v4
with:
path: |
build/vcpkg_installed
vcpkg/downloads
vcpkg/buildtrees
vcpkg/packages
key: ${{ runner.os }}-vcpkg-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }}
restore-keys: |
${{ runner.os }}-vcpkg-

- name: Release contract
run: |
. tools/ci/ci-stages.sh
ci_release_contract

# -------- Per-platform build + test (each validates its own determinism golden) --------

build-test-linux:
Expand Down
49 changes: 37 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,23 +177,48 @@ Reference class: `include/fire_engine/graphics/image.hpp`.

Catch2 (v3, `Catch2::Catch2WithMain`). Single binary `test_fire_engine`. CTest registers
`test_fire_engine` as the fast `~[slow]` entry, so plain `ctest` and `ctest --preset fast`
stay fast. The `tests-full` target runs the all-tags Catch2 binary plus the four build-time guards —
graphics-layer includes, shared shader blocks, the shadow bias law, and the shared GPU limits; from
the source root use `cmake --build --preset full`. Test files mirror
stay fast. The `tests-full` target runs the all-tags Catch2 binary plus the six build-time guards —
graphics-layer includes, shared shader blocks, the shadow bias law, the shared GPU limits, the
per-view shadow matrix, and the `[release-contract]` tags; from the source root use
`cmake --build --preset full`. Test files mirror
source paths. Shared helpers/traits live in `tests/support/`. Test assets in `tests/assets/`
→ copied to `build/test_assets/`.

Graphics-layer tests run without a GPU (opaque handles).

**CI has four parallel jobs** (GitHub Actions, all `FIRE_ENGINE_WARNINGS_AS_ERRORS=ON`): `clang-format`
+ `clang-tidy` (platform-independent lint, run once on Ubuntu) and `build-test-linux` +
`build-test-macos` (build + `tests-full` on Ubuntu and macOS/arm64). Each build job validates *its*
platform's determinism golden. The build/test/lint stage bodies are shared across the Docker replica
and the native macOS replica via `tools/ci/ci-stages.sh` — edit stages there, not in each script.

**Local CI parity.** `tools/ci/run-local-ci.sh [format|configure|build|tidy|test|all|shell]`
reproduces the **Linux** checks (Ubuntu 24.04) in Docker; `tools/ci/run-local-macos.sh
[format|configure|build|tidy|test|all]` runs the same stages **natively on macOS** (no container —
**CI has five parallel jobs** (GitHub Actions, all `FIRE_ENGINE_WARNINGS_AS_ERRORS=ON`): `clang-format`
+ `clang-tidy` (platform-independent lint, run once on Ubuntu), `build-test-linux` +
`build-test-macos` (build + `tests-full` on Ubuntu and macOS/arm64), and `release-contract` (below).
Each build job validates *its* platform's determinism golden. The build/test/lint stage bodies are
shared across the Docker replica and the native macOS replica via `tools/ci/ci-stages.sh` — edit
stages there, not in each script, and the GitHub `release-contract` job sources that file rather
than restating its commands.

**A test whose body is `#ifdef NDEBUG` is tagged `[release-contract]`.** Every preset here builds
`Dev`, so those bodies — the release half of a writer that asserts in Dev and returns `false` under
`NDEBUG` — compile to nothing: the cases run, pass, and assert nothing at all. The Linux-only
`release-contract` job is the only place they are real code (`cmake --preset vcpkg-release`, build
`test_fire_engine`, run `test_fire_engine "[release-contract]"`; locally,
`tools/ci/run-local-ci.sh release-contract`). Two things keep the job honest, and both must survive
any edit to it: Catch2 exits non-zero when a spec matches nothing, so a renamed tag fails rather
than passing quietly; and `tests/release_contract.cpp` holds a HIDDEN (`[.]`) sentinel case in the
same selection that fails unless `NDEBUG` is defined, so the job cannot pass by building `Dev` by
mistake. The `[.]` is what keeps it out of the Dev suites — `ctest` (`~[slow]`) and `tests-full` (no
filter) are both default runs, which exclude hidden cases, while an explicit tag selection includes
them. **Only tag a case that actually has an `#ifdef NDEBUG` in it**: the tag means "this asserts
something a Dev build cannot see". Both halves of that rule are enforced by the
`release_contract_guard` CTest case (`cmake/check_release_contract.cmake`), which fails on a
conditional case that is untagged — the silent failure, since nothing else would ever run it as real
code — on a tagged case with no conditional, on a missing sentinel, and on the tag vanishing
altogether; so the convention no longer depends on the next person knowing it. The `vcpkg-release`
preset builds into `build-release/` and points `VCPKG_INSTALLED_DIR` back at
`build/vcpkg_installed`, so it reuses the ports the Dev tree and the CI cache already hold instead
of building a second copy of every one.

**Local CI parity.** `tools/ci/run-local-ci.sh [format|configure|build|tidy|test|release-contract|all|shell]`
reproduces the **Linux** checks (Ubuntu 24.04) in Docker — its `all` includes the Release contract;
`tools/ci/run-local-macos.sh [format|configure|build|tidy|test|all]` runs the same stages
**natively on macOS**, minus that one (it is platform-independent, so one job proves it) (no container —
uses your existing vcpkg + C++ toolchain, installs nothing; Vulkan/GLFW/glslc all come from vcpkg). The Docker runner copies the
working tree into volumes (host artifacts untouched), defaults to `linux/amd64` to match CI
(`DOCKER_PLATFORM=linux/arm64` is faster but off-platform). Run the relevant one before committing
Expand Down
15 changes: 15 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,9 @@ endif()
enable_testing()

add_executable(test_fire_engine
# The build-configuration sentinel for the [release-contract] gate — it mirrors no source
# file, because its subject is how this binary was compiled.
tests/release_contract.cpp
tests/collision/test_aabb.cpp
tests/collision/test_geometry.cpp
tests/collision/test_sweep_and_prune_broad_phase.cpp
Expand Down Expand Up @@ -527,6 +530,7 @@ add_custom_target(tests-full
COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure -R shadow_bias_guard
COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure -R gpu_limits_guard
COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure -R shadow_matrix_guard
COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure -R release_contract_guard
DEPENDS test_fire_engine
COMMENT "Running the full test suite, including [slow] tests"
)
Expand Down Expand Up @@ -582,6 +586,17 @@ add_test(
-P ${PROJECT_SOURCE_DIR}/cmake/check_shadow_matrix.cmake
)

# Every NDEBUG-conditional test case carries `[release-contract]`, which is the only thing that
# selects it. An untagged one is not a weaker check but no check at all — every preset here builds
# Dev, so its guarded body compiles to nothing and it passes each suite while asserting nothing —
# and forgetting the tag is silent by construction, which is the gap this closes.
add_test(
NAME release_contract_guard
COMMAND ${CMAKE_COMMAND}
-DTESTS_DIR=${PROJECT_SOURCE_DIR}/tests
-P ${PROJECT_SOURCE_DIR}/cmake/check_release_contract.cmake
)

find_program(CLANG_TIDY_EXE NAMES clang-tidy)
if(CLANG_TIDY_EXE)
get_target_property(FIRE_ENGINE_TIDY_SOURCES fireengine SOURCES)
Expand Down
18 changes: 18 additions & 0 deletions CMakePresets.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
"CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake"
}
},
{
"name": "vcpkg-release",
"inherits": "vcpkg",
"displayName": "Release — the [release-contract] gate",
"description": "A genuine Release build (NDEBUG defined), which is the only configuration in which the suite's #ifdef NDEBUG bodies are real code. VCPKG_INSTALLED_DIR deliberately points back at build/vcpkg_installed: vcpkg would otherwise install a second copy of every port under build-release/, which no CI cache populates and which takes as long as the ports themselves. Its own binaryDir so it never disturbs the Dev tree that ctest, tests-full and compile_commands.json all live in.",
"binaryDir": "${sourceDir}/build-release",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"VCPKG_INSTALLED_DIR": "${sourceDir}/build/vcpkg_installed"
}
}
],
"buildPresets": [
Expand All @@ -23,6 +34,13 @@
"targets": [
"tests-full"
]
},
{
"name": "release-contract",
"configurePreset": "vcpkg-release",
"targets": [
"test_fire_engine"
]
}
],
"testPresets": [
Expand Down
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,9 @@ Or from the build directory:
ctest --test-dir build --output-on-failure
```

Run the full Catch2 suite, including `[slow]` tests, plus the graphics-layer include guard:
Run the full Catch2 suite, including `[slow]` tests, plus the build-time guards (graphics-layer
includes, shared shader blocks, the shadow bias law, the shared GPU limits, the per-view shadow
matrix, and the `[release-contract]` tags):

```bash
cmake --build --preset full
Expand Down Expand Up @@ -340,13 +342,19 @@ cmake --build build --target run-clang-tidy # if clang-tidy is installed
`cmake --build build --target run-clang-tidy --parallel <jobs>` or
`CMAKE_BUILD_PARALLEL_LEVEL` to cap local CPU/memory use.

CI (GitHub Actions, all `FIRE_ENGINE_WARNINGS_AS_ERRORS=ON`) runs four parallel jobs:
CI (GitHub Actions, all `FIRE_ENGINE_WARNINGS_AS_ERRORS=ON`) runs five parallel jobs:

- **`clang-format`** and **`clang-tidy`** — platform-independent lint gates, run once (Ubuntu).
- **`build-test-linux`** — build + `tests-full` on Ubuntu (validates the **Linux/x86_64**
determinism golden).
- **`build-test-macos`** — build + `tests-full` on macOS/arm64 (validates the **macOS/arm64**
golden). Like Linux, it gets Vulkan + GLFW + `glslc` from vcpkg — the runner only adds `ninja`.
- **`release-contract`** — the only job that builds `Release`. Every other job builds `Dev`, so the
suite's `#ifdef NDEBUG` bodies (what a writer returns once its assertion is compiled away)
vanish; this one builds `test_fire_engine` from the `vcpkg-release` preset and runs
`test_fire_engine "[release-contract]"`. Ubuntu only — that behaviour does not vary by platform —
and deliberately not the whole Release suite, which would drag in the optimisation-sensitive
physics goldens.

Each platform's `Determinism.GoldenHash` golden is now enforced by its own job — see
[`docs/collision.md`](docs/collision.md) and CLAUDE.md § Testing.
Expand All @@ -360,7 +368,8 @@ tools/ci/run-local-macos.sh all # macOS, native (no container)

The **Docker** runner copies the working tree into an Ubuntu 24.04 container, keeps Linux
build/vcpkg state in Docker volumes, and accepts `format`, `configure`, `build`, `tidy`, `test`,
`all`, or `shell` to isolate a stage. It defaults to `linux/amd64` to match GitHub Actions; set
`release-contract`, `all`, or `shell` to isolate a stage (`all` includes the Release contract on
Linux; the macOS runner has no such stage, by design). It defaults to `linux/amd64` to match GitHub Actions; set
`DOCKER_PLATFORM=linux/arm64` for a faster native Apple Silicon check. The **native macOS** runner
takes the same stages and runs them directly on your host toolchain (it installs nothing — Vulkan,
GLFW, and `glslc` all come from vcpkg, so it just needs your existing vcpkg + compiler + `ninja`).
Expand Down
140 changes: 140 additions & 0 deletions cmake/check_release_contract.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Guard: a test case whose body is conditional on NDEBUG carries the `[release-contract]` tag, and
# nothing else does.
#
# The tag is what the Linux `release-contract` job selects, and it is the ONLY thing that selects
# it. An untagged `#ifdef NDEBUG` case is therefore not a weaker check, it is no check at all: every
# preset here builds `Dev`, so its guarded body compiles to nothing in every suite that runs it, and
# the case passes everywhere while asserting nothing anywhere. That is precisely the failure the tag
# was introduced to end, and it comes back silently the first time someone adds a conditional case
# without knowing the convention — which is the normal case, since the convention is invisible at
# the point where it matters.
#
# The other direction is checked too, and is not merely tidiness. `[release-contract]` means "this
# case asserts something a Dev build cannot see". A case carrying the tag without a conditional body
# runs identically in both configurations, so it pads the job's selection with work every other
# suite already covers and makes a green Release run look broader than it is.
#
# COMMENTS ARE STRIPPED FIRST, both forms. This file's subject is `#ifdef NDEBUG`, and so is the
# prose of nearly every case it guards: an unstripped scan is satisfied by a case that merely talks
# about release behaviour and — worse — by one whose real conditional has been commented out.
#
# Invoked as a CTest case; needs TESTS_DIR.

if(NOT DEFINED TESTS_DIR)
message(FATAL_ERROR "TESTS_DIR must be set (path to tests/)")
endif()

include("${CMAKE_CURRENT_LIST_DIR}/strip_glsl_comments.cmake")

set(offenders "")
set(tag "[release-contract]")
set(sentinel_file "release_contract.cpp")
set(sentinel_seen FALSE)
set(tagged_count 0)

file(GLOB_RECURSE test_sources "${TESTS_DIR}/*.cpp")
if(NOT test_sources)
message(FATAL_ERROR
"release contract guard: no .cpp files under ${TESTS_DIR} — the sweep found nothing to check, which is not a pass")
endif()

foreach(source IN LISTS test_sources)
file(RELATIVE_PATH relative "${TESTS_DIR}" "${source}")
file(READ "${source}" raw)
strip_glsl_comments("${raw}" code)

# Split the file into cases. CMake regexes cannot report a match POSITION, so the macro name
# becomes the list separator and the text is split on it: element 1 is whatever precedes the first
# case (includes, helpers) and is dropped; every later element is one case — its argument list
# followed by its body, up to the start of the next case.
#
# Semicolons and backslashes go first, and both matter: `;` is CMake's list separator, so a case
# containing one would be scanned in fragments; a trailing `\` escapes the separator itself.
# Neither character can join two identifiers, so replacing them with a space cannot manufacture or
# destroy a match below.
string(REPLACE "\\" " " scan "${code}")
string(REPLACE ";" " " scan "${scan}")
string(REPLACE "TEST_CASE(" ";" scan "${scan}")

set(index 0)
foreach(case IN LISTS scan)
math(EXPR index "${index} + 1")
if(index EQUAL 1)
continue()
endif()

# Argument list and body are separated at the first line that STARTS with `{`, not at the first
# `)`: 36 case names in this suite contain a parenthesis and one contains a brace, so a
# punctuation-based split would read part of a name as body (and then miss its tag). Allman
# braces make the line-initial `{` reliable, and they are themselves CI-gated by clang-format.
string(FIND "${case}" "\n{" brace)
if(brace LESS 0)
list(APPEND offenders
"${relative}: a TEST_CASE( has no body brace on its own line — the guard cannot tell its tags from its body")
continue()
endif()
string(SUBSTRING "${case}" 0 ${brace} arguments)
string(LENGTH "${case}" case_length)
math(EXPR body_start "${brace} + 2")
math(EXPR body_length "${case_length} - ${body_start}")
string(SUBSTRING "${case}" ${body_start} ${body_length} body)

# The case's NAME, for the report — the first string literal, by Catch2's signature.
set(name "<unnamed>")
if(arguments MATCHES "\"([^\"]*)\"")
set(name "${CMAKE_MATCH_1}")
endif()

string(FIND "${arguments}" "${tag}" tag_position)
if(tag_position GREATER_EQUAL 0)
set(tagged TRUE)
math(EXPR tagged_count "${tagged_count} + 1")
else()
set(tagged FALSE)
endif()

# A preprocessor conditional ON NDEBUG, in any spelling. `#if !defined(NDEBUG)` counts: what
# matters is that the case's behaviour differs between the two configurations, not which half is
# which.
if(body MATCHES "#[ \t]*(ifdef|ifndef|if)[^\n]*NDEBUG")
set(conditional TRUE)
else()
set(conditional FALSE)
endif()

if(conditional AND NOT tagged)
list(APPEND offenders
"${relative}: \"${name}\" has an NDEBUG-conditional body but is not tagged ${tag} — nothing builds it as real code, so it asserts nothing in any configuration")
elseif(tagged AND NOT conditional)
list(APPEND offenders
"${relative}: \"${name}\" is tagged ${tag} but has no NDEBUG conditional — the tag means 'this asserts something a Dev build cannot see', and every other suite already covers this case")
endif()

# The sentinel: hidden, tagged, conditional. Its `[.]` is what keeps it out of the Dev suites,
# and removing the `[.]` fails them loudly — but removing the CASE is silent, and leaves the job
# unable to tell a Release build from a Dev one again.
if(relative STREQUAL "${sentinel_file}" AND tagged AND conditional AND arguments MATCHES "\\[\\.\\]")
set(sentinel_seen TRUE)
endif()
endforeach()
endforeach()

if(NOT sentinel_seen)
list(APPEND offenders
"tests/${sentinel_file} has no hidden ([.]) ${tag} case with an NDEBUG conditional — that case is what fails the job when the binary was not built with NDEBUG, and without it a Dev build passes the selection silently")
endif()

# A selection of nothing is not a green run. Catch2 catches that at job time (it exits non-zero when
# a spec matches no tests), but this fails in the build that removed the last tag, where the cause is
# still in front of whoever caused it.
if(tagged_count EQUAL 0)
list(APPEND offenders "no test case carries ${tag} — the Linux release-contract job now selects nothing")
endif()

if(offenders)
string(REPLACE ";" "\n " report "${offenders}")
message(FATAL_ERROR "release contract guard failed:\n ${report}")
endif()

message(STATUS
"release contract guard: ${tagged_count} case(s) tagged ${tag}, each with an NDEBUG-conditional body, plus the hidden sentinel")
Loading
Loading