← prevci.ymlyaml · 886 linesall
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