Skip to content

fix(ci): report unfinished nightly runs and cut the test build cost - #1017

Merged
inureyes merged 5 commits into
mainfrom
fix/issue-1000-nightly-verify-timeout-reporting
Aug 4, 2026
Merged

inureyes merged 5 commits into
mainfrom
fix/issue-1000-nightly-verify-timeout-reporting

Conversation

@inureyes

@inureyes inureyes commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

nightly-verify had two independent defects: the cargo test step never finished inside the 180-minute job budget, and when the job was killed by that budget nobody was told. This fixes both. Coverage of the other three workspace members (#1007) is deliberately out of scope, and #964 is being fixed separately.

Phase 1: make an unfinished run visible

The old Report a red main step was conditioned on failure() and reported nothing on the three-hour runs.

A correction to the premise this was scoped on. The step was not skipped because a job timeout tears the job down before later steps can run. It was skipped because the condition did not match: a timeout-minutes kill surfaces as cancellation, and failure() is false while a job is cancelled. Later steps do still run. A throwaway probe on a one-minute job settled it directly:

probe step condition conclusion after the timeout
sleep 300 (none) cancelled
always step if: always() success, it ran
cancelled step if: cancelled() success, it ran
failure step if: failure() skipped

The probe also showed job outputs survive a timeout cancellation: the downstream job read both steps.sleeper.outcome (cancelled) and a custom output set before the sleep.

So a step-level condition fix would have covered the timeout case. Reporting still moves to a separate report job, for three failure modes no in-job step can reach:

  1. Fate-sharing, and this one actually happened. The 2026-08-03 scheduled run (30842184319) failed in "Ensure build tools" because brew was not on the self-hosted runner's PATH, and the in-job reporting step then died the same way, brew install gh exiting 127. A genuinely red run reported nothing, for a reason unrelated to timeouts.
  2. Runner loss. If the self-hosted runner disappears mid-job, no step in that job runs at all.
  3. The post-cancellation window is bounded. The probe's step was one echo. A reporter that has to install gh and make several API calls is far likelier to be cut off.

The new job runs on ubuntu-latest (ships gh, no Homebrew needed, and keeps reporting off the scarce macOS runner), uses needs: verify + if: always(), and reads needs.verify.result. failure and cancelled produce different titles, so the two kinds dedup onto separate tracking issues:

  • failure produces [nightly-verify] main is red
  • cancelled produces [nightly-verify] the run did not finish

The existing body composition is preserved (step-outcome table, commit, run URL) with per-step outcomes passed across the boundary via job outputs, plus the verify job result, the trigger, and the job duration. Duration is what separates "ran out of budget" from "a person cancelled it", so the body states it and points at timeout-minutes rather than hardcoding 180, which would drift.

workflow_dispatch reporting: now included

Reversed from the previous rule. That rule assumed a manual dispatch has someone watching, so filing at them is noise. The 2026-08-02 dispatch disproved it: it ran three hours, timed out, told nobody, and was only found because someone went looking. Nobody watches a job for three hours. The reintroduced noise is bounded because repeat dispatches comment on one deduplicated issue rather than filing new ones, and the body names the trigger so a reader can dismiss a run they cancelled themselves.

Phase 2: cut the build cost

verify-test now builds under [profile.test-fast] instead of [profile.release]. --profile test-fast and --release are mutually exclusive, so the flag is replaced, not added.

The budget was going to codegen and linking, not to tests: run 30733081983 logged zero Running tests/ lines and zero test result: lines, and the processes the runner terminated were ld and clang. [profile.release] is fat LTO plus codegen-units = 1, measured at 4 to 6 minutes per incremental rebuild of the ~390k-line main crate, paid across roughly 77 test binaries.

The tradeoff, stated plainly

opt-level = 3 is unchanged, so the optimized MLX numerics the suite depends on are the same. What is dropped is fat LTO and codegen-units = 1, which exist to tune a shipped binary.

Still covered: everything the suite asserts, under optimized codegen, with metal,accelerate, on the real hardware class. panic = "unwind" is inherited from release, so the catch_unwind isolation tests behave identically.

No longer covered: a defect that reproduces only under fat LTO or single-unit codegen. The f16 reduction-order jitter class this repo has hit before is the realistic example. cargo test --release --features metal,accelerate by hand remains the tool for chasing one, and is documented as such.

Unaffected: what ships. release.yml still builds and links the shipping artifacts under [profile.release].

verify-clippy is left alone. It has no --release today, takes 67s warm on the runner, and changing it would risk a cold debug MLX build for no measured gain.

Two operational notes

  • The first nightly after this lands will still be slow, and that is expected. A different profile means a different OUT_DIR for the mlxcel-core build script, so the first run pays a full cold MLX C++ build against the persistent $HOME/.cargo-target/mlxcel. Do not read that run as a failed fix. Locally this cost was confirmed: a fresh 596M target/test-fast/build/mlxcel-core-*/out was produced.
  • Disk. The persistent target dir now holds two profile trees rather than one, order of 10 GiB each (locally target/release is 9.9G and target/test-fast is 10G). release.yml's 7-day prune is rm -rf of the whole directory, not a per-profile clean, so both are already bounded by it and no third rule is needed. A second cost worth naming: the nightly no longer refreshes [profile.release] artifacts, so release.yml's first build after a prune is colder than it used to be.

Local measurement, and its caveat

Development machine, M1 Ultra, not the runner. The self-hosted runner is a different and slower shared machine, so treat this as directional only.

cargo test --profile test-fast --features metal,accelerate --no-run

Cold for this profile, including the full MLX C++ build and the link of every root test binary: 14m56s wall clock, exit 0, 76 test executables. For contrast, on the runner the release-profile equivalent did not finish in 179 minutes on 2026-08-02.

The runner numbers are not mine to produce. The workflow has not been dispatched from this branch. The PR is ready to have the nightly dispatched against it for before and after.

One data point that arrived after the issue was written

Run 30761020020 (2026-08-02 schedule) succeeded, the first full pass the workflow has ever completed. Its cargo test step ran 2h54m34s inside the 180-minute budget: about 5 minutes of headroom, roughly 3%. That does not weaken the case, it sharpens it. The gate was passing on a knife edge, and #1007 proposes adding 1754 tests and three more link steps to it.

What changed

  • .github/workflows/nightly-verify.yml: new report job on ubuntu-latest with needs: verify and if: always(); outputs on verify to carry step outcomes across the boundary; the in-job Report a red main step removed; issues: write moved to the job that needs it; test step renamed; header comment updated for the profile tradeoff, the reporting rationale, the disk and cache-warming costs, and a correction to its stale "~30 min" figure that the 2h56m completed pass contradicts.
  • Makefile: verify-test builds --profile test-fast; the "how these differ" comment block and the test-fast section comment updated.
  • Cargo.toml: comment-only. [profile.test-fast] now documents that it backs verify-test, and the "do not use for anything that ships" note no longer points at verify-test.
  • CONTRIBUTING.md, docs/installation.md, .github/PULL_REQUEST_TEMPLATE.md: the documented gate matches what the gate now runs.
  • .github/workflows/ci.yml, .github/workflows/release.yml: comment-only, the quoted pre-push checklists said test(release).

No Rust source is touched.

Test plan

  • cargo fmt --all -- --check clean.
  • cargo clippy --all-targets --profile test-fast --features metal,accelerate -- -D warnings clean. Run under test-fast rather than a cold debug build because the diff contains no Rust changes and the Cargo.toml change is comment-only.
  • cargo test --profile test-fast --features metal,accelerate --no-run: exit 0, 76 executables, 14m56s cold.
  • Workflow YAML parses; both jobs and the if: expression resolve as intended.
  • The reporting script extracted and exercised as a standalone script against mocked gh and date in three states: timeout (cancelled, partial outputs, duration resolved to 180m21s from the real 30733081983 timings), genuine failure (failure, all outcomes present), and fully degraded (API unreachable and every output empty). All three exit 0 and emit a complete report; the degraded one reports unknown per step rather than failing.
  • Job-timeout semantics and output survival established by a throwaway probe run, branch deleted afterward.
  • python3 scripts/ci/check_cross_repo_refs.py clean.
  • Runner before and after timings, owned by the maintainer dispatching the nightly against this branch.

Closes #1000

`nightly-verify` had two independent defects: the `cargo test` step never finished inside the 180-minute job budget, and when the job was killed by that budget nobody was told. Three of the last five runs ended at exactly 3h00m and were reported as `cancelled`, which reads in the run list as "someone cancelled it".

Reporting. The old `Report a red main` step was conditioned on `failure()`, and a `timeout-minutes` kill surfaces as cancellation, where `failure()` is false. A throwaway probe on a job with `timeout-minutes: 1` established the exact semantics: after the timeout an `if: always()` step ran, an `if: cancelled()` step ran, only the `if: failure()` step was skipped, and job outputs survived. So a wider condition would have covered the timeout. Reporting still moves to a separate `report` job with `needs: verify` and `if: always()`, for three failure modes no in-job step can reach. The 2026-08-03 run is the concrete one: `verify` failed because `brew` was missing from the runner's PATH, and the in-job reporter then died the same way trying to `brew install gh`, so a genuinely red run reported nothing. The other two are outright runner loss, where no step in the job runs at all, and the bounded post-cancellation window, which a reporter that has to install `gh` is far likelier to exceed than the probe's single `echo`. The new job runs on `ubuntu-latest`, which ships `gh`, and reads `needs.verify.result` so the report distinguishes a red suite from a run that never finished. It reports on `workflow_dispatch` as well as `schedule`: the exemption assumed a manual dispatch has someone watching, and the 2026-08-02 dispatch ran three hours, timed out and told nobody.

Build cost. `verify-test` now builds under `[profile.test-fast]` instead of `[profile.release]`. The budget was going to codegen and linking, not to tests: the killed run logged zero `Running tests/` lines and the runner terminated `ld` and `clang`. `opt-level = 3` is unchanged, so the optimised MLX numerics the suite depends on are the same; fat LTO and `codegen-units = 1`, which exist to tune a shipped binary, are dropped. What that gives up is a defect reproducing only under release codegen, the f16 reduction-order jitter class among them. Nothing that ships changes: release.yml still builds and links under `[profile.release]`.

Docs and templates that named the old gate are updated with it: CONTRIBUTING.md, docs/installation.md, the PR template, and the pre-push checklists quoted in ci.yml and release.yml. The header comment in nightly-verify.yml carries the profile tradeoff, the reporting rationale, and a correction to its stale "~30 min" figure, which the one completed release-profile pass (2h56m) contradicts.

Refs #1000. Coverage across the other three workspace members is #1007 and deliberately not touched here; adding 1754 tests to a job that could not finish would only make it time out sooner.
@inureyes inureyes added type:bug Bug fixes, error corrections, or issue resolutions priority:high High priority status:review Under review labels Aug 4, 2026
Three comments in the report job overstated what a `timeout-minutes` cancellation destroys, which the probe run contradicts. Job outputs do survive a timeout cancellation, so the normal timeout report is fully populated and `unknown` is reserved for outright runner loss. Separately, `runs-on: ubuntu-latest` is what answers the Homebrew fate-sharing problem, while being a separate job at all is what answers runner loss and the bounded post-cancellation window; the previous wording attributed all three to the runner choice.

Comment-only, no behavior change.
The note told a reader that an `unknown` step outcome is the normal reading when the job did not finish. The probe run showed the opposite: job outputs survive a `timeout-minutes` cancellation, so a timeout report carries real per-step outcomes and `unknown` instead points at the runner being lost outright. The note now says that, which is the difference between "check the budget" and "check the runner".

Comment and report-text only, no behavior change.
…it is not

Two defects the first real run of this workflow exposed.

The `Ensure build tools` step assumed `cmake` or, failing that, `brew` is resolvable. Neither is: a non-login shell on the self-hosted runner does not source the profile that `brew shellenv` writes. Run 30842184319 (2026-08-03 schedule) and run 30870790330 (2026-08-04 dispatch, on this branch) both died there in about one second with exit 127, the second one before any gate could run, which is why this branch had no runner measurement to show. Both Homebrew prefixes are now prepended, Apple Silicon and Intel, and they are published to GITHUB_PATH so later steps inherit them. If cmake is still missing the step now says it is a provisioning problem and prints PATH, rather than failing on a missing `brew`.

The report from that run was titled "main is red" for a run that verified a pull-request branch, and its headline asserted that at least one of the three gates was genuinely red. Neither was true: the job died in a setup step, and fmt, clippy and test then reported `failure` only because the toolchain step had been skipped. The title now names the ref when it is not the default branch, which also keeps a branch run from reusing the scheduled runs' deduplicated issue, and the headline points the reader at the first failing step before the gate table.

Refs #1000.
Run 30870981405 got past the Homebrew fix and then failed in both clippy and test with `xcrun: error: unable to find utility "metal", not a developer tool or in PATH`, raised by cmake while resolving the MLX kernel headers under `.cargo-target/mlxcel/debug/build/mlxcel-core-*/out/build/_deps/mlx-src`. The runner's `xcode-select` points at the Command Line Tools, which ship no Metal compiler. This is the same condition docs/mla-absorbed-decode.md and docs/sparse-paged-decode.md already tell developers to fix by exporting DEVELOPER_DIR.

It stayed invisible because a warm CARGO_TARGET_DIR skips the MLX C++ build entirely, and every run until now was warm: run 30761020020 finished a full pass with a 71-second clippy on that basis. The first run that needed a cold build failed immediately. Since release.yml prunes the whole persistent target directory every 7 days, this was going to surface on its own regardless of the profile change.

The step now probes `xcrun -f metal`, and only if that fails walks the installed Xcode bundles, selecting the first whose Developer directory makes metal resolvable and publishing it as DEVELOPER_DIR for the later steps. When nothing works it says the runner is under-provisioned and prints `xcode-select -p` and the Xcode installs it found, rather than letting cmake fail hundreds of lines later with a header error that reads like a code problem.

Refs #1000.
@inureyes

inureyes commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Runner measurement

Dispatched on this branch after the two follow-up commits (a6f872806 Homebrew PATH, dbd5ee463 DEVELOPER_DIR). Run 30871982545, success.

step before (--release, run 30761020020) after (--profile test-fast, run 30871982545)
cargo fmt 4s 4s
cargo clippy 71s 71s
cargo test 2h54m34s 48m54s
job total 2h56m 50m18s
share of the 180-minute budget 97% 27%

A 3.6x reduction on the step that was consuming the entire budget.

The "after" number is an upper bound, not the steady state. The preceding run on this branch (30870981405) died in the Metal error before it could finish populating the test-fast tree, so this run paid most of a cold MLX C++ build on top of the test link. A run against an already-warm test-fast tree should come in lower.

The reporting path, verified end to end on the runner

Three observations across three dispatches, covering both directions:

run verify result report job outcome
30870790330 failure (died in Ensure build tools, 16s) success filed #1018
30870981405 failure (clippy and test, Metal) success commented on the same issue
30871982545 success skipped silent, as intended

The first of those is the fate-sharing case this PR's design argument rests on, and it was not hypothetical: the job died at brew, which is exactly where the old in-job reporter would have died too (it ran brew install gh). The reporter on ubuntu-latest was unaffected.

Two runner defects this surfaced, both fixed here

Neither was caused by the profile change; both were latent and would have surfaced on their own the next time release.yml pruned the persistent target directory.

  1. Homebrew is not on PATH for a non-login shell on the runner, so Ensure build tools exited 127 looking for brew after failing to find cmake. This had already taken down the 2026-08-03 scheduled run.
  2. xcrun cannot resolve metal, because xcode-select points at the Command Line Tools. Every warm run skipped the MLX C++ build and never noticed; the first cold build failed immediately. This is the same condition docs/mla-absorbed-decode.md tells developers to fix with DEVELOPER_DIR.

Also corrected: the report titled itself main is red for a run verifying a PR branch, and its headline asserted that one of the three gates was genuinely red when the job had actually died in a setup step and the gates only reported failure for want of a toolchain.

@inureyes
inureyes merged commit a6cf4b5 into main Aug 4, 2026
7 checks passed
@inureyes
inureyes deleted the fix/issue-1000-nightly-verify-timeout-reporting branch August 4, 2026 03:21
@inureyes inureyes self-assigned this Aug 31, 2026
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority:high High priority status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(ci): nightly-verify times out in the build phase and reports nothing when it does

1 participant