From 9ea0eed74030e5be09cd62e5d7c0ae0c4f9950df Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:46:53 +0700 Subject: [PATCH] Install it on a machine that has nothing, nightly CI runs on a machine that has everything: a compiler, a linker, a CMake an action put there, a Rust toolchain, and whatever the last job left in a cache. The install job it already had installs into a prefix and builds cmake/consumer against it, which is worth having and is still not what a user does, because the whole thing happens inside a checkout on a runner where every tool is already present. So this installs it the way a person with nothing gets it, in a container holding a compiler and nothing else, every night rather than on every push, because what breaks an install is usually somebody else's release and not a commit here. Three rows. A gcc image given cmake, which is most of the people who would install this. A bare debian given a compiler and cmake, which is the row where the absent list means something because every tool on it had to be named. And a bare debian with no cmake on it at all, which is the only honest way to check the claim the README makes for a project that would rather not use CMake: the wrapper is header only, so it needs the include path and nothing else. What each row does is install the wrapper, take both programs off the README, build them against the install, run each in a directory of its own since both write social.zu1 beside themselves, and diff what they print against the block under them on the page. The C program is there because it needs nothing out of this repository, which is the point: it says the SDK a user was handed is enough on its own. The image is checked rather than believed. The day a base image starts shipping a Rust toolchain is the day this job quietly stops being about anything, so the absent list is asserted, and so is the absence of a zu.h, a zu.hpp, a libzu and a pkg-config entry for one. And it is run again with a piece taken out of the install, which is the half a job like this usually leaves out. A job that only ever passes is a job nobody has seen fail. Take the header out and the build has to stop; take the CMake package out and find_package has to stop; take the engine out and the link has to stop. If any of them still worked, the compiler found something on the image and every pass meant nothing. The engine arrives as an SDK laid out on disk, since there is no release yet, in the layout ZU_ROOT documents and a release archive unpacks to. Checked on a Linux box with docker: three rows green, and all eight break runs failing, each for the reason it was supposed to. The header one fails at "#include ", which is the message a user would get. --- .github/workflows/install.yml | 151 ++++++++++++++++++++++ README.md | 2 + scripts/install.sh | 227 ++++++++++++++++++++++++++++++++++ 3 files changed, 380 insertions(+) create mode 100644 .github/workflows/install.yml create mode 100755 scripts/install.sh diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml new file mode 100644 index 0000000..986acd8 --- /dev/null +++ b/.github/workflows/install.yml @@ -0,0 +1,151 @@ +name: Install + +# What a person with nothing on their machine gets. +# +# CI runs on a machine that has everything: a compiler, a linker, CMake +# that lukka/get-cmake put there, a Rust toolchain, and whatever the last +# job left in a cache. It cannot tell you whether this kit installs, +# because it never installs it from outside its own build tree. This job +# does, in a container holding the language runtime and nothing else, and +# it does it every night rather than on every push, because what breaks +# an install is usually somebody else's release and not a commit here. +# +# The engine arrives as an SDK laid out on disk rather than off a release +# page, because there is no release yet. The shape is the shape a user +# gets: include/zu.h and lib/libzu.so, which is what an archive unpacks +# to and what ZU_ROOT means. What the layout leaves out is the download +# and the signature, and those arrive on the day the first tag does. +# +# Read scripts/install.sh. Everything the job decides is decided there. + +on: + schedule: + # Late enough that a base image published during the day is in it. + - cron: "31 4 * * *" + workflow_dispatch: + pull_request: + paths: + - README.md + - CMakeLists.txt + - "cmake/**" + - "include/**" + - "scripts/install.sh" + - ".github/workflows/install.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + clean: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # A C++ toolchain image, given CMake. This is most of the + # people who would install this: they have a compiler in a + # container and they add the build system. + - name: a gcc image, given cmake + image: gcc:15-bookworm + mode: cmake + absent: rustc cargo doxygen + setup: "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends cmake >/dev/null" + + # A bare machine, given a compiler and a build system and + # nothing else. This is the row where the absent list means + # something, because everything on it had to be listed. + - name: a bare machine, given a compiler and cmake + image: debian:13-slim + mode: cmake + absent: rustc cargo doxygen git python3 pkg-config + setup: "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends g++ cmake make libc6-dev >/dev/null" + + # And the claim the README makes for a project that would + # rather not use CMake: the wrapper is header only, so it needs + # the include path and nothing else. Checked on an image with + # no CMake on it at all, which is the only way that claim can + # be checked honestly. + - name: a bare machine with no cmake at all + image: debian:13-slim + mode: header + absent: rustc cargo doxygen git python3 pkg-config cmake make + setup: "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends g++ libc6-dev >/dev/null" + + name: ${{ matrix.name }} + steps: + - uses: actions/checkout@v5 + + - uses: actions/checkout@v5 + with: + repository: tamnd/zu + path: engine + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: engine + + - name: Build libzu + working-directory: engine + run: cargo build --release -p zu-capi + + # The release that does not exist yet, stood up on disk, in the + # layout ZU_ROOT documents. Read only inside the container, so an + # install cannot write back into the thing it is installing from. + - name: An SDK holding this engine + run: | + set -eu + mkdir -p "$RUNNER_TEMP/sdk/include" "$RUNNER_TEMP/sdk/lib" + cp engine/crates/zu-capi/include/zu.h "$RUNNER_TEMP/sdk/include/" + cp engine/target/release/libzu.so "$RUNNER_TEMP/sdk/lib/" + + # /app is a directory inside the container and not a mount, so + # every run starts empty and nothing root wrote is left behind for + # the runner to trip over. + - name: Install and run both programs on the page + run: | + docker run --rm \ + -v "$PWD:/src:ro" \ + -v "$RUNNER_TEMP/sdk:/sdk:ro" \ + -e SRC=/src -e SDK=/sdk -e APP=/app \ + -e MODE="${{ matrix.mode }}" \ + -e ABSENT="${{ matrix.absent }}" \ + "${{ matrix.image }}" \ + sh -c "${{ matrix.setup }} && /src/scripts/install.sh" + + # The failures the job is meant to catch, caught. A job that only + # ever passes is a job nobody has seen fail, and the failure that + # matters here is the one where the compiler found a header or the + # linker found a library that the install did not put there. Take + # each piece out of the install and the build has to stop; if it + # does not, then something on the image was used instead and every + # pass above meant nothing. + # + # The package row is skipped where there is no package, which is + # the row that installs a header by copying it. + - name: The failures the job is meant to catch, caught + run: | + set -eu + breaks="header sdk" + if [ "${{ matrix.mode }}" = cmake ]; then + breaks="header package sdk" + fi + for what in $breaks; do + echo "=== without the $what" + set +e + docker run --rm \ + -v "$PWD:/src:ro" \ + -v "$RUNNER_TEMP/sdk:/sdk:ro" \ + -e SRC=/src -e SDK=/sdk -e APP=/app \ + -e MODE="${{ matrix.mode }}" \ + -e ABSENT="${{ matrix.absent }}" \ + -e BREAK="$what" \ + "${{ matrix.image }}" \ + sh -c "${{ matrix.setup }} && /src/scripts/install.sh" >/dev/null 2>&1 + status=$? + set -e + test $status -ne 0 || { + echo "the install worked without the $what in it" + exit 1 + } + done diff --git a/README.md b/README.md index f06ff9a..4dc5ca3 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ lynn - `cmake/`, `find_package(Zu)` to find the engine and `find_package(zu-cpp)` to find this. vcpkg, Conan and pkg-config packaging come with the first release. - `docs/`, the API reference, generated from `include/zu.hpp` and published with the release rather than checked in beside the source. `docs/reference.py` is the part worth reading: Doxygen exits 0 on a header it extracted nothing from, so the check counts the types the header declares and fails when the reference does not have them, which is what an empty reference looks like from the outside. +- `scripts/install.sh`, the install a person with nothing on their machine gets, run nightly in a container holding a compiler and nothing else. It installs the wrapper, takes both programs off this page, builds them against the install and diffs what they print against the blocks under them. It is run again three times with a piece taken out of the install, because a build that still works without the header is a build that found one somewhere else. + Four sanitizer jobs run over the suite, because an ABI nine languages depend on should fail loudly rather than corrupt quietly. The whole tree runs under ASan and UBSan; `test/misuse.c` runs again with leak detection on, which it can and the C++ files cannot, because it is the file that gives every handle back by hand; `test/threads.c` runs under TSan; and both C files run under valgrind, which sees what the sanitizers cannot, since libzu is compiled without instrumentation and memcheck does not need any. `test/tsan.supp` records what TSan is unable to be told about a library that takes no pthread lock, and why the reports from inside the engine are dropped rather than read. ## Building diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..df17200 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,227 @@ +#!/bin/sh +# +# The install, run inside the container the Install workflow starts. +# +# It is a program rather than lines in a YAML file because it is run four +# times, once for the install and three times to prove the install would +# have failed if a piece of it were missing, and because a thing that +# decides whether a client is installable should be readable without +# opening a workflow. +# +# What it does is what a reader does. Install the wrapper into a prefix, +# take the two whole programs off the README, put each in an empty +# directory somewhere else on the machine, build them against the prefix +# and run them, and diff what they print against the block under them on +# the page. Nothing is copied out of the checkout except those two +# programs and the install, and the checkout is mounted read only so +# that a build cannot reach back into it for a header. +# +# It is driven by these, all set by the workflow: +# +# SRC the checkout, read only +# SDK an engine SDK, read only: include/zu.h and lib/libzu.so, +# which is what a release archive unpacks to. There is no +# release yet, so the workflow builds one with cargo and lays +# it out, the same way it would arrive. +# APP an empty directory, outside the checkout, to work in +# ABSENT the tools this image is claimed not to have +# MODE cmake, for a project that runs find_package(zu-cpp), or +# header, for one that wants the include path and nothing else +# BREAK what to take out of the install before building, for the runs +# that have to fail. See the bottom of this file. + +set -eu + +: "${SRC:=/src}" +: "${SDK:=/sdk}" +: "${APP:=/app}" +: "${ABSENT:=rustc cargo}" +: "${MODE:=cmake}" +: "${BREAK:=}" + +prefix="$APP/prefix" + +# What the image is claimed to be, checked rather than believed. The day +# a base image starts shipping a Rust toolchain is the day this job +# quietly stops being about anything, because the thing it exists to +# prove is that none of it is needed. +for tool in $ABSENT; do + if command -v "$tool" >/dev/null 2>&1; then + echo "this image has $tool on it, so it is not the machine this job is about" >&2 + exit 1 + fi +done + +# And no zu already on it, which is the failure that would be worst of +# all: the install would look like it worked while the compiler took a +# header and the linker took a library that a previous job left behind. +if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists zu 2>/dev/null; then + echo "this image has a libzu registered with pkg-config" >&2 + exit 1 +fi +for path in /usr/include/zu.h /usr/local/include/zu.h \ + /usr/include/zu.hpp /usr/local/include/zu.hpp \ + /usr/lib/libzu.so /usr/local/lib/libzu.so; do + if [ -e "$path" ]; then + echo "this image has $path on it, so the install is not what would be used" >&2 + exit 1 + fi +done + +# The SDK is the engine's half and it has to be there, since neither +# zu.h nor libzu is in this repository and a job that ran without them +# would be reporting on a compile that never linked. +test -f "$SDK/include/zu.h" || { echo "no zu.h in $SDK" >&2; exit 1; } +# The glob expands to itself when it matches nothing, so this is asking +# whether there is a library rather than whether the shell was in the +# mood to say so. +set -- "$SDK"/lib/libzu.* +test -e "$1" || { echo "no libzu in $SDK/lib" >&2; exit 1; } + +# ---- install ---- + +mkdir -p "$APP" + +case "$MODE" in +cmake) + # The install a user runs, from the checkout, with the suite and the + # examples off because a user installing a header is not building + # this repository's tests. + cmake --version | head -1 + cmake -S "$SRC" -B "$APP/build" \ + -DCMAKE_BUILD_TYPE=Release \ + -DZU_CPP_TESTS=OFF -DZU_CPP_EXAMPLES=OFF -DZU_CPP_BENCH=OFF \ + -DZU_ROOT="$SDK" \ + -DCMAKE_INSTALL_PREFIX="$prefix" >/dev/null + cmake --build "$APP/build" --target install >/dev/null + ;; +header) + # The other claim the page makes: the wrapper is header only, so a + # project that would rather not use CMake needs the include path and + # nothing else. That is checked by doing it, on an image with no + # CMake on it at all, rather than by saying so. + mkdir -p "$prefix/include" + cp "$SRC/include/zu.hpp" "$prefix/include/zu.hpp" + ;; +*) + echo "MODE is cmake or header, not $MODE" >&2 + exit 1 + ;; +esac + +# The run that has to fail, set up. Everything above this line is the +# install; what is taken out here is taken out of the install rather +# than out of the source, so that a build which still succeeds is a +# build that found the missing piece somewhere else on the machine. +case "$BREAK" in +"") ;; +header) + rm -f "$prefix/include/zu.hpp" + ;; +package) + # By searching rather than by naming a path, because GNUInstallDirs + # picks the library directory and it is not lib everywhere. + find "$prefix" -type d -name zu-cpp -exec rm -rf {} + 2>/dev/null || true + ;; +sdk) + # Not the install: the engine beside it. A wrapper that compiled + # without libzu would be a wrapper that is not calling it. + SDK="$APP/no-sdk" + mkdir -p "$SDK" + ;; +*) + echo "BREAK is header, package, sdk or empty, not $BREAK" >&2 + exit 1 + ;; +esac + +# ---- the two programs on the page ---- + +# Taken off the page rather than written again here, so that what this +# job installs is what a reader copies. One fenced block per language +# and the first one wins, which is the same rule readme/CMakeLists.txt +# applies when it builds them as part of the suite. +lift() { + awk -v fence="\`\`\`$1" ' + $0 == fence { inside = 1; n = 0; next } + $0 == "```" && inside { for (i = 1; i <= n; i++) print line[i]; exit } + inside { n++; line[n] = $0 } + ' "$SRC/README.md" +} + +want='ada +grace +lynn' + +# Built into one place and run from another, each in a directory of its +# own, because both programs write social.zu1 beside themselves and +# zu_create refuses a path that is already there. +mkdir -p "$APP/bin" + +run() { + name="$1" + where="$APP/work-$name" + rm -rf "$where" + mkdir -p "$where" + got=$(cd "$where" && "$APP/bin/$name") + if [ "$got" != "$want" ]; then + echo "the $name quickstart printed:" >&2 + echo "$got" >&2 + echo "and the page says it prints:" >&2 + echo "$want" >&2 + exit 1 + fi +} + +libdir="$SDK/lib" + +# The C++ one, which is what this client installs. +mkdir -p "$APP/src-cpp" +lift cpp > "$APP/src-cpp/main.cpp" +test -s "$APP/src-cpp/main.cpp" || { + echo "the README has no C++ program on it, which is a page to fix rather than an install to report" >&2 + exit 1 +} + +if [ "$MODE" = cmake ]; then + cat > "$APP/src-cpp/CMakeLists.txt" <<'EOF' +cmake_minimum_required(VERSION 3.20) +project(quickstart LANGUAGES CXX) +find_package(zu-cpp REQUIRED) +add_executable(cpp main.cpp) +target_link_libraries(cpp PRIVATE zu::cpp) +EOF + cmake -S "$APP/src-cpp" -B "$APP/build-cpp" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$prefix" \ + -DZU_ROOT="$SDK" + cmake --build "$APP/build-cpp" + cp "$APP/build-cpp/cpp" "$APP/bin/cpp" +else + # The include path and one -l, which is the whole of what the page + # claims a project without CMake needs. + ${CXX:-c++} -std=c++20 -O2 \ + -I"$prefix/include" -I"$SDK/include" \ + "$APP/src-cpp/main.cpp" \ + -L"$libdir" -lzu -Wl,-rpath,"$libdir" \ + -o "$APP/bin/cpp" +fi +run cpp + +# And the C one, which is the half of this kit that is the ABI itself. +# It needs nothing out of this repository, and that is the point: it is +# the check that the SDK a user was handed is enough on its own. +mkdir -p "$APP/src-c" +lift c > "$APP/src-c/main.c" +test -s "$APP/src-c/main.c" || { + echo "the README has no C program on it, which is a page to fix rather than an install to report" >&2 + exit 1 +} +${CC:-cc} -std=c11 -O2 \ + -I"$SDK/include" \ + "$APP/src-c/main.c" \ + -L"$libdir" -lzu -Wl,-rpath,"$libdir" \ + -o "$APP/bin/c" +run c + +echo "installed in $MODE mode and ran both programs on the page"