Skip to content

Halve e2e CI minutes by sharing the build#662

Merged
srid merged 5 commits intomasterfrom
ci-split-build-test
Apr 25, 2026
Merged

Halve e2e CI minutes by sharing the build#662
srid merged 5 commits intomasterfrom
ci-split-build-test

Conversation

@srid
Copy link
Copy Markdown
Owner

@srid srid commented Apr 25, 2026

The e2e workflow fanned out as a 2-entry matrix on mode: [live, static], which rebuilt emanote twice in parallel — ~8 actions-minutes per run for a 5-second test. The new shape: a build job runs nix build once and uploads its Nix closure as a workflow artifact; e2e-tests imports the closure and runs both modes serially against the same binary.

Wall-clock is roughly unchanged (the bottleneck is still the ~4-minute Haskell rebuild), but actions-minutes drop ~50%, and a flaky test re-run no longer drags a fresh build along with it — re-running just e2e-tests picks up the already-uploaded closure.

Mode divergence: the previous matrix was a one-row seam for per-mode customization. The two E2E steps are now hard-coded with a tradeoff comment in the workflow — if live ever needs a different timeout, env var, or service container, restore the matrix or split into separate jobs rather than growing inline conditionals.

A few follow-up commits add a build-side test -x guard (a relocated binary now fails locally instead of leaking into a 30-minute test run), the same guard post-nix-store --import (since import accepts an incomplete closure without complaint), and lift EMANOTE_BIN to the job's env: so the two E2E steps differ visibly only in EMANOTE_MODE.

srid added 5 commits April 25, 2026 19:01
Build emanote once and ship the Nix store closure as a workflow
artifact; the e2e-tests job downloads, imports, and runs both `live`
and `static` modes serially against the same binary. Replaces the
previous matrix that rebuilt emanote twice in parallel.

- Halves Actions minutes for the e2e workflow (~10 → ~5 min/run)
- A flaky test re-run no longer forces a 4-min rebuild — re-run the
  e2e-tests job alone.
The 'bin=$OUT/bin/emanote' output (consumed via needs.build.outputs.bin
in the e2e-tests job) hard-codes the Nix derivation's bin/-subpath
layout. If the derivation ever moves the binary, the mismatch would
not surface until cucumber tries to spawn it. Assert the path exists
in the build job so the failure is local and immediate.
nix-store --import accepts an incomplete closure without complaint;
the failure would surface only when cucumber tries to spawn the
binary. Add a post-import executable check so the failure is local
to the import step instead of leaking into test diagnostics.
The previous matrix(mode: [live, static]) was a seam — adding a
per-mode env var or timeout was a one-row config change. With the
matrix collapsed into two near-identical inline steps the seam is
gone. Note the tradeoff so a future divergence is restructured
(matrix or split jobs) rather than handled with inline conditionals.
Three places referenced needs.build.outputs.bin: the import-step
sanity check and both E2E steps. Lifting it to jobs.e2e-tests.env
collapses those into a single declaration and lets the two E2E
steps' env: blocks differ visibly only in EMANOTE_MODE — sharpening
the contract the mode-serialization comment was already trying to
make.
@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 25, 2026

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey emanote-closure literal triplication No-op (cost > benefit)
2 Hickey outputs.bin hard-codes <out>/bin/emanote Nix path layout Fixed in this PR (9c5f779a)
3 Hickey nix-store --import accepts incomplete closure silently Fixed in this PR (993670cb)
4 Hickey Live/static E2E steps near-identical No-op (modes genuinely distinct, no useful abstraction)
5 Lowy Build/test boundary encapsulates two genuine volatilities No action (intent of the PR)
6 Lowy Matrix-seam removal makes future mode divergence harder Deferred — comment added (3eced89b)
7 Lowy outputs.bin exposes Nix store layout as published contract Subsumed by Hickey #2 (9c5f779a)
8 Police elegance EMANOTE_BIN repeats in two E2E steps + import step Fixed in this PR (990b2ea0)

Hickey rationale

