diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f5ba8a88..e0d49a3a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,9 +79,12 @@ jobs: flake: name: Flake runs-on: ubuntu-latest - # A runner-level guard, not a target. The gate builds every check serially - # from a cold hosted store; the bedwars binary alone measured 620 to 670 - # seconds across four runs on 2026-07-28 and everything else is minutes. + # A runner-level guard, not a target. The gate realises every check from a + # cold hosted store in one `nix build --keep-going`, so nix schedules them + # up to `max-jobs` (auto, four here) rather than one at a time. The floor is + # the release compile plus the longest single gate behind it: on run + # 30500640846 that was roughly 500 seconds of crate graph and then + # `smash-e2e` at 639. timeout-minutes: 90 steps: - uses: actions/checkout@v4 diff --git a/nix/ci/flake-gate.nix b/nix/ci/flake-gate.nix index 8f84b45e3..5a965c370 100644 --- a/nix/ci/flake-gate.nix +++ b/nix/ci/flake-gate.nix @@ -101,9 +101,75 @@ let ${reasonArms}esac } - build() { - nix build --accept-flake-config --no-link --print-build-logs \ - "$flake#checks.${system}.$1" + # One `nix build` for the whole set, rather than one per name. + # + # The checks ran strictly one after another and nothing about a check + # needs the machine to itself, so all the serialisation bought was wall + # clock. Measured on GitHub Actions run 30500640846: 67 checks, 2102 + # seconds, of which `smash-e2e` was 639 and `bedwars-bow-e2e` was 546. + # The other 65 queued behind those two, and 55 of the 67 were under 20 + # seconds each. + # + # Concurrency is nix's own `max-jobs` and is deliberately not pinned + # here. `nix-installer-action` writes `max-jobs = auto`, so on the hosted + # runner it is the core count -- four, which the hud gate reads back off + # the same machine as `CPU 2% of 400%`. A number written into this script + # would be that machine's number on everybody else's. It also does not + # raise the ceiling: one `nix build` of one e2e check already scheduled + # its crate units four wide, so four is what the compile phase ran at + # before this. What changes is that whole checks overlap, and an e2e gate + # spends its time waiting on a server to boot rather than on a core. + # + # `--keep-going` is load bearing. Without it the first failed check + # abandons the rest, and every name after it would report as failed + # having never been attempted -- the opposite of the complete report the + # loops below exist to produce. + # + # The cost, named: an evaluation error in any one check now aborts the + # command before anything builds, so every name reports FAILED where only + # the broken one used to. nix prints the eval error with the offending + # attribute in it immediately above the report, which is where to look + # when every name goes red at once. + installables=() + for name in "''${enforced[@]}" "''${excluded[@]}"; do + installables+=("$flake#checks.${system}.$name") + done + + # `--print-build-logs` prefixes every line with the derivation that + # emitted it, which is what keeps interleaved logs attributable. The + # prefix is the derivation name and not the check name, so `smash-e2e` + # reads as `hyperion-smash-e2e`. + # + # The exit status is discarded on purpose: it says only that something + # failed, and which checks failed is the question the loops below answer + # per name. It is also the expected status whenever `excluded` is + # non-empty. + nix build --accept-flake-config --no-link --print-build-logs --keep-going \ + "''${installables[@]}" || true + + # Did this check pass? Asked of the store, never by building it again. + # + # `--max-jobs 0` forbids starting a local build, so this exits 0 exactly + # when the output is already realised and fails otherwise; `--builders ""` + # closes the same door for a contributor whose nix.conf names a remote + # builder. A plain `nix build` per name would instead rebuild every check + # that just failed, because a failed derivation is not in the store, and + # the slowest thing in the job would be paid for twice. + # + # Asked of nix rather than by comparing `.outPath` against the store, + # because cargoUnit content-addresses every crate unit and a derivation + # downstream of a content-addressed one has a deferred output path. + # `nix eval .#checks.${system}.smash-e2e.outPath` answers with a + # placeholder rather than a store path for 14 of the 67 checks, and those + # 14 are exactly the e2e gates. + # + # Output is dropped because the only thing on the failing path is nix + # explaining that it will not build with max-jobs 0, which is the answer + # rather than a problem. The build above is where a check says why it + # failed. + realised() { + nix build --accept-flake-config --no-link --max-jobs 0 --builders "" \ + "$flake#checks.${system}.$1" > /dev/null 2>&1 } status=0 @@ -112,7 +178,7 @@ let # Every failure is reported rather than the first one only, so a single # push can fix all of them. for name in "''${enforced[@]}"; do - if build "$name"; then + if realised "$name"; then echo "ok $name" else echo "FAILED $name" @@ -122,7 +188,7 @@ let done for name in "''${excluded[@]}"; do - if build "$name"; then + if realised "$name"; then echo "STALE $name" { echo "checks.${system}.$name is excluded from CI but now builds."