name: CI
on:
# Allow this workflow to be triggered manually:
workflow_dispatch:
# Allow this workflow to be run in merge queues:
merge_group:
# Run this workflow for all PRs:
pull_request:
# …and on `main` itself, where only `lint` runs — see the `if:` guards below.
#
# This is what populates the *base* cache scope. A `pull_request` run may
# only read caches written by its own `refs/pull/N/merge` ref, by the base
# branch, or by the default branch, and it may only *write* to its own ref.
# Without a run on `refs/heads/main` nothing ever lands in the shared scope,
# so every PR starts cold and then saves its own private copy of each cache
# — which is how 13 caches came to occupy 14.64 GB against a 10 GB quota,
# evicting each other by LRU.
#
# Seeding only lychee's cache is the deliberate trade: the ~1.8 GB `nix-v2-*`
# and `Linux-dub-*` entries stay per-PR, because warming those would mean
# running the whole matrix on every merge while CircleCI runs it too.
#
# That seeding is now a fallback rather than the plan. A cache written once
# per merge is, by construction, the least-recently-used entry in a repository
# whose pull requests write multi-gigabyte caches on every push — so it is
# always the first thing evicted, and raising the quota moves that threshold
# without changing the ordering. A pull request therefore no longer depends on
# it: `lychee-offline` covers the whole tree without the network, and the
# networked check runs over the diff. The full sweep is the `schedule` below.
push:
branches:
- main
# The full network link sweep. Rot appears with nobody committing, so it is
# found on a timer rather than by whichever pull request happens to be open.
schedule:
- cron: '17 4 * * *'
# 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 `main` run is not. It is the
# only writer of the base-scope cache above, so cancelling it on the next
# merge would leave every PR cold again.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
# `main` and `schedule` runs exist for the link sweep and the cache it
# seeds. Everything but `lint` is therefore pull-request-only, rather than
# duplicating a full matrix that CircleCI already runs on `main` — and, for
# the nightly, one that nothing has changed since the last run.
if: github.event_name == 'pull_request'
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
system: x86_64-linux
DC: ldc2
- runner: ubuntu-latest
system: x86_64-linux
DC: dmd
- runner: macos-latest
system: aarch64-darwin
DC: ldc2
runs-on: ${{ matrix.runner }}
# The Linux legs finish in ~4m warm; the macOS leg normally runs ~5m but the
# hosted Apple-Silicon runner occasionally hangs (a 5m job has stalled for
# 78m). The cap is uniform at 20m because the DUB cache key is the hash of
# the dub files, so bumping the `dmd:frontend` pin invalidates it and the
# leg pays a cold frontend build — ~7m under dmd, which overran a 12m cap.
# That is a build cost, not a runtime one, and it recurs on every pin bump.
#
# Raised to 30 for the OS-API windowing demos below, which add ~4m30s to the
# Linux ldc2 leg (41 dub packages built and run under xvfb/weston). That leg
# measured 19m23s against the old 20m cap — a 37-second margin, and the next
# thing to grow the job pushed it over: a run cancelled mid-demo at 20m07s.
# A cap this tight fails as a *cancellation* in the middle of a step, which
# reads as a hang rather than as "out of budget".
#
# The demos are a research sweep bolted onto the D test gate and now eat a
# quarter of its budget; moving them to their own parallel job is the better
# fix, left as follow-up so this stack does not restructure shared CI.
timeout-minutes: 30
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 }}-${{ matrix.DC }}-${{ hashFiles('**/flake.lock') }}
nix-cache-restore-prefixes: |
nix-v2-${{ runner.os }}-${{ matrix.DC }}-
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-
- name: Run flake checks
run: nix flake check
- name: Build and test with dub
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 }}
- name: Build and run examples
run: ci --example-files --fail-fast
# 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: runner.os == '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: runner.os == '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 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)
if: github.event_name == 'pull_request'
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
# The Win32 OS-API example builds with druntime's core.sys.windows (no Nix, no
# third-party deps), so it runs on a plain windows-latest runner via setup-dlang.
win32-example:
name: Win32 OS-API example (windows-latest)
if: github.event_name == 'pull_request'
runs-on: windows-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install D (LDC)
uses: dlang-community/setup-dlang@v2
with:
compiler: ldc-1.41.0
- name: Cache DUB packages
uses: actions/cache@v6
with:
path: ~/AppData/Local/dub
key: windows-dub-osapi
restore-keys: windows-dub-osapi
- name: Build & run the Win32 example
run: dub run --compiler=ldc2 --root=docs/research/window-system-integration/os-apis/win32/example
# Per-feature Win32 demos: bounded run via WSI_AUTO_EXIT (interactive parts
# are exercised manually / under Wine locally — see the manual-run queue).
- name: Build & run the Win32 demos
shell: bash
env:
WSI_AUTO_EXIT: 1
run: |
base=docs/research/window-system-integration/os-apis
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
# 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 }})
if: github.event_name == 'pull_request'
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.
timeout-minutes: 20
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
# 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). Linux-only:
# the NDK/SDK ship prebuilt for x86_64-linux alone. 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)
if: github.event_name == 'pull_request'
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: every link in the repository, on a schedule and
# on `main`, where minutes are free and the cache it writes lands in the
# base scope every other run can read.
- name: Check every link
if: github.event_name != 'pull_request'
run: prek run lychee --all-files --show-diff-on-failure --color always
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Verify markdown runnable examples
# Pull requests only: this compiles and runs every example, half an hour
# of runner time, and the non-PR runs exist for the link sweep.
if: github.event_name == 'pull_request'
run: |
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
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
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
cache: npm
- name: Install dependencies
run: npm ci
# 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: npm run 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:
name: CI
needs:
- test
- extracted-tests
- win32-example
- nix-build
- nix-build-android
- lint
- docs
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