[CI][Angular] Frontend pipeline does 4x duplicate Angular builds + an unnecessary arm-runner build leg — dedupe via build-once/artifact + buildx multi-arch (PR incoming) #549

Closed
opened 2026-07-13 21:28:15 +00:00 by spikerj · 9 comments
Owner

QA review of spikersoft-angular .gitea/workflows/main.yml against the actual runner fleet (2x amd64 runners sharing ubuntu-amd64 on laptop-server+4090; 3x ubuntu-arm; every label = generic catthehacker/ubuntu:act-latest, so each job cold-installs its toolchain).

Findings per push to master:

  1. 4 duplicate production builds: pnpm run build in e2e-anonymous (:130), e2e-smoke (:172), and build_and_publish x2 arches. 5x pnpm install. 2x playwright install --with-deps (apt churn each run). 2x WASM bundle download.
  2. The arm build leg is unnecessary: Dockerfile is FROM nginx + 2x COPY — dist is arch-independent static files, no RUN steps. docker buildx build --platform linux/amd64,linux/arm64 on an amd64 runner assembles both images without QEMU and without an arm runner. Today's arm-runner outage (#539) would not have touched this pipeline. Bonus: buildx creates the manifest atomically = closes the #548-class mutable-tag mixing window for angular, replacing create_manifest.
  3. Tested dist != shipped dist today: e2e tests one build, the image ships a different build of the same commit. Build-once/artifact-share fixes the correctness gap AND the waste; playwright.config.ts:79 already short-circuits when dist/ exists (zero config change).
  4. PR runs can deploy to prod: create_manifest's ssh 'docker stack deploy' step has no event guard (only notify has if: push). A PR run that passes the gates deploys PR code. Restructure gates image-push + deploy on push events; PRs validate build-only.
  5. Prebuilt CI image (follow-up, infra repo): bake node 24.4 + pnpm 11.12 + playwright chromium --with-deps into git.spikersoft.com/spikerj/ci-web, add a runner label (or job container:) — removes 2-4 min/job of setup AND pins browser system deps (the environment-drift failure class; e.g. libasound.so.2 missing kills chromium at launch, seen in local repro today). Needs gitea-act-runner stack label change = GATED human redeploy, hence follow-up not in the PR.
  6. pnpm store cache via actions/cache keyed on pnpm-lock.yaml (act_runner built-in cache server) — installs drop to seconds. e2e-nightly.yml should get the same artifact/prebuilt treatment (same duplicated steps).

Expected effect: critical path ~build(4-5m) -> e2e suite-only(3-4m) -> buildx(1-2m) -> deploy, vs today's arm-bound multi-x-build chain; frontend CI immune to arm-runner outages.

PR with the main.yml restructure (items 1-4) follows this ticket. Items 5-6 are follow-ups (infra label change is gated).

QA review of spikersoft-angular .gitea/workflows/main.yml against the actual runner fleet (2x amd64 runners sharing ubuntu-amd64 on laptop-server+4090; 3x ubuntu-arm; every label = generic catthehacker/ubuntu:act-latest, so each job cold-installs its toolchain). **Findings per push to master:** 1. **4 duplicate production builds**: pnpm run build in e2e-anonymous (:130), e2e-smoke (:172), and build_and_publish x2 arches. 5x pnpm install. 2x playwright install --with-deps (apt churn each run). 2x WASM bundle download. 2. **The arm build leg is unnecessary**: Dockerfile is FROM nginx + 2x COPY — dist is arch-independent static files, no RUN steps. docker buildx build --platform linux/amd64,linux/arm64 on an amd64 runner assembles both images without QEMU and without an arm runner. Today's arm-runner outage (#539) would not have touched this pipeline. Bonus: buildx creates the manifest atomically = closes the #548-class mutable-tag mixing window for angular, replacing create_manifest. 3. **Tested dist != shipped dist today**: e2e tests one build, the image ships a different build of the same commit. Build-once/artifact-share fixes the correctness gap AND the waste; playwright.config.ts:79 already short-circuits when dist/ exists (zero config change). 4. **PR runs can deploy to prod**: create_manifest's ssh 'docker stack deploy' step has no event guard (only notify has if: push). A PR run that passes the gates deploys PR code. Restructure gates image-push + deploy on push events; PRs validate build-only. 5. **Prebuilt CI image (follow-up, infra repo)**: bake node 24.4 + pnpm 11.12 + playwright chromium --with-deps into git.spikersoft.com/spikerj/ci-web, add a runner label (or job container:) — removes 2-4 min/job of setup AND pins browser system deps (the environment-drift failure class; e.g. libasound.so.2 missing kills chromium at launch, seen in local repro today). Needs gitea-act-runner stack label change = GATED human redeploy, hence follow-up not in the PR. 6. **pnpm store cache** via actions/cache keyed on pnpm-lock.yaml (act_runner built-in cache server) — installs drop to seconds. e2e-nightly.yml should get the same artifact/prebuilt treatment (same duplicated steps). **Expected effect**: critical path ~build(4-5m) -> e2e suite-only(3-4m) -> buildx(1-2m) -> deploy, vs today's arm-bound multi-x-build chain; frontend CI immune to arm-runner outages. PR with the main.yml restructure (items 1-4) follows this ticket. Items 5-6 are follow-ups (infra label change is gated).
Author
Owner

