name: CI
on:
# The rebuild path. When a merge lands a tree no pull request built (the base
# moved under it), `main-checks.yml` calls this workflow as a reusable one, so
# the jobs below run inside that push's own run and their checks land on the
# pushed commit. Calling rather than dispatching is deliberate: a workflow's
# `GITHUB_TOKEN` cannot reliably trigger a `workflow_dispatch`, and a dispatch
# would race the branch tip rather than naming the commit it is judging.
workflow_call:
secrets:
CACHIX_AUTH_TOKEN:
required: false
# Manual runs against any ref.
workflow_dispatch:
# Merge queues. Enabling one is a repository setting, not a workflow change:
# the queue builds its own ref from the pull requests it batches and runs this
# workflow against it, which is the *only* run that ever sees the combined
# result. See the note below on why that matters here.
merge_group:
# Run this workflow for all PRs:
pull_request:
# Every job below runs on every event above, and none of them carries an `if:`
# guard. That is deliberate, and it is the property to preserve when editing:
#
# * A merge-queue run that skipped the matrix would gate merges on nothing —
# testing the batched result is the entire purpose of a queue. The guards
# that used to sit here (`event_name != 'merge_group'`, and before that
# `== 'pull_request'`) would have made adopting one silently vacuous.
# * A *called* workflow reports the caller's event, so on `main-checks.yml`'s
# rebuild path `github.event_name` is `push`. A job gated to `pull_request`
# would make that rebuild report green having built nothing.
#
# The guards existed for the `push`-to-`main` and `schedule` triggers this
# workflow no longer has: `main-checks.yml` owns the default branch and
# `link-check.yml` owns the nightly sweep. Nothing reaching this file now wants
# less than the full matrix, so gate on the trigger list, not on `if:`.
# Least privilege by default: no job here writes to the repository. The cachix
# push authenticates with its own token, not GITHUB_TOKEN.
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }}
# Superseded PR pushes are worth cancelling; a dispatched rebuild of `main` is
# not — it is the only thing standing behind that commit's checks.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
system: x86_64-linux
DC: ldc2
timeout: 75
- runner: ubuntu-latest
system: x86_64-linux
DC: dmd
timeout: 40
- runner: macos-latest
system: aarch64-darwin
DC: ldc2
timeout: 90
# The one host where `real` is binary128 (`real.mant_dig == 113`):
# the leg that compiles and runs the wide-format conversion paths
# natively. Scoped to `ci --test` for now; the example and OS-API
# steps below are keyed to the x86_64 leg until this one is proven.
- runner: ubuntu-24.04-arm
system: aarch64-linux
DC: ldc2
timeout: 120
# Native Windows — the host the `sparkles:wsi` Win32 backend had only
# ever been certified on through Wine. There is no Nix on the hosted
# runner, so this leg takes its toolchain from setup-dlang and runs
# the suite through ci/test-windows.sh instead of the `ci` helper
# (whose closure needs tree-sitter via pkg-config). Which packages it
# covers, and why the rest are left out, is that script's business;
# the Nix-only steps below are keyed off it by `runner.os`.
- runner: windows-latest
system: x86_64-windows
DC: ldc2
timeout: 40
runs-on: ${{ matrix.runner }}
# Budget the entire job, including cold toolchain builds and cleanup.
# macOS passed its tests but twice hit 40m during the example sweep.
# Native macOS linked package 28/55 near 40m in run 37332583414.
# Its ~36m test prefix plus ~5m setup projects to ~78m before examples.
# ARM Linux twice spent the whole 40m cap bootstrapping the SPIR-V LLVM
# toolchain; cancelled builds do not publish that unfinished derivation,
# so an unchanged rerun repeats the cold build instead of warming its cache.
# x86_64 LDC also includes the complete X11/Wayland demo sweep. Run
# 37375104839 spent ~45m on tests and ~10m on examples before that sweep;
# its 55m cap cancelled the otherwise passing job during the demos.
timeout-minutes: ${{ matrix.timeout }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Nix
if: runner.os != 'Windows'
uses: ./.github/actions/setup-nix
with:
push-to-cache: true
cachix-cache: ${{ vars.CACHIX_CACHE }}
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
trusted-public-keys: ${{ vars.TRUSTED_PUBLIC_KEYS }}
substituters: ${{ vars.SUBSTITUTERS }}
devshell: ci
use-nix-cache: true
# Keyed by system, not runner.os: that is "Linux" for x86_64 and arm64 alike,
# and two legs sharing one cache corrupt it for each other.
nix-cache-key: nix-v2-${{ matrix.system }}-${{ matrix.DC }}-${{ hashFiles('**/flake.lock') }}
nix-cache-restore-prefixes: |
nix-v2-${{ matrix.system }}-${{ matrix.DC }}-
nix-v2-${{ matrix.system }}-
# The Windows leg's toolchain (LDC plus the dub it bundles). Pinned to the
# flake's LDC so every leg agrees on the compiler; ci/install-ldc-windows.sh
# carries the same version for CircleCI.
- name: Install D (LDC)
if: runner.os == 'Windows'
uses: dlang-community/setup-dlang@v2
with:
compiler: ldc-1.42.0
- name: Cache DUB packages
if: runner.os != 'Windows'
uses: actions/cache@v6
with:
path: |
~/.dub
~/.local/share/dub
# Same reason as the Nix cache key above.
key: ${{ matrix.system }}-dub-${{ hashFiles('**/dub.sdl', '**/dub.selections.json') }}
restore-keys: |
${{ matrix.system }}-dub-
# dub keeps its package store under %LOCALAPPDATA% on Windows, and the
# cache action does not accept a per-OS path list, hence the second step.
- name: Cache DUB packages (Windows)
if: runner.os == 'Windows'
uses: actions/cache@v6
with:
path: ~/AppData/Local/dub
key: ${{ matrix.system }}-dub-${{ hashFiles('**/dub.sdl', '**/dub.selections.json') }}
restore-keys: |
${{ matrix.system }}-dub-
- name: Run flake checks
if: matrix.system != 'aarch64-linux' && runner.os != 'Windows'
run: nix flake check
- name: Build and test with dub
if: runner.os != 'Windows'
run: ci --test --fail-fast
env:
# `ci` embeds dmd (small closure); honour the matrix compiler for the
# test suite so the ldc2/dmd dimensions are actually exercised. Both
# are on PATH via the `ci` dev shell.
DC: ${{ matrix.DC }}
# `ci --test` without the `ci` helper: `dub test` per allowlisted
# sub-package, `-t 1` then `-t 0`, under Git Bash so the script shares
# ci/lib/common.sh with CircleCI's bash.exe.
- name: Build and test with dub (Windows)
if: runner.os == 'Windows'
shell: bash
run: ci/test-windows.sh
env:
DC: ${{ matrix.DC }}
- name: Build and run examples
if: matrix.system != 'aarch64-linux' && runner.os != 'Windows'
run: |
if [ "$RUNNER_OS" = Linux ]; then
dub run --single apps/ci/tools/prepare-event-horizon-comparisons.d --temp-build -b checked
fi
ci --example-files --fail-fast
# sparkles:shaders' agreement evidence (SHV7, SHF4): a probe unit compiled
# by shader-compile runs on Mesa's software rasterizer under Xvfb, and its
# results are read back against the host's. The runner is not NixOS, so
# the Nix-built raylib is pointed at the flake's Mesa explicitly. Opted
# into, the test fails rather than skips when it cannot run.
- name: Read back generated shaders on a GL driver
if: matrix.system == 'x86_64-linux' && matrix.DC == 'ldc2'
run: |
mesa="$(nix build --no-link --print-out-paths --inputs-from . nixpkgs#mesa)"
export LIBGL_DRIVERS_PATH="$mesa/lib/dri"
export LD_LIBRARY_PATH="$mesa/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
SPARKLES_GPU_READBACK=1 xvfb-run -a dub test :ui-raylib -- -i shader_readback
# OS-API research examples (docs/research/.../os-apis). These are multi-file
# dub packages (ImportC), not single-file, so they are outside `ci
# --example-files`; build/run the Linux ones here in the devshell. X11 opens a
# real window under xvfb; Wayland has no compositor in CI so it prints SKIP and
# exits 0. (macOS/iOS/Android are verified out of band — see the surveys.)
- name: Build & run OS-API windowing examples (X11 + Wayland)
if: matrix.system == 'x86_64-linux' && matrix.DC == 'ldc2'
run: |
xvfb-run -a dub run --compiler=ldc2 --root=docs/research/window-system-integration/os-apis/x11/example
dub run --compiler=ldc2 --root=docs/research/window-system-integration/os-apis/wayland/example
# Interactive GUI probe — compile only; running it would sit in a
# window loop until the job timed out.
dub build --single --compiler=ldc2 \
docs/research/window-system-integration/cursor-shapes/cursor-shapes.d
# Per-feature windowing demos (os-apis/<platform>/examples/{scaffold,fXX-*}).
# Demos exit after a bounded run when WSI_AUTO_EXIT=1 and print `SKIP:` +
# exit 0 when a host capability is missing — a missing capability must never
# red the build. X11 demos run under Xvfb; Wayland demos under a headless
# weston instance.
- name: Build & run OS-API windowing demos (X11 + Wayland)
if: matrix.system == 'x86_64-linux' && matrix.DC == 'ldc2'
env:
WSI_AUTO_EXIT: 1
run: |
base=docs/research/window-system-integration/os-apis
for d in "$base"/x11/examples/*/; do
[ -f "$d/dub.sdl" ] || continue
echo "::group::x11 $d"
xvfb-run -a dub run --compiler=ldc2 --root="$d"
echo "::endgroup::"
done
if ls -d "$base"/wayland/examples/*/ >/dev/null 2>&1; then
export XDG_RUNTIME_DIR="$(mktemp -d)"
weston --backend=headless --socket=wsi-ci --idle-time=0 &
weston_pid=$!
sleep 3
for d in "$base"/wayland/examples/*/; do
[ -f "$d/dub.sdl" ] || continue
echo "::group::wayland $d"
WAYLAND_DISPLAY=wsi-ci dub run --compiler=ldc2 --root="$d"
echo "::endgroup::"
done
kill "$weston_pid"
fi
# The native half of what libs/wsi/scripts/verify-win32-wine.sh certifies
# under Wine: the shared WSI conformance suite through the Win32 driver,
# on a real User32 and IOCP. Wine is a compatibility lane, not native
# evidence — its monitors are fixed at 96 DPI and its modal loops depend
# on which display driver loaded — which is why the spec's comparison.md
# carried "native Windows CI" as an owed column. Counts are pinned as in
# every other lane, so a driver that skipped everything cannot pass. The
# IMM32 preedit phase is the one thing this host cannot show: the runner's
# US layout has no IME UI, so the driver sees the result-string commit but
# no preedit, and says so — the preedit evidence stays with the Wine lane.
- name: Build & run the sparkles:wsi Win32 conformance driver
if: runner.os == 'Windows'
shell: bash
run: |
dub run --single --compiler=ldc2 libs/wsi/examples/win32-hosted-smoke.d | tee smoke.log
grep -q '^ok: Win32 WSI conformance (18 checked, 3 skipped)' smoke.log
grep -q '^ok: Win32 text commit' smoke.log
# The Win32 counterpart of the X11/Wayland OS-API steps above: the research
# example and the per-feature demos, on druntime's core.sys.windows alone.
# WSI_AUTO_EXIT bounds each demo's run; the interactive parts are exercised
# by hand and under Wine locally (see the manual-run queue).
- name: Build & run OS-API windowing example and demos (Win32)
if: runner.os == 'Windows'
shell: bash
env:
WSI_AUTO_EXIT: 1
run: |
base=docs/research/window-system-integration/os-apis
echo "::group::win32 $base/win32/example"
dub run --compiler=ldc2 --root="$base/win32/example"
echo "::endgroup::"
for d in "$base"/win32/examples/*/; do
[ -f "$d/dub.sdl" ] || continue
echo "::group::win32 $d"
dub run --compiler=ldc2 --root="$d"
echo "::endgroup::"
done
# The test runner's extracted-test modes. `--test` does not cover these: a
# @betterC test runs as an ordinary unittest there, and only `--better-c`
# additionally extracts it, compiles it without druntime, and runs the
# result — so a break in the extraction, the -betterC codegen, or the wasm32
# cross-compile is invisible to `dub test`.
#
# One job rather than a matrix leg: `--wasm` needs LDC specifically (it is
# -mtriple/llvm_trap based), so a dmd leg could only skip. Linux-only for
# now — the hosted macOS runner is the flaky/slow one, and these modes
# exercise the compiler far more than the host.
extracted-tests:
name: Extracted test modes (--better-c, --wasm)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Nix
uses: ./.github/actions/setup-nix
with:
push-to-cache: true
cachix-cache: ${{ vars.CACHIX_CACHE }}
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
trusted-public-keys: ${{ vars.TRUSTED_PUBLIC_KEYS }}
substituters: ${{ vars.SUBSTITUTERS }}
devshell: ci
use-nix-cache: true
nix-cache-key: nix-v2-${{ runner.os }}-extracted-${{ hashFiles('**/flake.lock') }}
nix-cache-restore-prefixes: |
nix-v2-${{ runner.os }}-extracted-
nix-v2-${{ runner.os }}-
- name: Cache DUB packages
uses: actions/cache@v6
with:
path: |
~/.dub
~/.local/share/dub
key: ${{ runner.os }}-dub-${{ hashFiles('**/dub.sdl', '**/dub.selections.json') }}
restore-keys: |
${{ runner.os }}-dub-
# Both modes normally *skip* when their toolchain is missing, which is
# right on a contributor's machine and exactly wrong here. `ci
# --test-extracted` passes the runner's `--require-toolchain`, turning
# any such skip into a failure — without it a missing wasm-ld would
# leave this job green having run nothing.
- name: Run --better-c and --wasm for every sub-package that uses them
run: ci --test-extracted --fail-fast
env:
DC: ldc2
# AddressSanitizer + LDC stack-overflow sanitizer. Linux LDC only: nixpkgs
# LDC links gcc libasan on Linux and has no ASan runtime dylib on Darwin.
# No UBSan: LDC rejects `-fsanitize=undefined` for D, and `-Xcc` never
# reaches ImportC codegen (docs/research/sanitizers/ubsan.md). One `-t N`
# leg on 4 MiB workers (the stack watermark is off under ASan).
# 45m: every package is rebuilt with ASan; a 30m cap cancelled mid-package
# the same way the test job's old 20m cap did.
sanitize:
name: Sanitizers (ASan, stackovf)
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v7
with:
# No history needed: dub's `git describe` version only names the
# in-tree build dir, which never hits the cache across checkouts.
fetch-depth: 1
- name: Setup Nix
uses: ./.github/actions/setup-nix
with:
push-to-cache: true
cachix-cache: ${{ vars.CACHIX_CACHE }}
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
trusted-public-keys: ${{ vars.TRUSTED_PUBLIC_KEYS }}
substituters: ${{ vars.SUBSTITUTERS }}
devshell: ci
use-nix-cache: true
# ubuntu-latest is x86_64-linux; key by system, not runner.os, so a
# future arm64 Ubuntu runner cannot share this cache (see the test job).
nix-cache-key: nix-v2-x86_64-linux-sanitize-${{ hashFiles('**/flake.lock') }}
nix-cache-restore-prefixes: |
nix-v2-x86_64-linux-sanitize-
nix-v2-x86_64-linux-
# Its own key family. dub hashes `$DFLAGS` into every build id, so an
# ASan build shares nothing with a plain one; falling back to the test
# job's `x86_64-linux-dub-` cache would only restore dead weight, and
# saving the union back would let the test job restore ours in turn
# (its prefix matches `x86_64-linux-dub-*`) — doubling both caches in
# an Actions cache already at its quota. `asan-dub` matches neither.
- name: Cache DUB packages
uses: actions/cache@v6
with:
path: |
~/.dub
~/.local/share/dub
key: x86_64-linux-asan-dub-${{ hashFiles('**/dub.sdl', '**/dub.selections.json') }}
restore-keys: |
x86_64-linux-asan-dub-
- name: Run tests under ASan / stackovf
run: ci --test-sanitize --fail-fast
env:
DC: ldc2
# Build the Nix outputs and push them to the binary cache, once per system
# (the derivations are compiler-agnostic — they always build with ldc — so
# this is independent of the `test` job's DC matrix). `.#all-desktop` is the
# aggregate defined in nix/packages/all.nix: the full dev shell, every
# desktop package, and every standalone example. The Android closure builds
# in its own `nix-build-android` job below.
nix-build:
name: Nix build & push (${{ matrix.runner }})
strategy:
fail-fast: false
matrix:
runner:
- ubuntu-latest
- macos-latest
runs-on: ${{ matrix.runner }}
# `.#all-desktop` builds the whole closure; normally 2-4m from cache, but
# a cold-cache rebuild (after a flake.lock bump) needs real margin. On
# Linux the aggregate also carries the wasm toolchain, and a cold rebuild
# of that compiles the LDC fork from source — far more than 20 minutes.
# `with-cachix.sh` pushes each path as it completes, so even a run that
# hits this cap leaves progress in the cache for the next one.
# The macOS cold aggregate in run 37375104839 was still compiling desktop
# applications at its 40m cap; allow the remaining closure and example run.
timeout-minutes: ${{ matrix.runner == 'ubuntu-latest' && 60 || 90 }}
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Nix
uses: ./.github/actions/setup-nix
with:
push-to-cache: true
cachix-cache: ${{ vars.CACHIX_CACHE }}
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
trusted-public-keys: ${{ vars.TRUSTED_PUBLIC_KEYS }}
substituters: ${{ vars.SUBSTITUTERS }}
use-nix-cache: true
nix-cache-key: nix-${{ runner.os }}-nixbuild-${{ hashFiles('**/flake.lock') }}
nix-cache-restore-prefixes: |
nix-${{ runner.os }}-nixbuild-
nix-${{ runner.os }}-
# `with-cachix.sh` pushes each path as it is built, so a failure in the
# step below still leaves this build's outputs in the cache. The
# setup-nix action's post-job hook (push-to-cache: true) then pushes
# anything else the job produced.
- name: Build & push devShells.full, all packages, and all examples
env:
CACHIX_CACHE: ${{ vars.CACHIX_CACHE }}
CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }}
run: ci/with-cachix.sh nix build --print-build-logs .#all-desktop
- name: Run all runnable examples
run: nix run .#run-all-examples
# sparkles:shaders SHT3: the same sources and toolchain must generate the
# same GLSL on every platform. Each runner records the content hash of
# its `ui-shaders` (names, bytes, modes), and `shader-reproducibility`
# compares them.
- name: Hash the generated shaders
run: |
out="$(nix build --no-link --print-out-paths .#ui-shaders)"
ls -A "$out"
nix hash path --type sha256 "$out" | tee "ui-shaders-${{ runner.os }}.hash"
- name: Upload the generated shaders' hash
uses: actions/upload-artifact@v7
with:
name: ui-shaders-${{ runner.os }}
path: ui-shaders-${{ runner.os }}.hash
shader-reproducibility:
name: Generated shaders agree across platforms
needs: nix-build
runs-on: ubuntu-latest
steps:
- name: Download every platform's hash
uses: actions/download-artifact@v8
with:
pattern: ui-shaders-*
merge-multiple: true
- name: Compare
run: |
for f in ui-shaders-*.hash; do echo "$f: $(cat "$f")"; done
hashes=(ui-shaders-*.hash)
[ "${#hashes[@]}" -ge 2 ] || { echo "::error::fewer than two platforms reported"; exit 1; }
[ "$(sort -u ui-shaders-*.hash | wc -l)" -eq 1 ] || { echo "::error::the generated GLSL differs between platforms (SHT3)"; exit 1; }
# The Android APK pipeline (nix/packages/android/): builds `.#all-android`
# — the repo-embedded hue APK, whose dependency chain covers the entire
# cross closure (dual-ABI druntimes via dlang.nix's ldc-android, the
# raylib/tree-sitter/libghostty-vt cross builds, every grammar parser, the
# Maple font build, and the aapt2/zipalign/apksigner assembly). Built on
# Linux only, to keep the cross closure off the macOS runners; the same
# outputs exist on aarch64-darwin (nix/packages/android/host.nix), where the
# macOS leg's `nix flake check` evaluates every one of them. The unfree Android SDK
# licence is accepted inside the scoped nixpkgs import
# (nix/packages/android/sdk.nix) — no env flags needed here.
nix-build-android:
name: Nix build & push (Android APK)
runs-on: ubuntu-latest
# From a warm cache this is minutes; a cold cache rebuilds the cross
# druntimes and the Maple font pipeline — give it real headroom.
timeout-minutes: 90
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Nix
uses: ./.github/actions/setup-nix
with:
push-to-cache: true
cachix-cache: ${{ vars.CACHIX_CACHE }}
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
trusted-public-keys: ${{ vars.TRUSTED_PUBLIC_KEYS }}
substituters: ${{ vars.SUBSTITUTERS }}
use-nix-cache: true
nix-cache-key: nix-${{ runner.os }}-nixandroid-${{ hashFiles('**/flake.lock') }}
nix-cache-restore-prefixes: |
nix-${{ runner.os }}-nixandroid-
nix-${{ runner.os }}-
- name: Build & push the Android APK closure
env:
CACHIX_CACHE: ${{ vars.CACHIX_CACHE }}
CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }}
run: ci/with-cachix.sh nix build --print-build-logs .#all-android
lint:
name: Lint & markdown link check
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- uses: actions/checkout@v7
- name: Setup Nix
uses: ./.github/actions/setup-nix
with:
push-to-cache: true
nix-github-token: ${{ github.token }}
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
cachix-cache: ${{ vars.CACHIX_CACHE }}
trusted-public-keys: ${{ vars.TRUSTED_PUBLIC_KEYS }}
substituters: ${{ vars.SUBSTITUTERS }}
devshell: pre-commit
use-nix-cache: true
nix-cache-key: nix-v2-${{ runner.os }}-lint-${{ hashFiles('**/flake.lock') }}
nix-cache-restore-prefixes: |
nix-v2-${{ runner.os }}-lint-
nix-v2-${{ runner.os }}-
- name: Restore lychee cache
uses: actions/cache/restore@v6
with:
path: .lycheecache
enableCrossOsArchive: true
key: cache-lychee-v4-${{ github.run_id }}
restore-keys: |
cache-lychee-v4-
# Everything over the whole tree EXCEPT the networked link check — which
# includes `lychee-offline`, a ~0.4 s pass that catches the thing a diff
# cannot see: a renamed heading or a deleted page breaking a relative link
# in a file this change never touched.
#
# `no-commit-to-branch` is a local developer guard (don't commit on main);
# on push-to-main the checkout is that branch, so the hook would fail the
# job that exists precisely to seed caches and re-check links after merge.
- name: Lint (and check local links)
run: prek run --all-files --show-diff-on-failure --color always
env:
GITHUB_TOKEN: ${{ github.token }}
SKIP: verify-md-examples,lychee,no-commit-to-branch
# The networked half, over the DIFF only. A pull request can only
# introduce a dead URL in a file it touched, and re-checking the other
# ~8.7k links costs minutes and depends on a cache that PR-scoped caches
# evict. Rot in untouched files is found by the scheduled sweep instead —
# it appears with nobody committing, so a pull request is the wrong place
# to look for it.
- name: Check links added or changed by this PR
if: github.event_name == 'pull_request'
run: |
git fetch --no-tags --depth=1 origin "$BASE_REF"
prek run lychee \
--from-ref "origin/$BASE_REF" --to-ref HEAD \
--show-diff-on-failure --color always
env:
GITHUB_TOKEN: ${{ github.token }}
BASE_REF: ${{ github.base_ref }}
# The full network sweep lives in `link-check.yml` now. Making a dead
# third-party URL a dependency of the `CI` fan-in meant somebody else's
# expired domain turned this repository red; it reports into a standing
# issue instead. `lychee-offline`, above, still covers every relative
# link in the tree — the half a change here can actually break.
# Half an hour of runner time, and a real gate: it runs on the rebuild
# path too, because a dispatched run of this workflow exists precisely to
# say whether a commit on the branch builds.
- name: Verify markdown runnable examples
# A transient load spike after Nix setup selected one worker for all
# 272 examples and exhausted the 35m job budget. Bound fan-out to two
# on this four-core runner, retaining headroom for compiler memory.
env:
SPARKLES_CI_JOBS: '2'
run: |
# The pre-commit shell exposes the packaged verifier, not DUB.
nix develop .#ci --command dub run --single apps/ci/tools/prepare-event-horizon-comparisons.d --temp-build -b checked
nix run .#ci -- --verify --fail-fast --include-files '**.md' --exclude-files 'libs/syntax/test/data/**' 'libs/source-view/test/data/**'
- name: Save lychee cache
if: always()
uses: actions/cache/save@v6
with:
path: .lycheecache
enableCrossOsArchive: true
# Same reasoning as the CircleCI `lint` job: lychee's cache has to
# keep growing, and neither provider overwrites an existing key — so
# the key changes every run while the restore prefix stays stable.
# The old fixed `cache-lychee-v3` key made every save after the first
# a silent no-op, freezing the cache at its first-ever contents.
key: cache-lychee-v4-${{ github.run_id }}
docs:
name: Build documentation site
runs-on: ubuntu-latest
env:
# Corepack asks before downloading the packageManager pin. CI must not.
COREPACK_ENABLE_DOWNLOAD_PROMPT: '0'
# The build itself runs ~11m; under runner contention the nix teardown
# stretched past a 15m cap and cancelled after every real step had
# succeeded. 30m matches the test job's headroom.
# The site embeds the wasm modules, which need the wasm toolchain; when
# the cache lacks it (see `nix-build`) this job rebuilds it too, and 30
# minutes was not enough for that.
timeout-minutes: 40
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Nix
uses: ./.github/actions/setup-nix
with:
push-to-cache: true
cachix-cache: ${{ vars.CACHIX_CACHE }}
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
trusted-public-keys: ${{ vars.TRUSTED_PUBLIC_KEYS }}
substituters: ${{ vars.SUBSTITUTERS }}
use-nix-cache: true
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
# setup-node's `cache: yarn` calls `yarn --version` while the action
# runs, before Corepack has put Yarn on PATH. Enable Corepack first,
# then cache the folder Yarn itself reports.
- name: Enable Corepack
env:
COREPACK_ENABLE_DOWNLOAD_PROMPT: '0'
run: corepack enable
- name: Get Yarn cache directory
id: yarn-cache
run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- name: Cache Yarn
uses: actions/cache@v6
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: yarn-${{ runner.os }}-
- name: Install dependencies
run: yarn install --immutable
# The site build sits just under node's default ~4 GB heap and tips over
# it intermittently (the failure is an "Ineffective mark-compacts near
# heap limit" abort, not a dead link), so give V8 room rather than
# re-running the job until it lands on the right side of the line.
- name: Build VitePress site
env:
NODE_OPTIONS: --max-old-space-size=8192
run: yarn docs:build
# The single required check. Branch protection and the merge queue gate on
# job *names*, so without a fan-in every new job has to be added to that
# list by hand — and until someone does, it can fail while PRs merge green.
# Gate on `CI` instead and this stays true as jobs come and go.
#
# `if: always()` so the job still runs when a dependency failed (otherwise
# it would be skipped, which branch protection reads as "not failed").
# Skipped dependencies are tolerated; failed and cancelled ones are not.
#
# Reported to GitHub as `CI`. That is the one check to require when GitHub
# Actions is the primary provider (it currently is); CircleCI's equivalent is
# `ci/circleci: ci`. Switching providers is that single change — see
# ci/README.md § Switching the primary provider.
# `ci/prepare-cloud-env.sh` is the one-line setup an ephemeral agent container
# runs (Claude Code on the web, and anything comparable). It is the only entry
# point here that bootstraps its *own* toolchain, so it cannot be covered by
# the jobs above — they all start from `setup-nix`, which is exactly the state
# the script exists to create. Hence a job that deliberately does not use it:
# bare `actions/checkout`, no Nix, no caches, nothing on PATH.
#
# The `restricted` leg is the point of the matrix. A hosted runner has open
# egress, so the script's probes would take the fast native paths and the two
# workarounds that make an agent container work at all — the flake.lock
# github:→git+https rewrite, and seeding dub registry packages from git —
# would never execute. CI_FORCE_GIT_INPUTS / CI_FORCE_GIT_DEPS force them, so
# both paths are exercised without needing a proxy that blocks codeload.
cloud-env:
name: cloud-env (${{ matrix.mode }})
strategy:
fail-fast: false
matrix:
include:
# What a container with open egress gets: tarball flake inputs and a
# plain `dub` registry fetch.
- mode: native
force: '0'
# What an agent container actually gets.
- mode: restricted
force: '1'
runs-on: ubuntu-latest
# Cold: installs Nix, resolves 33 flake inputs and substitutes devShells.ci.
# The `restricted` leg additionally clones every input over git. This is
# minutes *provided the Cachix caches apply* — the script asserts that, so a
# regression fails fast here rather than compiling LDC and DMD until the cap.
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v7
# No setup-nix, no cache restore, no devshell: proving the script needs
# none of them is the test.
- name: Assert the runner starts clean
run: |
if command -v nix >/dev/null 2>&1; then
echo "::error::nix is already on PATH; this job must start without it"
exit 1
fi
if command -v dub >/dev/null 2>&1; then
echo "::error::dub is already on PATH; this job must start without it"
exit 1
fi
- name: Run the setup script
run: ci/prepare-cloud-env.sh
env:
# The CI floor — smallest closure, and the shell this repo's own jobs
# use. Also covers the documented CI_DEVSHELL override.
CI_DEVSHELL: ci
CI_FORCE_GIT_INPUTS: ${{ matrix.force }}
CI_FORCE_GIT_DEPS: ${{ matrix.force }}
# A separate step on purpose: the script exports PATH through `ci_export`,
# so this only passes if that export actually reached $GITHUB_ENV — the
# failure mode the script exists to prevent (a toolchain that works in the
# setup shell and nowhere else).
- name: Toolchain survives into a later step
run: |
echo "dub: $(command -v dub)"
echo "ldc2: $(command -v ldc2)"
dub --version
ldc2 --version | head -n1
# The failure this job was written to catch second: the script can look
# like it worked while every derivation was built from source, because a
# daemon silently discards an untrusted user's substituter list. That is
# an hour of compiling, not an error. Assert the caches are in effect.
- name: Project caches are actually in effect
run: |
substituters=$(nix config show substituters)
echo "substituters: $substituters"
echo "trusted user: $(nix store info --json)"
case "$substituters" in
*sparkles.cachix.org*) ;;
*) echo "::error::sparkles.cachix.org is not an effective substituter"; exit 1 ;;
esac
- name: Tests actually run
run: dub test :base
- name: Re-running is idempotent
run: ci/prepare-cloud-env.sh
env:
CI_DEVSHELL: ci
CI_FORCE_GIT_INPUTS: ${{ matrix.force }}
CI_FORCE_GIT_DEPS: ${{ matrix.force }}
# The rewrite is a local accommodation. If it ever reaches a commit, every
# other consumer of this lock gets shallow git inputs and the `--inputs-from`
# resolution that `ci_nix_run` depends on stops working.
- name: flake.lock is not modified in a way that could be committed
run: |
if [ -n "$(git diff --name-only -- flake.lock)" ]; then
echo "::error::flake.lock shows as modified; the skip-worktree guard did not hold"
git diff --stat -- flake.lock
exit 1
fi
# Complete authenticated Unicode 18 corpora, not samples. The Nix check
# builds the existing harness offline and executes it in the build sandbox.
text-conformance:
name: Owned Unicode conformance
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Nix
uses: ./.github/actions/setup-nix
with:
push-to-cache: true
cachix-cache: ${{ vars.CACHIX_CACHE }}
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
trusted-public-keys: ${{ vars.TRUSTED_PUBLIC_KEYS }}
substituters: ${{ vars.SUBSTITUTERS }}
- name: Run complete offline official corpora
run: nix build -L .#checks.x86_64-linux.text-conformance
# Every symbol a spec's evidence column cites must exist in the tree. The
# pre-commit hook of the same name runs it too (and so does `lint`, through
# `prek run --all-files`), but a hook can be skipped and a lint failure
# reads as formatting. This is the gate, named for what it checks. The
# backlog it started with (`docs/specs/evidence-backlog.txt`) is empty, so
# any unresolved citation fails here.
#
# `.#ci-minimal`, not `.#ci`: the check only reads files and runs `git
# ls-files`, so the toolchain in `.#ci`'s closure would be 2.4 GiB of
# nothing. For the same reason no `/nix` cache is restored.
spec-evidence:
name: Spec evidence
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Nix
uses: ./.github/actions/setup-nix
with:
trusted-public-keys: ${{ vars.TRUSTED_PUBLIC_KEYS }}
substituters: ${{ vars.SUBSTITUTERS }}
use-nix-cache: false
- name: Every cited symbol resolves
run: nix run .#ci-minimal -- --check-spec-evidence
# Same shape of check, same job: it reads the credits document and the
# build inputs, nothing more (docs/specs/terminal/pages.md TPG14).
- name: Every shipped component is credited
run: nix run .#ci-minimal -- --check-credits
ci:
name: CI
needs:
- test
- extracted-tests
- sanitize
- nix-build
- nix-build-android
- shader-reproducibility
- lint
- docs
- cloud-env
- spec-evidence
- text-conformance
if: always()
runs-on: ubuntu-latest
steps:
- name: Check that every job succeeded or was skipped
run: |
results='${{ join(needs.*.result, ' ') }}'
echo "dependency results: $results"
for r in $results; do
case "$r" in
success|skipped) ;;
*) echo "::error::a required job reported '$r'"; exit 1 ;;
esac
done