ci: split CI — fast native gate every PR, hermetic nix flake check only on nix/dep changes #32

Closed
opened 2026-07-07 23:24:23 +02:00 by dominik.polakovics · 1 comment

Idea from the maintainer, refined against real CI timing data on 2026-07-07. The decisions below are binding — do not re-litigate them. A complementary infra lever (warm the nix store) is tracked separately for triage (see comments).

Request

Split CI so the common path is fast. Today .forgejo/workflows/ci.yml runs a single flake-check job on every PR (install Determinate nix → nix flake check -L) at a measured ~12–13 min/run. Keep a fast native gate (Go + Node directly, with setup-* caching) on every PR, and run the hermetic nix flake check gate only when nix or Go-dependency files change.

Why (measured)

From the Forgejo Actions API (last ~10 runs):

  • flake-check (PR gate): 12m18s / 12m35s / 13m05s / 12m25s; 16m35s on a failure.
  • bump-nixos-pin (deploy, same Determinate installer): ~2m — the natural control (it early-exits before test-configuration).

So the installer is only ~1.5–2 min; the other ~10 min is nix flake check realizing the whole closure stone-cold every run — the Docker-backed runner is ephemeral, /nix is wiped per job, so the Go toolchain + node + golangci-lint closures (hundreds of MB from cache.nixos.org), the ~160 MB npm deps, and the Go vendor modules are re-fetched and rebuilt every time. Merely "putting nix in an image" would save only the ~2 min install, not the ~10 min cold store — hence: skip nix entirely on the common path.

Current behavior

  • .forgejo/workflows/ci.yml: on: pull_request, one job flake-check on ubuntu-latest, curl Determinate installer (--init none) → nix flake check -L.
  • Flake checks (flake.nix L58–154): lab (Go build + full go test with real git/tmux/util-linux in nativeCheckInputs, built -tags ui against the copied SPA dist), labctl (build only), web (npm run lint + format:check + npm test/vitest + vite build), golangci-lint (whole-program, untagged, CGO_ENABLED=0), nixos-module (eval-only — greps the systemd unit text).
  • Branch protection requires the flake-check status.

Design decisions (binding)

1. Two workflows.

  • ci.yml → fast native gate. on: pull_request (no path filter). Always runs. Becomes the required status check. Give the job a stable id/name (e.g. native) so the required-check string is deterministic.
  • New ci-nix.yml → the existing nix flake check -L (same in-job Determinate install). on: pull_request with the paths: filter below.

2. Path filter for the nix gate. Run the nix job only when these change:

**/*.nix
flake.lock
go.mod
go.sum

Rationale: the only intrinsically-nix check is nixos-module (depends on nix/module.nix). The critical entries are go.mod/go.sumnix/package.nix pins vendorHash (a fixed-output hash over the vendored Go module set). A dependency change makes that hash stale → nix flake check fails, but the native gate (go mod download live) passes green — a dep bump would merge clean and rot the nix build for the next nix-touching PR (and the deploy). Gating the nix job on go.mod/go.sum closes that trap.

web/package-lock.json is deliberately not a trigger: importNpmLock reads the lockfile directly (no npmDepsHash), so npm deps never rot the nix build (per the nix/package.nix comment); its vitest/build coverage is already in the native gate.

3. The native gate mirrors the flake's non-nix checks, version-matched to the flake.

  • Go 1.26 (the go.mod gate) via actions/setup-go with module + build cache.
  • Node via actions/setup-node with npm cache; pin the major to match the flake's nodejs.
  • golangci-lint pinned to the version nixpkgs ships at the current flake.lock (e.g. nix eval nixpkgs#golangci-lint.version at rev d407951…), run golangci-lint run ./... untagged, CGO_ENABLED=0 (matches the flake's golangci-lint check). Drift between pin bumps is acceptable — the full untagged lint re-runs in the nix gate whenever flake.lock changes.
  • Ensure git, tmux, prlimit (util-linux) are on PATH for go test (the D17 real-subprocess bar) — install tmux if the base image lacks it.
  • Order (== "what make + local dev runs, without nix"): cd web && npm ci && npm run lint && npm run format:check && npm test && npm run build → copy web/distinternal/webui/dist (go:embed target, same as make build-ui) → go build -tags ui ./cmd/...go test -tags ui ./... (needs the dist for the ui-tagged embed; mirrors the flake's tagged checkPhase) → golangci-lint run ./... (untagged). Keep CGO_ENABLED=0 throughout.
  • setup-* actions must resolve on the runner (same actions source as the existing actions/checkout@v4). If they don't, the fallback is a container: image carrying Go 1.26 + Node + golangci-lint + tmux — but prefer setup-* + caching (no image to maintain).

4. Required-status-check switch (must get right). After the split, the native ci.yml job is the required check; the nix job reports a status only when its paths match, so on a Go/TS-only PR it won't run. A forge that "expects" a never-reporting required check can wedge the PR. Therefore the maintainer must switch branch protection's required check from flake-check to the native job's name — a repo setting the agent cannot change; call it out explicitly in the PR description. Keep the native job name stable for that reason.

5. ADR-0023. Write docs/adr/0023-ci-split-native-and-nix-gates.md recording the trade: this relaxes ADR-0002's "CI == a single nix flake check, local green == CI green" on the common path — native-green no longer guarantees nix-green for non-dependency reasons (sandbox behavior, the ui-embed placeholder compile, checks that exist only in the flake). The hermetic gate stays authoritative and still runs on every nix/dep change and as the merge-to-main backstop (bump-nixos-pintest-configuration rebuilds packages.lab). Update docs/ops.md "CI runner prerequisites" to describe the two gates.

6. Scope. Pure CI + docs (+ ADR). No change to flake.nix, nix/**, product source (Go/TS), or deploy.yml. Leave the store-postgres commented template where it is.

Acceptance criteria

  • Go/TS-only PR: only the native gate runs — builds the SPA, runs eslint + prettier + vitest, builds -tags ui, runs go test -tags ui ./... and golangci-lint, all green; the nix workflow is skipped.
  • PR touching **/*.nix / flake.lock / go.mod / go.sum: the nix flake check -L gate additionally runs and is green.
  • Native gate uses Go 1.26; golangci-lint version matches the nixpkgs-pinned version; git/tmux/prlimit present for go test.
  • ADR-0023 written (relaxes ADR-0002 on the common path; hermetic gate authoritative on nix/dep changes + deploy backstop); docs/ops.md CI section updated.
  • The branch-protection required-check switch is spelled out in the PR description for the maintainer to flip.
  • No diff to flake.nix, nix/**, Go/TS source, or deploy.yml.

Agent brief follows as a comment.

> Idea from the maintainer, refined against real CI timing data on 2026-07-07. The decisions below are **binding — do not re-litigate them.** A complementary infra lever (warm the nix store) is tracked separately for triage (see comments). ## Request Split CI so the common path is fast. Today `.forgejo/workflows/ci.yml` runs a single `flake-check` job on **every** PR (install Determinate nix → `nix flake check -L`) at a measured **~12–13 min/run**. Keep a **fast native gate** (Go + Node directly, with `setup-*` caching) on every PR, and run the hermetic **`nix flake check`** gate **only when nix or Go-dependency files change**. ## Why (measured) From the Forgejo Actions API (last ~10 runs): - `flake-check` (PR gate): **12m18s / 12m35s / 13m05s / 12m25s**; 16m35s on a failure. - `bump-nixos-pin` (deploy, **same** Determinate installer): **~2m** — the natural control (it early-exits before `test-configuration`). So the installer is only ~1.5–2 min; the other **~10 min is `nix flake check` realizing the whole closure stone-cold every run** — the Docker-backed runner is ephemeral, `/nix` is wiped per job, so the Go toolchain + node + golangci-lint closures (hundreds of MB from cache.nixos.org), the ~160 MB npm deps, and the Go vendor modules are re-fetched and rebuilt every time. Merely "putting nix in an image" would save only the ~2 min install, not the ~10 min cold store — hence: skip nix entirely on the common path. ## Current behavior - `.forgejo/workflows/ci.yml`: `on: pull_request`, one job `flake-check` on `ubuntu-latest`, curl Determinate installer (`--init none`) → `nix flake check -L`. - Flake `checks` (`flake.nix` L58–154): `lab` (Go build + full `go test` with real git/tmux/util-linux in `nativeCheckInputs`, built `-tags ui` against the copied SPA dist), `labctl` (build only), `web` (`npm run lint` + `format:check` + `npm test`/vitest + `vite build`), `golangci-lint` (whole-program, **untagged**, `CGO_ENABLED=0`), `nixos-module` (**eval-only** — greps the systemd unit text). - Branch protection requires the `flake-check` status. ## Design decisions (binding) **1. Two workflows.** - `ci.yml` → fast **native** gate. `on: pull_request` (no path filter). Always runs. Becomes the **required** status check. Give the job a stable id/name (e.g. `native`) so the required-check string is deterministic. - New `ci-nix.yml` → the existing `nix flake check -L` (same in-job Determinate install). `on: pull_request` with the `paths:` filter below. **2. Path filter for the nix gate.** Run the nix job only when these change: ``` **/*.nix flake.lock go.mod go.sum ``` Rationale: the only intrinsically-nix check is `nixos-module` (depends on `nix/module.nix`). The **critical** entries are `go.mod`/`go.sum` — `nix/package.nix` pins `vendorHash` (a fixed-output hash over the vendored Go module set). A dependency change makes that hash stale → `nix flake check` fails, **but the native gate (`go mod download` live) passes green** — a dep bump would merge clean and rot the nix build for the next nix-touching PR (and the deploy). Gating the nix job on `go.mod`/`go.sum` closes that trap. `web/package-lock.json` is deliberately **not** a trigger: `importNpmLock` reads the lockfile directly (no `npmDepsHash`), so npm deps never rot the nix build (per the `nix/package.nix` comment); its vitest/build coverage is already in the native gate. **3. The native gate mirrors the flake's non-nix checks, version-matched to the flake.** - **Go 1.26** (the `go.mod` gate) via `actions/setup-go` with module + build cache. - **Node** via `actions/setup-node` with npm cache; pin the major to match the flake's `nodejs`. - **golangci-lint pinned to the version nixpkgs ships at the current `flake.lock`** (e.g. `nix eval nixpkgs#golangci-lint.version` at rev `d407951…`), run `golangci-lint run ./...` **untagged**, `CGO_ENABLED=0` (matches the flake's golangci-lint check). Drift between pin bumps is acceptable — the full untagged lint re-runs in the nix gate whenever `flake.lock` changes. - Ensure **`git`, `tmux`, `prlimit` (util-linux)** are on PATH for `go test` (the D17 real-subprocess bar) — install `tmux` if the base image lacks it. - Order (== "what `make` + local dev runs, without nix"): `cd web && npm ci && npm run lint && npm run format:check && npm test && npm run build` → copy `web/dist` → `internal/webui/dist` (go:embed target, same as `make build-ui`) → `go build -tags ui ./cmd/...` → **`go test -tags ui ./...`** (needs the dist for the `ui`-tagged embed; mirrors the flake's tagged checkPhase) → `golangci-lint run ./...` (untagged). Keep `CGO_ENABLED=0` throughout. - `setup-*` actions must resolve on the runner (same actions source as the existing `actions/checkout@v4`). If they don't, the fallback is a `container:` image carrying Go 1.26 + Node + golangci-lint + tmux — but prefer `setup-*` + caching (no image to maintain). **4. Required-status-check switch (must get right).** After the split, the **native** `ci.yml` job is the required check; the nix job reports a status only when its paths match, so on a Go/TS-only PR it won't run. A forge that "expects" a never-reporting required check can wedge the PR. Therefore the maintainer must switch branch protection's required check from `flake-check` to the native job's name — **a repo setting the agent cannot change; call it out explicitly in the PR description.** Keep the native job name stable for that reason. **5. ADR-0023.** Write `docs/adr/0023-ci-split-native-and-nix-gates.md` recording the trade: this **relaxes ADR-0002's** "CI == a single `nix flake check`, local green == CI green" on the common path — native-green no longer guarantees nix-green for non-dependency reasons (sandbox behavior, the `ui`-embed placeholder compile, checks that exist only in the flake). The hermetic gate stays authoritative and still runs on every nix/dep change **and** as the merge-to-main backstop (`bump-nixos-pin` → `test-configuration` rebuilds `packages.lab`). Update `docs/ops.md` "CI runner prerequisites" to describe the two gates. **6. Scope.** Pure CI + docs (+ ADR). **No** change to `flake.nix`, `nix/**`, product source (Go/TS), or `deploy.yml`. Leave the `store-postgres` commented template where it is. ## Acceptance criteria - **Go/TS-only PR:** only the native gate runs — builds the SPA, runs eslint + prettier + vitest, builds `-tags ui`, runs `go test -tags ui ./...` and `golangci-lint`, all green; the nix workflow is **skipped**. - **PR touching `**/*.nix` / `flake.lock` / `go.mod` / `go.sum`:** the `nix flake check -L` gate additionally runs and is green. - Native gate uses **Go 1.26**; golangci-lint version matches the nixpkgs-pinned version; `git`/`tmux`/`prlimit` present for `go test`. - **ADR-0023** written (relaxes ADR-0002 on the common path; hermetic gate authoritative on nix/dep changes + deploy backstop); `docs/ops.md` CI section updated. - The branch-protection required-check switch is spelled out in the PR description for the maintainer to flip. - **No diff** to `flake.nix`, `nix/**`, Go/TS source, or `deploy.yml`. Agent brief follows as a comment.
Author
Owner

Agent Brief

Category: enhancement (CI / build infra)

Summary: Split the single nix flake check PR gate into (a) a fast native Go+Node gate that runs on every PR (the required check) and (b) a nix flake check gate path-gated to **/*.nix, flake.lock, go.mod, go.sum. Cuts the common-path PR from ~12–13 min to ~2–4 min. Write ADR-0023 relaxing ADR-0002 for the common path; flag the branch-protection required-check switch for the maintainer.

Files

  • .forgejo/workflows/ci.yml — replace the flake-check job with the native gate (stable job id, e.g. native).
  • .forgejo/workflows/ci-nix.ymlnew; the existing nix flake check -L + in-job Determinate install, on: pull_request with the paths: filter.
  • docs/adr/0023-ci-split-native-and-nix-gates.mdnew ADR (relaxes ADR-0002 on the common path; hermetic gate authoritative on nix/dep changes + bump-nixos-pin backstop).
  • docs/ops.md — "CI runner prerequisites" → describe the two gates.

Watch out for

  • vendorHash rotgo.mod/go.sum must be in the nix paths: filter (decision 2). This is the one correctness trap: without it, a dependency bump goes green on the native gate and rots the nix build.
  • go test must be -tags ui and run after the SPA dist is built and copied to internal/webui/dist, else the ui-tagged go:embed won't compile. golangci-lint runs untagged (the placeholder embed variant), CGO_ENABLED=0 — same as the flake.
  • golangci-lint version pinned to match nixpkgs at the current flake.lock; git/tmux/prlimit (util-linux) on PATH for go test.
  • Required check is a repo setting the agent cannot change — spell out the flake-checknative switch in the PR description.
  • setup-go / setup-node must resolve on the forgejo-runner (same actions source as checkout@v4); if not, fall back to a container: with the toolchain baked in.

Verify: open the PR with a Go/TS-only change → confirm only native runs and is green, nix workflow skipped; then push a go.mod or flake.lock touch on a scratch branch → confirm the nix gate fires and is green. (Or reason it from the paths: filter if a live push isn't practical.)

Out of scope: warming the nix store / persistent runner / binary cache (tracked in #33); any change to flake.nix, nix/**, product source, or deploy.yml.

## Agent Brief **Category:** enhancement (CI / build infra) **Summary:** Split the single `nix flake check` PR gate into (a) a fast **native** Go+Node gate that runs on every PR (the required check) and (b) a **`nix flake check`** gate path-gated to `**/*.nix`, `flake.lock`, `go.mod`, `go.sum`. Cuts the common-path PR from ~12–13 min to ~2–4 min. Write ADR-0023 relaxing ADR-0002 for the common path; flag the branch-protection required-check switch for the maintainer. **Files** - `.forgejo/workflows/ci.yml` — replace the `flake-check` job with the native gate (stable job id, e.g. `native`). - `.forgejo/workflows/ci-nix.yml` — **new**; the existing `nix flake check -L` + in-job Determinate install, `on: pull_request` with the `paths:` filter. - `docs/adr/0023-ci-split-native-and-nix-gates.md` — **new** ADR (relaxes ADR-0002 on the common path; hermetic gate authoritative on nix/dep changes + `bump-nixos-pin` backstop). - `docs/ops.md` — "CI runner prerequisites" → describe the two gates. **Watch out for** - **vendorHash rot** — `go.mod`/`go.sum` **must** be in the nix `paths:` filter (decision 2). This is the one correctness trap: without it, a dependency bump goes green on the native gate and rots the nix build. - `go test` must be **`-tags ui`** and run **after** the SPA dist is built and copied to `internal/webui/dist`, else the `ui`-tagged `go:embed` won't compile. golangci-lint runs **untagged** (the placeholder embed variant), `CGO_ENABLED=0` — same as the flake. - **golangci-lint version** pinned to match nixpkgs at the current `flake.lock`; `git`/`tmux`/`prlimit` (util-linux) on PATH for `go test`. - **Required check is a repo setting** the agent cannot change — spell out the `flake-check` → `native` switch in the PR description. - `setup-go` / `setup-node` must resolve on the forgejo-runner (same actions source as `checkout@v4`); if not, fall back to a `container:` with the toolchain baked in. **Verify:** open the PR with a Go/TS-only change → confirm only `native` runs and is green, nix workflow skipped; then push a `go.mod` or `flake.lock` touch on a scratch branch → confirm the nix gate fires and is green. (Or reason it from the `paths:` filter if a live push isn't practical.) **Out of scope:** warming the nix store / persistent runner / binary cache (tracked in **#33**); any change to `flake.nix`, `nix/**`, product source, or `deploy.yml`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#32
No description provided.