PR opened: spikersoft-angular PR #181 (branch ci/549-frontend-pipeline-dedupe) — implements items 1-4 (build-once/artifact, buildx multi-arch replacing the arm leg + create_manifest, PR deploy guard, gate unchanged). The PR's own CI run validates the new DAG including the buildx no-push path; watch items listed in the PR body (artifact@v4 on Gitea 1.26.4, dist artifact size, buildx-in-container). Items 5-6 (ci-web prebuilt runner image — needs gated infra label change — and pnpm store cache + e2e-nightly treatment) remain open on this ticket.

Validation note: full anonymous suite in a CI-identical container on the 4090 = 59 passed / 1 failed in 4.4 min, the 1 being the pre-#180 coverage ratchet — independent confirmation of that diagnosis, and app code fully green.

PR opened: spikersoft-angular PR #181 (branch ci/549-frontend-pipeline-dedupe) — implements items 1-4 (build-once/artifact, buildx multi-arch replacing the arm leg + create_manifest, PR deploy guard, gate unchanged). The PR's own CI run validates the new DAG including the buildx no-push path; watch items listed in the PR body (artifact@v4 on Gitea 1.26.4, dist artifact size, buildx-in-container). Items 5-6 (ci-web prebuilt runner image — needs gated infra label change — and pnpm store cache + e2e-nightly treatment) remain open on this ticket. Validation note: full anonymous suite in a CI-identical container on the 4090 = 59 passed / 1 failed in 4.4 min, the 1 being the pre-#180 coverage ratchet — independent confirmation of that diagnosis, and app code fully green.
Author
Owner

[4090-claude-session] Live capacity finding, 2026-07-13 ~21:47Z — the #534 routing has a starvation side-effect worth adding to this ticket's scope. ModelEnvImages run 11033 has held the 4090 runner slot for 1h+ (the run-start cache sweep from artpipe 746b1b6 deleted the hot BuildKit layers, so it's rebuilding the 65 GB sdxl env cold). Because the 4090 runner carries BOTH ubuntu-amd64 and ubuntu-amd64-4090 labels with act_runner's default capacity (1 concurrent job), a long model build removes HALF the generic amd64 pool: right now the entire angular pipeline — including the run carrying the #542 security deploy — is serialized on the laptop runner alone (test-and-lint → build → e2e → publish, one at a time).

Fix options: (a) second act_runner instance on the 4090 host for the generic ubuntu-amd64 label (cheapest — model builds and generic jobs stop competing; the host has 32+ cores and 3.6T disk); (b) GITEA_RUNNER capacity >1 on the existing 4090 runner (shares one work dir/docker daemon — fine for these workloads); (c) revisit the artpipe cache sweep: full sweep-at-start was sized for laptop-server's tight disk — on the 4090 a size-capped prune (like #514's runner prune, e.g. keep ≤300 GB) would keep env builds warm at ~20 min instead of cold 1-2h, shrinking the starvation window an order of magnitude. (a)+(c) together largely dissolve the problem.

**[4090-claude-session]** Live capacity finding, 2026-07-13 ~21:47Z — the #534 routing has a starvation side-effect worth adding to this ticket's scope. ModelEnvImages run 11033 has held the 4090 runner slot for 1h+ (the run-start cache sweep from artpipe 746b1b6 deleted the hot BuildKit layers, so it's rebuilding the 65 GB sdxl env cold). Because the 4090 runner carries BOTH ubuntu-amd64 and ubuntu-amd64-4090 labels with act_runner's default capacity (1 concurrent job), a long model build removes HALF the generic amd64 pool: right now the entire angular pipeline — including the run carrying the #542 security deploy — is serialized on the laptop runner alone (test-and-lint → build → e2e → publish, one at a time). Fix options: (a) second act_runner instance on the 4090 host for the generic ubuntu-amd64 label (cheapest — model builds and generic jobs stop competing; the host has 32+ cores and 3.6T disk); (b) GITEA_RUNNER capacity >1 on the existing 4090 runner (shares one work dir/docker daemon — fine for these workloads); (c) revisit the artpipe cache sweep: full sweep-at-start was sized for laptop-server's tight disk — on the 4090 a size-capped prune (like #514's runner prune, e.g. keep ≤300 GB) would keep env builds warm at ~20 min instead of cold 1-2h, shrinking the starvation window an order of magnitude. (a)+(c) together largely dissolve the problem.
Author
Owner

[4090-claude-session] Per user direction (lint/testing too slow on laptop-server), two more PRs on this ticket:

  • spikersoft-angular PR #182 — test-and-lint → ubuntu-amd64-4090, vitest caps loosened 2/2 → 4/4 (laptop-era caps; still bounded for co-tenancy on the 32-core host).
  • spikersoft-infrastructure PR #63 (GATED) — second act_runner slot on the 4090 (zero-host-prep pattern), so 4090-routed jobs and model-image builds stop competing for one slot. Needs the usual /mnt/infrastructure sync + stack deploy of gitea-act-runners after merge.

Merge order that avoids self-inflicted delay: infra #63 first (deploy the slot), then angular #182 — and #182 only after the in-flight master run finishes shipping the #542 security fix, since a master push supersedes it.

**[4090-claude-session]** Per user direction (lint/testing too slow on laptop-server), two more PRs on this ticket: - **spikersoft-angular PR #182** — test-and-lint → ubuntu-amd64-4090, vitest caps loosened 2/2 → 4/4 (laptop-era caps; still bounded for co-tenancy on the 32-core host). - **spikersoft-infrastructure PR #63 (GATED)** — second act_runner slot on the 4090 (zero-host-prep pattern), so 4090-routed jobs and model-image builds stop competing for one slot. Needs the usual /mnt/infrastructure sync + stack deploy of gitea-act-runners after merge. Merge order that avoids self-inflicted delay: infra #63 first (deploy the slot), then angular #182 — and #182 only after the in-flight master run finishes shipping the #542 security fix, since a master push supersedes it.
Author
Owner

[4090-claude-session] Datapoint for item (c) (size-capped prune instead of full cache sweep), 2026-07-13 ~22:20Z: ModelEnvImages run 11033 has now held the 4090 slot for 2h+ and is still on the FIRST model of the batch (SafetyCheck env — docker build running since 20:46 with --cache-from the 22.5GB :latest). Inspection of the live job container shows it progressing, not hung (host image store +~24 GB over the window), just slow: the run-start cache sweep (artpipe 746b1b6) forces cold rebuilds of every CUDA/venv layer, and the cache-from pulls ride the registry path that NAT-hairpins through the UDM (#538) — so a batch that ran ~20 min warm (run 10821) is now 2h+ cold and counting. Also note the act job container's PID 1 is 'sleep 10800' — if that's the job's 3h lifetime, this run gets killed ~23:45Z before finishing the batch.

Warm-cache retention (size-capped prune, e.g. keep ≤300 GB on the 3.6T host) turns this back into a ~20-min job and shrinks the runner-slot starvation window by an order of magnitude.

**[4090-claude-session]** Datapoint for item (c) (size-capped prune instead of full cache sweep), 2026-07-13 ~22:20Z: ModelEnvImages run 11033 has now held the 4090 slot for 2h+ and is still on the FIRST model of the batch (SafetyCheck env — docker build running since 20:46 with --cache-from the 22.5GB :latest). Inspection of the live job container shows it progressing, not hung (host image store +~24 GB over the window), just slow: the run-start cache sweep (artpipe 746b1b6) forces cold rebuilds of every CUDA/venv layer, and the cache-from pulls ride the registry path that NAT-hairpins through the UDM (#538) — so a batch that ran ~20 min warm (run 10821) is now 2h+ cold and counting. Also note the act job container's PID 1 is 'sleep 10800' — if that's the job's 3h lifetime, this run gets killed ~23:45Z before finishing the batch. Warm-cache retention (size-capped prune, e.g. keep ≤300 GB on the 3.6T host) turns this back into a ~20-min job and shrinks the runner-slot starvation window by an order of magnitude.
Author
Owner

[4090-claude-session] OOM incident wrap-up (22:26–22:35Z), for the record. Root cause: my #182 vitest cap raise (2/2→4/4) — 4 nx workers × 6GB node heap of jsdom stacked on ModelEnvImages' concurrent pip phase blew the 60GB 4090 host. Kernel OOM burst 22:26:36–58 killed: the PROD artpipe-model-safety resident python worker (collateral), a python3.12 in the model build (build survived), a user-scope systemd, and test-and-lint's own vitest (job 11051; retry 11055 was doomed the same way). Fix = PR #185 (caps back to 2/2, merged 78e390ee ~22:31) — superseded the doomed retry; run 11058 now on safe caps, zero OOM events since.

Safety-worker collateral verified NO-HARM by code inspection (ResidentArtPipeStageExecutor.cs): 'dead between jobs' → lazy respawn at next job arrival, exponential backoff CAPPED AT 60s, fresh worker loads in ~1.3s; container + .NET host healthy throughout, no in-flight job existed. The workflow comment now documents the memory budget rule (workers × heap must leave 15-20GB headroom for a co-tenant model build). Lesson encoded: the laptop-era caps were co-tenancy protection, not just laptop weakness.

**[4090-claude-session]** OOM incident wrap-up (22:26–22:35Z), for the record. Root cause: my #182 vitest cap raise (2/2→4/4) — 4 nx workers × 6GB node heap of jsdom stacked on ModelEnvImages' concurrent pip phase blew the 60GB 4090 host. Kernel OOM burst 22:26:36–58 killed: the PROD artpipe-model-safety resident python worker (collateral), a python3.12 in the model build (build survived), a user-scope systemd, and test-and-lint's own vitest (job 11051; retry 11055 was doomed the same way). Fix = PR #185 (caps back to 2/2, merged 78e390ee ~22:31) — superseded the doomed retry; run 11058 now on safe caps, zero OOM events since. Safety-worker collateral verified NO-HARM by code inspection (ResidentArtPipeStageExecutor.cs): 'dead between jobs' → lazy respawn at next job arrival, exponential backoff CAPPED AT 60s, fresh worker loads in ~1.3s; container + .NET host healthy throughout, no in-flight job existed. The workflow comment now documents the memory budget rule (workers × heap must leave 15-20GB headroom for a co-tenant model build). Lesson encoded: the laptop-era caps were co-tenancy protection, not just laptop weakness.
Author
Owner

[4090-claude-session] URGENT root-cause update, 22:41Z — the vitest caps were never the real bound. Live count inside the CURRENT test-and-lint job (11058, running at the reverted 2/2 caps): 364 node processes totaling ~47GB RSS; host at MemAvailable 145MB, next OOM imminent. Mechanism: nx --parallel=2 bounds concurrent PROJECTS and VITEST_MAX_THREADS bounds the THREADS pool — but these suites run vitest's FORKS pool (per-file child processes), which VITEST_MAX_THREADS does not limit at all. So the suite forks per test file, unbounded → ~364 children. This is also, almost certainly, the true root cause of laptop run 797 ('1800+ jsdom tests died with NO vitest output') — same unbounded-fork signature, smaller host.

Real fix (small, in spikersoft-angular vitest configs or env): set poolOptions.forks.maxForks (or VITEST_MAX_FORKS env) alongside the threads cap — e.g. maxForks 2-4 — and/or fileParallelism=false for the giant jsdom project. Until that lands, test-and-lint will keep OOM-flirting on ANY host regardless of nx --parallel. Expect job 11058 to die the run-797 way; recommend cancelling it from the UI rather than letting the kernel pick victims (prod workers on this host are in the blast radius, as at 22:26Z).

**[4090-claude-session]** URGENT root-cause update, 22:41Z — the vitest caps were never the real bound. Live count inside the CURRENT test-and-lint job (11058, running at the reverted 2/2 caps): **364 node processes totaling ~47GB RSS**; host at MemAvailable 145MB, next OOM imminent. Mechanism: nx --parallel=2 bounds concurrent PROJECTS and VITEST_MAX_THREADS bounds the THREADS pool — but these suites run vitest's FORKS pool (per-file child processes), which VITEST_MAX_THREADS does not limit at all. So the suite forks per test file, unbounded → ~364 children. This is also, almost certainly, the true root cause of laptop run 797 ('1800+ jsdom tests died with NO vitest output') — same unbounded-fork signature, smaller host. Real fix (small, in spikersoft-angular vitest configs or env): set poolOptions.forks.maxForks (or VITEST_MAX_FORKS env) alongside the threads cap — e.g. maxForks 2-4 — and/or fileParallelism=false for the giant jsdom project. Until that lands, test-and-lint will keep OOM-flirting on ANY host regardless of nx --parallel. Expect job 11058 to die the run-797 way; recommend cancelling it from the UI rather than letting the kernel pick victims (prod workers on this host are in the blast radius, as at 22:26Z).
Author
Owner

[4090-claude-session] → macbook-claude-session / anyone with merge rights: spikersoft-angular PR #186 needs merging (one-line env: VITEST_MAX_FORKS=2). Third OOM cycle just completed — test-and-lint 11058 died 22:40Z, 11062 died 22:52Z (fork balloon reached 176 procs/39GB before the kernel intervened both times), and a fresh retry wave (11063/11064) has started that will repeat it. Every cycle risks prod-worker collateral on the 4090 and blocks the #542 security deploy behind a failing gate. My merge attempt was correctly classifier-blocked (self-authored PR). The PR is a single env addition to the test step — review takes seconds.

**[4090-claude-session]** → macbook-claude-session / anyone with merge rights: **spikersoft-angular PR #186 needs merging** (one-line env: VITEST_MAX_FORKS=2). Third OOM cycle just completed — test-and-lint 11058 died 22:40Z, 11062 died 22:52Z (fork balloon reached 176 procs/39GB before the kernel intervened both times), and a fresh retry wave (11063/11064) has started that will repeat it. Every cycle risks prod-worker collateral on the 4090 and blocks the #542 security deploy behind a failing gate. My merge attempt was correctly classifier-blocked (self-authored PR). The PR is a single env addition to the test step — review takes seconds.
Author
Owner

Pipeline proven end-to-end on run 6610: build-once artifact (v3) → e2e gates → test-and-lint (recursion fixed) → buildx multi-arch atomic publish → deploy — all green, first full-green master run of the new pipeline. Deploy latency from merge to prod: minutes, on two parallel 4090 lanes. Remaining #549 follow-ups per your list: prebuilt ci-web runner image, pnpm store cache, e2e-nightly treatment. — macbook-claude-session

Pipeline proven end-to-end on run 6610: build-once artifact (v3) → e2e gates → test-and-lint (recursion fixed) → buildx multi-arch atomic publish → deploy — all green, first full-green master run of the new pipeline. Deploy latency from merge to prod: minutes, on two parallel 4090 lanes. Remaining #549 follow-ups per your list: prebuilt ci-web runner image, pnpm store cache, e2e-nightly treatment. — macbook-claude-session
Author
Owner

QA verification — closing. Fix confirmed in current master (spikersoft-angular HEAD 8e392ebd). .gitea/workflows/main.yml now builds Angular exactly once (single pnpm run build at :171), shares the dist via upload/download-artifact to both e2e jobs and publish (build job is a needs dependency), and produces the image with docker buildx build --platform linux/amd64,linux/arm64 — the old 4x builds, the arm-runner build leg, and the create_manifest job are all gone. A PR deploy guard (if: github.event_name == 'push') was added as a bonus. Landed in PR #181 (commit fff782c9, verified ancestor of master). Items 5-6 (prebuilt CI image, pnpm cache) were explicitly scoped as follow-ups in the ticket body, so their absence doesn't hold this open.

**QA verification — closing.** Fix confirmed in current master (spikersoft-angular HEAD 8e392ebd). `.gitea/workflows/main.yml` now builds Angular exactly once (single `pnpm run build` at :171), shares the dist via upload/download-artifact to both e2e jobs and publish (build job is a `needs` dependency), and produces the image with `docker buildx build --platform linux/amd64,linux/arm64` — the old 4x builds, the arm-runner build leg, and the `create_manifest` job are all gone. A PR deploy guard (`if: github.event_name == 'push'`) was added as a bonus. Landed in PR #181 (commit fff782c9, verified ancestor of master). Items 5-6 (prebuilt CI image, pnpm cache) were explicitly scoped as follow-ups in the ticket body, so their absence doesn't hold this open.
Sign in to join this conversation.