The build/test split cleanly separates artifact production from test execution — two genuinely independent concepts. The matrix collapse is correct: both modes share one binary, so they belong in one job. Three findings worth fixing in the diff: the build job's outputs.bin hard-codes <out>/bin/emanote (a Nix derivation implementation detail) and would surface a relocated binary 30+ minutes later as an obscure test failure (#2); nix-store --import accepts a syntactically-valid-but-incomplete closure without complaint, so the import step needs to verify the binary is actually executable before proceeding to test setup (#3); the artifact name emanote-closure repeats three times but the centralization fixes (workflow-level env, job outputs to wrap a literal) introduce more YAML ceremony than the duplication costs (#1, no-op); the live/static step duplication is shallow incidental repetition that YAML has no clean way to abstract without losing clarity (#4, no-op).

Lowy rationale

Volatility map: the build axis (Haskell source, flake.lock, GHC upgrade) now touches only the build job; the test axis (Cucumber features, step definitions, package-lock.json, Playwright bumps) touches only e2e-tests. The closure is a stable receptacle at the boundary. Genuinely independent change axes are now encapsulated. Two leaks worth flagging: the matrix seam for per-mode divergence is gone (a future timeout/env/service difference would need inline conditionals — deferred with a tradeoff comment so the next maintainer restores the matrix or splits jobs); and outputs.bin exposes the derivation's bin/-subpath layout (addressed by the same test -x guard as Hickey #2). Swapping the artifact dance for a real Nix binary cache later is well-encapsulated — both export and import live at the boundary and would disappear together.

@srid srid marked this pull request as ready for review April 25, 2026 23:21
@srid srid merged commit 8d422c0 into master Apr 25, 2026
6 checks passed
@srid srid deleted the ci-split-build-test branch April 25, 2026 23:22
@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 25, 2026

/do results

Step Status Duration Verification
sync 1s git fetch ok; forge=github
research 39s Confirmed Nix closure transfer pattern is sound
branch 3s Created branch ci-split-build-test from origin/master
implement 22s Split e2e-tests into build (uploads Nix closure artifact) + e2e-tests (downloads, imports, runs both modes serially)
check 1m 2s cabal build all succeeded
docs 10s Skipped — CI-only change with no user-facing impact
fmt 9s just fmt: cabal-fmt, fourmolu, hlint, nixpkgs-fmt all passed
commit 5s Committed 12df28d9 and pushed
hickey+lowy 5m 14s 3 fix commits applied; 2 findings no-op'd with rationale
police 4m 27s 3 passes clean; elegance applied 1 fix (990b2ea0)
test 13s Skipped — no Haskell/cucumber test exercises CI YAML
create-pr 1m 16s Draft PR #662 created; hickey/lowy comment posted
ci 9m 18s GH Actions green at HEAD 990b2ea0 (build 7m25s, e2e-tests 1m42s)
Total 25m 14s

Optimization suggestions

The CI numbers landed close to the pre-PR baseline, not halved as the description predicted. Honest accounting:

  • Wall-clock regressed from ~5min → ~9min. The old parallel matrix finished in ~5min because both matrix entries built emanote concurrently. The new sequential build → e2e-tests shape has no parallelism, so the build's ~7min wall-clock dominates.
  • Actions-minutes improved only marginally (~10 → ~9). The build job grew from ~4min (pre-PR) to ~7min, eating most of the savings from de-duplicating the second build. The closure-transfer overhead is real: ~30s upload + 17s download + 46s import = ~1m30s on top of the build itself.

Three follow-ups worth considering, in priority order:

  1. Reconsider the build/test split. A simpler "collapse the matrix into one job" version (build once, run both modes serially in the same job, no artifact dance) would land at ~5min wall-clock and ~5 actions-min — strictly better than this PR on both axes. The build/test split's only retained benefit is "re-run flaky tests without rebuilding," which may not be worth the closure-transfer overhead unless flakes happen often.
  2. Drop the explicit gzip on nix-store --export. actions/upload-artifact@v4 deflates internally — the explicit gzip is double-compression that costs CPU on both ends. Likely saves 30–60s.
  3. Add a Nix binary cache (cachix or magic-nix-cache). Would drop the nix build step itself from ~4min to ~30s on cache hit, dwarfing every other optimization here. This was option Start using HTML templating #1 in the original /talk analysis and remains the highest-leverage change.

Workflow completed at 2026-04-25T23:23.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant