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