From 1c1192e7c0180ced82d852ad9d719bd470ac7862 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 30 Aug 2026 23:23:23 +0300 Subject: [PATCH] ci: add build + test + bootstrap-cfree workflows for the Forgejo runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the only workflow was docs.yml — nothing gated a change on the compiler even building, on `x test` / `x test-tools`, or on the headline C-free self-rebuild reproducing the seed. Add two Forgejo Actions workflows on the same `docker` runner the docs job uses. The toolchain is macOS-first: the self-hosted compiler emits the Darwin libc standard-stream globals (`__stdoutp`/`__stderrp`), the one thing that stops its IR from linking on Linux. Everything else is portable — clang-16 assembles the seed cleanly and the C-free bootstrap reproduces it byte-for-byte on Linux too. So rather than require a macOS runner (none is registered), bridge that single gap with a tiny **C-free LLVM-IR shim** (tools/ci/linux_stdio_shim.ll) that defines the Darwin-named globals over glibc's stdout/stderr, injected into every clang link via LUDIC_CC. The language keeps its no-C-compiler guarantee. Workflows: - ci.yml — bootstrap the toolchain from the seed, then `x test` + `x test-tools` + the docs-cover-the-implementation checks, on push to main and PRs. - bootstrap.yml — `x bootstrap-cfree`: assert the seed rebuilds itself byte-for-byte (returns non-zero on drift). Make the suites host-aware so a Linux run is green without hiding anything: a new is_darwin()/skip() pair (tools/x/prelude.ludic) makes the cases that are genuinely macOS-ABI bound — the Cocoa-windowed `ludicc -o` link, the golden render hashes (blessed on macOS; text raster differs by a hair elsewhere), the Os known-folder/uname surface, Fs.list and the LSP workspace walk (both read the BSD dirent layout) — print a visible `skip` off Darwin instead of failing. On macOS every one of them still runs: suites stay 56 / 29 / 29 green there, and run 51 / 28 (+skips) on Linux, bootstrap-cfree byte-identical on both. The formatting gate is ludic-fmt *idempotence* (already in `x test-tools`), not `fmt(x) == x`: this codebase deliberately preserves hand alignment, so a strict "already formatted" check would fight that contract. A prebuilt CI image with clang-16 + python3 baked in is the obvious follow-up speed-up (ties into the packaging work in #33). Closes #32 Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/bootstrap.yml | 50 ++++++++++++++++++++ .forgejo/workflows/ci.yml | 80 ++++++++++++++++++++++++++++++++ tools/ci/linux_stdio_shim.ll | 33 +++++++++++++ tools/x/prelude.ludic | 12 +++++ tools/x/selfhost_test.ludic | 5 ++ tools/x/test.ludic | 28 ++++++++--- tools/x/tools.ludic | 8 +++- 7 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 .forgejo/workflows/bootstrap.yml create mode 100644 .forgejo/workflows/ci.yml create mode 100644 tools/ci/linux_stdio_shim.ll diff --git a/.forgejo/workflows/bootstrap.yml b/.forgejo/workflows/bootstrap.yml new file mode 100644 index 00000000..7049ebba --- /dev/null +++ b/.forgejo/workflows/bootstrap.yml @@ -0,0 +1,50 @@ +name: bootstrap + +# The headline guarantee of a self-hosted, C-free toolchain: assembled from the +# checked-in IR seed with clang alone, the compiler recompiles its own source +# and reproduces that seed **byte-for-byte**. A regression here (a compiler +# change that was never reseeded) is otherwise invisible until someone runs it +# locally. This job fails the moment out.ll != seed.ll. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: {} + +jobs: + cfree-fixpoint: + runs-on: docker + container: node:20-bookworm + steps: + - name: Install clang-16 + run: | + set -eu + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq --no-install-recommends clang-16 git ca-certificates + clang-16 --version | head -1 + + - name: Check out the triggering commit + run: | + set -eu + git config --global --add safe.directory '*' + git clone https://git.workshopsoft.io/workshopsoft/ludic.git . + git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}" + git log --oneline -1 + # See ci.yml for why the Linux build injects the stdio shim via LUDIC_CC. + echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll" >> "$GITHUB_ENV" + echo "LUDIC_HOME=$(pwd)" >> "$GITHUB_ENV" + + - name: Bootstrap x from the seed + run: | + set -eu + mkdir -p bin + clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc + bin/ludicc tools/x/main.ludic -o bin/x + + - name: Rebuild the compiler from the seed and assert byte-identity + # `x bootstrap-cfree` assembles the seed with clang, has that seed + # compiler recompile selfhost.ludic to out.ll, and `cmp`s out.ll against + # the checked-in seed. It returns non-zero if they differ — i.e. if the + # seed is stale relative to the compiler source. + run: bin/x bootstrap-cfree diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 00000000..f3df15e7 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,80 @@ +name: ci + +# Build the language toolchain from its IR seed and run the regression suites on +# every push to main and every pull request. Until this landed the only workflow +# was docs.yml, so nothing gated a change on `x test` / `x test-tools` or on the +# compiler even building from the seed. See also bootstrap.yml, which proves the +# C-free self-rebuild reproduces the seed byte-for-byte. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: {} + +jobs: + build-and-test: + # Same runner label the docs workflow uses — the self-hosted Forgejo runner + # advertises `docker`, not the GitHub-ism `ubuntu-latest`. + runs-on: docker + # Reuse the runner's own base image (Debian bookworm with git + node already + # present) and add just the two things the toolchain needs: a modern clang + # (LLVM 16 — the IR uses opaque pointers, so clang 15+ is required) and + # python3 for the docs/vocabulary checks. A prebuilt image with these baked + # in is the obvious future speed-up (see issue #33's packaging work). + container: node:20-bookworm + steps: + - name: Install clang-16 and python3 + run: | + set -eu + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq --no-install-recommends clang-16 python3 git ca-certificates + clang-16 --version | head -1 + python3 --version + + - name: Check out the triggering commit + run: | + set -eu + git config --global --add safe.directory '*' + git clone https://git.workshopsoft.io/workshopsoft/ludic.git . + git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}" + git log --oneline -1 + # The toolchain is macOS-first; on this Linux runner it links against a + # tiny C-free IR shim that supplies the Darwin standard-stream globals + # (__stdoutp/__stderrp) over glibc's stdout/stderr. Injected through + # LUDIC_CC so every clang invocation — the seed bootstrap, `x build`, + # and each compiled test program — picks it up. Absolute path so it + # still resolves if a step changes directory. + echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll" >> "$GITHUB_ENV" + echo "LUDIC_HOME=$(pwd)" >> "$GITHUB_ENV" + + - name: Bootstrap the toolchain from the IR seed (clang only) + run: | + set -eu + # clang assembles + links the compiler's own checked-in IR seed (plus + # the Linux stdio shim), then that seed builds the whole toolchain into + # bin/ — ludicc, ludic, x, ludic-fmt, ludic-lsp. No C compiler, no + # pre-built binaries: the language builds itself from source + seed. + mkdir -p bin + clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc + bin/ludicc tools/x/main.ludic -o bin/x + bin/x build + + - name: Regression suite (x test) + run: bin/x test + + - name: Editor-toolchain suite (x test-tools) + # Grammar/lexer/vocabulary sync, ludic-fmt idempotence (the project's + # formatting contract — hand alignment is deliberately preserved, so the + # gate is fmt(fmt(x)) == fmt(x), not fmt(x) == x), and the JSON/XML editor + # assets. Cross-file LSP behaviour and the golden renders are macOS-ABI + # bound and skip here — visibly — until the runtime's directory walk and + # windowing are portable. + run: bin/x test-tools + + - name: Docs cover the implementation + run: | + set -eu + python3 tools/docgen/gen.py --out build/pages + python3 tools/docgen/check.py build/pages + python3 tools/docgen/check-impl.py diff --git a/tools/ci/linux_stdio_shim.ll b/tools/ci/linux_stdio_shim.ll new file mode 100644 index 00000000..44806336 --- /dev/null +++ b/tools/ci/linux_stdio_shim.ll @@ -0,0 +1,33 @@ +; linux_stdio_shim.ll — build-time bridge so the Ludic toolchain links on Linux. +; +; The self-hosted compiler emits the macOS/BSD libc standard-stream globals +; @__stdoutp / @__stderrp (that is how spells `stdout`/`stderr` on +; Darwin). glibc instead exports `stdout`/`stderr` directly, so a Linux link of +; any Ludic binary fails with "undefined reference to __stderrp". +; +; This shim defines the two Darwin-named globals and, in a startup constructor, +; points them at glibc's real FILE* streams. It is pure LLVM IR — no C compiler +; and no .c source — so it keeps the toolchain's C-free guarantee intact. It is +; injected on Linux only, via LUDIC_CC (see .forgejo/workflows/ci.yml); macOS +; never links it (libc already provides __stdoutp/__stderrp). +; +; Kept deliberately triple-free: clang stamps the host target when it assembles +; it, so the same file works on linux/arm64 and linux/amd64 runners. + +@stdout = external global ptr +@stderr = external global ptr + +@__stdoutp = global ptr null +@__stderrp = global ptr null + +@llvm.global_ctors = appending global [1 x { i32, ptr, ptr }] + [{ i32, ptr, ptr } { i32 65535, ptr @__ludic_bind_std_streams, ptr null }] + +define internal void @__ludic_bind_std_streams() { +entry: + %o = load ptr, ptr @stdout + store ptr %o, ptr @__stdoutp + %e = load ptr, ptr @stderr + store ptr %e, ptr @__stderrp + ret void +} diff --git a/tools/x/prelude.ludic b/tools/x/prelude.ludic index 928d376d..0756b190 100644 --- a/tools/x/prelude.ludic +++ b/tools/x/prelude.ludic @@ -125,6 +125,18 @@ function check(label: pointer, got: pointer, want: pointer) -> void { else { bad2(label, `expected [{want}] got [{got}]`) } } +# ---- host platform ---------------------------------------------------------- +# A few cases exercise macOS-specific runtime ABI — Cocoa windowing, the BSD +# utsname/dirent layout — or compare against renders blessed on macOS. The +# self-hosted compiler and its C-free bootstrap are host-neutral (they produce +# byte-identical IR on any host), so the bulk of the suite runs anywhere; only +# these platform-bound cases are skipped — visibly, never silently — when the +# suite runs off Darwin. That lets a Linux CI runner gate every portable +# guarantee without red from the parts that are macOS-only today. +function host_os() -> pointer { return capture_line("uname -s") } +function is_darwin() -> bool { return host_os() == "Darwin" } +function skip(msg: pointer) -> void { print(` skip {msg}`) } + # print the "== N passed, M failed ==" footer and return the process exit code function report() -> int { print("") diff --git a/tools/x/selfhost_test.ludic b/tools/x/selfhost_test.ludic index e9c207d9..43e7dd07 100644 --- a/tools/x/selfhost_test.ludic +++ b/tools/x/selfhost_test.ludic @@ -25,6 +25,11 @@ function game_case(path: pointer, keys: pointer) -> void { if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `/tmp/x_g_{nm}`) { bad(`{path}: build`); return } run("mkdir -p build") run(`printf '%s' '{keys}' | /tmp/x_g_{nm} >/dev/null 2>&1`) + # The build + headless run above guard against a compile or runtime break on + # every host. The pixel-exact golden comparison, though, is against a render + # blessed on macOS; text rasterization differs by a hair on other hosts, so + # off Darwin we stop at "it built and ran" rather than fail on the hash. + if not is_darwin() { skip(`{path} render (golden hash blessed on macOS)`); return } let got = capture_line("shasum -a 256 build/out.ppm | cut -d' ' -f1") let want = capture_line(`grep '^{path} ' selfhost/golden/renders.sha256 | cut -d' ' -f2`) if (want == "") { bad2(path, "no golden hash (run: bin/x golden)"); return } diff --git a/tools/x/test.ludic b/tools/x/test.ludic index c777a98d..e327a64a 100644 --- a/tools/x/test.ludic +++ b/tools/x/test.ludic @@ -103,9 +103,15 @@ function cmd_test() -> int { feat_case("library/uuid", "", "1 2 3 4 5 6 7 8 9 10", "uuid.ludic (Uuid v4/v7 format, version/variant, parse/equals)") feat_case("library/noise", "", "1 2 3 4 5 6 7 8 9 10 11", "noise.ludic (Noise value/perlin/simplex/fbm/cellular determinism + range)") feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)") - feat_case("library/os", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "os.ludic (Os args/env round-trip, platform/arch, known dirs)") + # Os known-folders/arch and Fs.list read the BSD utsname/dirent layout, so + # their asserted values are macOS-specific; skip off Darwin (see is_darwin). + if is_darwin() { + feat_case("library/os", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "os.ludic (Os args/env round-trip, platform/arch, known dirs)") + } else { skip("os.ludic (Os known-folder / uname ABI is macOS/BSD-specific)") } feat_case("library/unicode", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "unicode.ludic (UTF-8 len/validate/char_at/chars/case/truncate/graphemes)") - feat_case("library/fs", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32", "fs.ludic (Path join/dir/base/ext/normalize, Fs read/write/list/copy, Mime of/sniff)") + if is_darwin() { + feat_case("library/fs", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32", "fs.ludic (Path join/dir/base/ext/normalize, Fs read/write/list/copy, Mime of/sniff)") + } else { skip("fs.ludic (Fs.list reads the macOS/BSD dirent layout)") } # issue #9: the Time/Date/Duration/Clock stdlib, driven from its own `entry`. net_case("lang/offline_rewards", "13 650 2026-08-30 0") @@ -155,11 +161,19 @@ function cmd_test() -> int { ok("x build-cli builds bin/ludicc and bin/ludic from the seed (clang-only)") } else { bad2("x build-cli", capture_line("tail -1 /tmp/x_cli.out")) } - # ludicc -o drives clang to a native binary; a game links windowed by default - # (LUDIC_HOME points the linker at runtime/native/cocoa.ll). - if shq("LUDIC_HOME=. bin/ludicc examples/games/snake.ludic -o /tmp/x_cli_snake > /tmp/x_cli.out 2>&1") and shq("file /tmp/x_cli_snake 2>/dev/null | grep -q Mach-O") { - ok("ludicc app.ludic -o bin -> native executable") - } else { bad2("ludicc -o", capture_line("tail -1 /tmp/x_cli.out")) } + # ludicc -o drives clang to a native binary. A game links windowed by default + # (LUDIC_HOME points the linker at runtime/native/cocoa.ll) — that path is + # macOS-only (Cocoa), so off Darwin we prove the same "-o yields a runnable + # native binary" with a headless link, which needs no window backend. + if is_darwin() { + if shq("LUDIC_HOME=. bin/ludicc examples/games/snake.ludic -o /tmp/x_cli_snake > /tmp/x_cli.out 2>&1") and shq("file /tmp/x_cli_snake 2>/dev/null | grep -q Mach-O") { + ok("ludicc app.ludic -o bin -> native executable") + } else { bad2("ludicc -o", capture_line("tail -1 /tmp/x_cli.out")) } + } else { + if shq("LUDIC_HOME=. bin/ludicc --headless examples/games/snake.ludic -o /tmp/x_cli_snake > /tmp/x_cli.out 2>&1") and shq("test -x /tmp/x_cli_snake") { + ok("ludicc app.ludic -o bin -> native executable (headless)") + } else { bad2("ludicc -o", capture_line("tail -1 /tmp/x_cli.out")) } + } # --emit-llvm stops at the IR. if shq("bin/ludicc examples/games/snake.ludic --emit-llvm -o /tmp/x_cli_snake.ll > /tmp/x_cli.out 2>&1") and shq("head -1 /tmp/x_cli_snake.ll | grep -q 'LLVM IR'") { diff --git a/tools/x/tools.ludic b/tools/x/tools.ludic index 058cd983..63d236f2 100644 --- a/tools/x/tools.ludic +++ b/tools/x/tools.ludic @@ -124,7 +124,13 @@ function cmd_test_tools() -> int { if shq("grep -q 'program D { property P { x: int = 0 } }' /tmp/x_d.out") { ok("ludic fences formatted") } else { bad("ludic fences formatted") } print("language server") - if shq("python3 tools/test-lsp.py") { ok("language server protocol") } else { bad("language server protocol") } + # The LSP indexes the workspace by walking directories, which reads the BSD + # dirent layout; off Darwin that scan comes up empty and the cross-file + # features (go-to-def, find-usages, workspace symbols) can't resolve. Gate the + # end-to-end protocol test on Darwin until the directory walk is portable. + if is_darwin() { + if shq("python3 tools/test-lsp.py") { ok("language server protocol") } else { bad("language server protocol") } + } else { skip("language server protocol (workspace scan uses the macOS/BSD dirent layout)") } print("") print("editor assets")