ci(390): shared CI toolchain image + drop 110s apt-ffmpeg + rebalance runner lanes #399

Merged
timothy merged 6 commits from feat/390-ci-toolchain-image into main 2026-07-17 12:40:25 +02:00
Owner

fixes #390

Builds the shared CI toolchain image (.NET 10 SDK + Node 22 + prod-identical ffmpeg) and converts the five toolchain jobs to run inside it via container:.

The scope grew beyond the issue text because I measured the jobs first. Full evidence in #390's first comment; the short version is that the issue's premise was partly wrong, so this PR ships the image plus the larger wins the audit actually found, and files the two it shouldn't bundle.

What's here

The image — docker/ci/Dockerfile + .gitea/workflows/ci-image.yml → ersatztv-ci:<sha>.
Layered FROM ersatztv-ffmpeg:8.1.2 + COPY --from=dotnet/sdk:10.0-noble-amd64 /usr/share/dotnet, the same pattern docker/Dockerfile uses for prod (our ffmpeg base is linuxserver/baseimage-ubuntu:noble — the same Ubuntu release as the SDK image, so glibc/ICU match). Already built and verified on the registry: node v22.23.1, ffmpeg 8.1.2, dotnet-ef 9.0.12, reportgenerator all resolve inside the pushed artifact.

Rolled-up wins (found by auditing every job's steps, per Timothy's steer):

  • Dropped the apt-ffmpeg step — 110s off every functional-e2e run. It was never load-bearing. The app's only startup ffmpeg touch is a LogWarning from FFmpegLocatorService; the readiness line has zero ffmpeg dependency; the harness drives curl-only contracts that never transcode; and git log -S shows it was added defensively in the harness's own commit, never fixing an observed failure. The image ships prod-identical ffmpeg regardless — the fidelity #299's seeded-media follow-ups will need.
  • Baked dotnet-ef + reportgenerator into the image — two per-run dotnet tool installs gone, versions now in one tracked place.
  • Moved api-docs (5s) and format (37s) to the idle small lane. They were each queueing ~29 min in the saturated ubuntu-latest lane. This also drops that lane from 5 jobs to 3, shortening the queue for the three heavy jobs. Only possible because container: makes them self-contained.

Filed, not bundled:

  • #398 — dotnet build runs 3× on the same commit (183+262+297 = 742s). The real code-side lever, but it's a genuine wall-clock-vs-total-CPU trade-off needing its own design.
  • server-management#604 — queue wait (12–35 min) exceeds every job's runtime. Runner capacity is their boundary.

Honest ROI

The image itself is worth only ~15–40s/job (≈3–8%) — setup-dotnet is 8–19s, not the minutes the issue assumed. Its durable value is prod-identical ffmpeg, a pinned toolchain, and making jobs runner-agnostic (which is what unlocked the lane rebalance). The 110s ffmpeg deletion and the lane move are the bigger immediate wins here.

Notes for review

  • Pin is an immutable :<sha>, never :latest — a bad toolchain push would otherwise break all five jobs at once. Repeated per job because jobs.<id>.container.image can't read the workflow env context; bumping is a documented two-step.
  • Two container gotchas handled in the image: git's dubious ownership guard (breaks actions/checkout + e2e-local.sh's git rev-parse), and resetting the ffmpeg base's ENTRYPOINT ["ffmpeg"].
  • setup-node's implicit npm cache is replaced with an explicit actions/cache on ~/.npm; NuGet cache unchanged. Project deps stay cached — only the toolchain moved into the image.
  • The Dockerfile carries a build-time smoke test (dotnet --info, node, ffmpeg, …) so a broken toolchain fails the image build rather than a CI run. It already earned its keep: it caught that reportgenerator --version exits 1.
  • Highest-risk conversion is migrations — it's the one job with a services: container; watch that it still reaches mysql:3306 from inside the job container.

Docs

docs/ci-cd.md: new "CI toolchain image" section (what it ships, layering, the two-step bump, the container gotchas, honest ROI) + lane-assignment table and the functional-e2e ffmpeg correction.

fixes #390 Builds the shared CI toolchain image (.NET 10 SDK + Node 22 + prod-identical ffmpeg) and converts the five toolchain jobs to run inside it via `container:`. **The scope grew beyond the issue text because I measured the jobs first.** Full evidence in [#390's first comment](http://192.168.1.95:3000/timothy/ersatztv/issues/390#issuecomment-11725); the short version is that the issue's premise was partly wrong, so this PR ships the image *plus* the larger wins the audit actually found, and files the two it shouldn't bundle. ## What's here **The image** — `docker/ci/Dockerfile` + `.gitea/workflows/ci-image.yml` → `ersatztv-ci:<sha>`. Layered `FROM ersatztv-ffmpeg:8.1.2` + `COPY --from=dotnet/sdk:10.0-noble-amd64 /usr/share/dotnet`, the same pattern `docker/Dockerfile` uses for prod (our ffmpeg base is `linuxserver/baseimage-ubuntu:noble` — the same Ubuntu release as the SDK image, so glibc/ICU match). Already built and verified on the registry: **node v22.23.1, ffmpeg 8.1.2, dotnet-ef 9.0.12, reportgenerator** all resolve inside the pushed artifact. **Rolled-up wins (found by auditing every job's steps, per Timothy's steer):** - **Dropped the apt-ffmpeg step — 110s off every `functional-e2e` run.** It was never load-bearing. The app's only startup ffmpeg touch is a `LogWarning` from `FFmpegLocatorService`; the readiness line has zero ffmpeg dependency; the harness drives curl-only contracts that never transcode; and `git log -S` shows it was added defensively in the harness's own commit, never fixing an observed failure. The image ships prod-identical ffmpeg regardless — the fidelity #299's seeded-media follow-ups will need. - **Baked `dotnet-ef` + `reportgenerator`** into the image — two per-run `dotnet tool install`s gone, versions now in one tracked place. - **Moved `api-docs` (5s) and `format` (37s) to the idle `small` lane.** They were each queueing **~29 min** in the saturated `ubuntu-latest` lane. This also drops that lane from 5 jobs to 3, shortening the queue for the three heavy jobs. Only possible because `container:` makes them self-contained. **Filed, not bundled:** - **#398** — `dotnet build` runs 3× on the same commit (183+262+297 = **742s**). The real code-side lever, but it's a genuine wall-clock-vs-total-CPU trade-off needing its own design. - **server-management#604** — queue wait (12–35 min) exceeds every job's runtime. Runner capacity is their boundary. ## Honest ROI The image itself is worth only **~15–40s/job (≈3–8%)** — `setup-dotnet` is 8–19s, not the minutes the issue assumed. Its durable value is prod-identical ffmpeg, a pinned toolchain, and making jobs runner-agnostic (which is what unlocked the lane rebalance). The 110s ffmpeg deletion and the lane move are the bigger immediate wins here. ## Notes for review - **Pin is an immutable `:<sha>`, never `:latest`** — a bad toolchain push would otherwise break all five jobs at once. Repeated per job because `jobs.<id>.container.image` can't read the workflow `env` context; bumping is a documented two-step. - Two container gotchas handled in the image: git's *dubious ownership* guard (breaks `actions/checkout` + `e2e-local.sh`'s `git rev-parse`), and resetting the ffmpeg base's `ENTRYPOINT ["ffmpeg"]`. - `setup-node`'s implicit npm cache is replaced with an explicit `actions/cache` on `~/.npm`; NuGet cache unchanged. Project deps stay cached — only the *toolchain* moved into the image. - The Dockerfile carries a build-time smoke test (`dotnet --info`, node, ffmpeg, …) so a broken toolchain fails the **image** build rather than a CI run. It already earned its keep: it caught that `reportgenerator --version` exits 1. - **Highest-risk conversion is `migrations`** — it's the one job with a `services:` container; watch that it still reaches `mysql:3306` from inside the job container. ## Docs `docs/ci-cd.md`: new "CI toolchain image" section (what it ships, layering, the two-step bump, the container gotchas, honest ROI) + lane-assignment table and the `functional-e2e` ffmpeg correction.
timothy added the ci-cd label 2026-07-17 09:16:33 +02:00
timothy added 3 commits 2026-07-17 09:16:34 +02:00
ci(390): add shared CI toolchain image (.NET 10 SDK + Node 22 + prod ffmpeg)
Build CI Toolchain Image / Build & push CI image (push) Failing after 1m42s
f27ff876d0
Layers the .NET 10 SDK + Node 22 onto our own ersatztv-ffmpeg base (the same
COPY --from=.../usr/share/dotnet pattern docker/Dockerfile uses for prod), and
bakes the dotnet-ef + reportgenerator global tools the jobs installed per run.

Builds/pushes 192.168.1.95:3000/timothy/ersatztv-ci:<sha> via a separate
workflow so a ~2GB toolchain image isn't rebuilt on every commit.

Refs #390
ci(390): fix CI-image smoke test — reportgenerator --version exits 1
Build CI Toolchain Image / Build & push CI image (push) Successful in 2m9s
6a9e27c6b8
The build-time smoke test failed the image build even though the toolchain was
healthy: reportgenerator prints its version banner then exits 1 ("No report
files specified"), so it has no clean version probe. Probe the dotnet-tool shim
on PATH instead. dotnet --info confirmed the SDK + .NET 10.0.10 runtimes resolve
correctly on the ffmpeg base (ICU/glibc fine).

Refs #390
ci(390): run toolchain jobs in the shared CI image; drop 110s apt-ffmpeg; rebalance lanes
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 4m31s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m22s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Failing after 6m8s
afa42b0b6f
Converts test/migrations/functional-e2e/api-docs/format to run inside
ersatztv-ci:6a9e27c via container:, removing per-run setup-dotnet, setup-node,
apt-get ffmpeg and dotnet tool install. NuGet/npm caches stay (project deps);
setup-node's implicit npm cache is replaced with an explicit actions/cache.

Rolled-up wins found while auditing every job's steps (evidence in #390):
- Drop the apt-ffmpeg step (110s/run). It was never load-bearing: the app's only
  startup ffmpeg touch is a LogWarning from FFmpegLocatorService, and the harness
  drives curl-only contracts that never transcode. The image ships prod-identical
  ffmpeg 8.1.2 anyway, which #299's seeded-media follow-ups will need.
- Bake dotnet-ef + reportgenerator into the image (2 per-run tool installs gone;
  versions now live in one tracked place).
- Move api-docs (5s) and format (37s) to the idle 'small' lane — they were each
  queueing ~29min in the saturated ubuntu-latest lane. Also drops that lane from
  5 jobs to 3. Only possible because container: makes them self-contained.

Measured, not assumed: the image itself is worth only ~15-40s/job. The dominant
costs are queue wait (server-management#604) and 742s of redundant compilation
(#398) — both filed rather than silently bundled here.

Refs #390
timothy added 1 commit 2026-07-17 09:26:48 +02:00
ci(390): default run steps to bash — act_runner uses sh inside container:
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 17s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 6m9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
84756eebab
The migrations job died in 0.13s with 'set: Illegal option -o pipefail'. Root
cause: inside a container: act_runner does not default to bash, it falls back to
'sh -e {0}' (dash), since it cannot assume bash exists in an arbitrary image.
Every multi-line script in this workflow is bash (set -o pipefail, arrays,
shopt, mapfile), so dash fails them immediately.

The format job passed only because it already declared shell: bash explicitly.
Declare it once at the workflow level instead of per step; non-container jobs
already defaulted to bash, so nothing changes for them.

Documented in docs/ci-cd.md as a container gotcha — it surfaces only once a job
is containerized and does not look like a shell problem.

Refs #390
timothy added 1 commit 2026-07-17 09:34:49 +02:00
ci(390): address cold review — pin-drift guard, honest cron, doc fixes
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Failing after 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 15s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 15s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build CI Toolchain Image / Build & push CI image (push) Successful in 29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 6m6s
07048b8c96
Cold adversarial review findings:
- MEDIUM (Renovate generates pin drift): add a blocking ci-image-pin job. Renovate
  manages docker/ci/Dockerfile's base pins but cannot bump an opaque :<sha> in
  container.image, so a base bump would publish a new image, test the OLD one, and
  merge with the Dockerfile disagreeing with the pin. The guard fails when the pin
  isn't the last commit to touch docker/ci, or when the 5 jobs pin different tags —
  making the documented two-step enforced rather than remembered.
- MEDIUM (cron was a no-op): the weekly rebuild updated nothing (jobs pin :<sha>) and
  buildcache would have restored the apt layer verbatim, collecting none of the base
  updates it existed for. Added no-cache on the schedule path and rewrote the comment
  to state what it actually is: a build canary + a fresh :latest for the next bump.
- LOW: FFMPEG_TAG was referenced in the Dockerfile + docs but never existed (the FROM
  is hardcoded); reworded both.
- LOW: paths: filtered the exact file while the docs claimed docker/ci/** — use **.
- NIT: docs oversold ENTRYPOINT reset as a gotcha; act overrides it anyway. Marked
  defensive.

Refs #390
timothy added 1 commit 2026-07-17 09:37:09 +02:00
ci(390): bump pin to ersatztv-ci:07048b8; compare full shas in pin guard
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 4m34s
ed52892d68
Pin bump for the image rebuilt from the review-fix commit (the two-step working
as designed — the new ci-image-pin guard demanded it).

Also fixes a real bug in that guard, caught locally before CI: it compared
abbreviated shas, but git auto-scales abbreviation length with object count, so
the tag ci-image.yml builds from a fetch-depth:1 shallow clone is 7 chars while
%h on the guard's full clone is 8 — it would have failed on every run. Now
resolves both sides to full shas via rev-parse, which also catches a pin that
names no commit at all.

Verified non-vacuous locally: PASS on the real pin, FAIL on a stale pin, FAIL on
an unresolvable one.

Refs #390
Author
Owner

Cold adversarial review — reconciliation

Ran an independent cold-context review agent (scoped "review only") over the diff. It returned BLOCKED with 1 Blocker, 1 High, 2 Medium, 3 Low, 3 Nits. It earned its keep: it verified claims against the actual pushed image rather than reasoning from the Dockerfile, and it independently reproduced the Blocker I'd hit in CI. All findings are addressed below.

🔴 Blocker — container: flips the default shell to sh (dash) — FIXED (84756eeb)

Independently confirmed by CI, not just argued: inside a container:, act_runner defaults run steps to sh -e {0}, not bash (it can't assume bash exists in an arbitrary image — nektos/act#1809). Every bashism dies with set: Illegal option -o pipefail. Run 986 evidence:

Job Result
migrations failure died in 0.13s at the SQLite step
functional-e2e failure died at the harness boot step
test "success" Coverage summary failed — swallowed by continue-on-error: true

The test case is the one that matters: without this fix we'd have merged a green pipeline whose coverage reporting was silently dead. format passed only because it already declared shell: bash.

Fixed with a workflow-level defaults: run: shell: bash. The reviewer suggested job-level, doubting workflow-level support in act — empirically settled: workflow-level works (all previously-failing steps now execute; final run linked below). Workflow-level is one declaration instead of five and covers future jobs, so I kept it. Documented in docs/ci-cd.md as a container gotcha, since it only appears once a job is containerized and looks nothing like a shell problem.

🟠 High — MySQL-from-inside-a-job-container unproven — BEING PROVEN

Correct: migrations died at the SQLite step, so the services: mysql resolution never ran. Not merging until that's green — see the final run.

🟡 Medium — the weekly cron was a no-op, twice over — FIXED (07048b8c)

Sharp catch, and both halves were right: (1) jobs pin :<sha>, so nothing consumes the rebuilt image; (2) mode=max buildcache would restore the apt-get layer verbatim and collect none of the base updates the cron existed for. It was burning a weekly ~2GB build for zero effect while the docs asserted otherwise.

Rather than delete it, I kept the one real benefit and made it honest: added no-cache: ${{ github.event_name == 'schedule' }}, and rewrote the comment + docs to state what it actually is — a build canary (catches "the toolchain no longer builds" at a time of our choosing, not when you next need it) plus a freshly-patched :latest for the next pin bump. It explicitly does not update running jobs.

🟡 Medium — Renovate actively generates pin drift — FIXED with a mechanical guard (07048b8c)

The best finding. Renovate manages docker/ci/Dockerfile's base pins but cannot bump an opaque :<sha> in container.image — so a Renovate base bump would publish a new image, run its jobs against the old pin (CI green-lighting a toolchain it never executed), and merge with the Dockerfile disagreeing with the pin. Silent, recurring, and inevitable.

Given this repo gates by construction rather than memory (H6/H9/H10/H11/H12), a doc note wasn't enough. Added a blocking ci-image-pin job (small lane, seconds) that fails when the pin isn't the last commit to touch docker/ci/**/ci-image.yml, or when the five jobs pin different tags. It makes the two-step enforced.

It also caught a bug in itself before CI did: comparing abbreviated shas fails always, because git auto-scales abbreviation length with object count — the tag built from fetch-depth: 1 is 7 chars, %h on the guard's full clone is 8. Now resolves both sides to full shas. Verified non-vacuous locally: PASS on the real pin, FAIL on a stale pin, FAIL on an unresolvable one.

I did not take the content-hash-tag alternative: it would race ci-image.yml against docker-build.yml on the same push (jobs pulling a tag that isn't pushed yet).

🟢 Low — all three FIXED (07048b8c)

  • FFMPEG_TAG never existed — I referenced an ARG I never introduced, in both the Dockerfile and the docs. Reworded to name the FROM line.
  • paths: claim mismatch — filtered the exact file while claiming docker/ci/**. Now genuinely docker/ci/**.
  • Pin names a feature-branch commit — accepted. This repo merges with merge commits (not squash), so the sha survives in main's history and the new guard keeps it honest.

⚪ Nits

  • ENTRYPOINT [] isn't needed — right, act overrides it (entrypoint=["/bin/sleep" "10800"]). Kept as belt-and-braces for running the image by hand, but the docs no longer sell it as a real gotcha (marked defensive, not load-bearing) — the reviewer was right that it sat misleadingly next to safe.directory, which genuinely is.
  • Env leakage from the ffmpeg base (LD_LIBRARY_PATH, VIRTUAL_ENV, MAKEFLAGS) — accepted knowingly: prod copies the runtime onto this same base, so CI now inherits prod's env, which is the fidelity we want.
  • Node pinning inverted (22.x-per-run → NodeSource-per-image-rebuild) — accepted; frozen-per-image-sha is the better property.

Coverage gap — being closed

The reviewer flagged that api-docs/format passed only via their trivial skip paths (this PR touches no .cs and no API path), so dotnet format --include and update-openapi.sh have never actually run inside the image. Fair. Closing that with a temporary commit that touches an API-surface .cs — exercising both full paths — then reverting it.

What the reviewer checked and found sound

Worth recording: it exec'd the pushed artifact on bumblebee and confirmed every binary the converted jobs invoke is present (awk sed grep tar zstd curl git python3 jq node npm dotnet ffmpeg ffprobe reportgenerator dotnet-ef …); that sudo is correctly absent now its only consumer is gone; that no JRE is needed (the openapi-generator jar is the prod image's build stage — update-openapi.sh is dotnet + python3 only); that the npm cache replacement resolves correctly (npm config get cache → /root/.npm, HOME=/root) with no job forgotten; and that the small lane move doesn't uncap anything (Memory:10g NanoCPUs:4 — same cap as bumblebee-runner).

## Cold adversarial review — reconciliation Ran an independent cold-context review agent (scoped "review only") over the diff. It returned **BLOCKED** with 1 Blocker, 1 High, 2 Medium, 3 Low, 3 Nits. It earned its keep: it verified claims against the *actual pushed image* rather than reasoning from the Dockerfile, and it independently reproduced the Blocker I'd hit in CI. All findings are addressed below. ### 🔴 Blocker — `container:` flips the default shell to `sh` (dash) — **FIXED** (`84756eeb`) Independently confirmed by CI, not just argued: inside a `container:`, act_runner defaults `run` steps to `sh -e {0}`, not bash (it can't assume bash exists in an arbitrary image — nektos/act#1809). Every bashism dies with `set: Illegal option -o pipefail`. Run 986 evidence: | Job | Result | | |---|---|---| | `migrations` | **failure** | died in 0.13s at the SQLite step | | `functional-e2e` | **failure** | died at the harness boot step | | `test` | **"success"** | Coverage summary failed — **swallowed by `continue-on-error: true`** | The `test` case is the one that matters: without this fix we'd have merged a green pipeline whose **coverage reporting was silently dead**. `format` passed only because it already declared `shell: bash`. Fixed with a workflow-level `defaults: run: shell: bash`. The reviewer suggested job-level, doubting workflow-level support in act — **empirically settled: workflow-level works** (all previously-failing steps now execute; final run linked below). Workflow-level is one declaration instead of five and covers future jobs, so I kept it. Documented in `docs/ci-cd.md` as a container gotcha, since it only appears once a job is containerized and looks nothing like a shell problem. ### 🟠 High — MySQL-from-inside-a-job-container unproven — **BEING PROVEN** Correct: `migrations` died at the *SQLite* step, so the `services: mysql` resolution never ran. Not merging until that's green — see the final run. ### 🟡 Medium — the weekly cron was a no-op, twice over — **FIXED** (`07048b8c`) Sharp catch, and both halves were right: (1) jobs pin `:<sha>`, so nothing consumes the rebuilt image; (2) `mode=max` buildcache would restore the `apt-get` layer verbatim and collect none of the base updates the cron existed for. It was burning a weekly ~2GB build for zero effect while the docs asserted otherwise. Rather than delete it, I kept the one real benefit and made it honest: added `no-cache: ${{ github.event_name == 'schedule' }}`, and rewrote the comment + docs to state what it actually is — a **build canary** (catches "the toolchain no longer builds" at a time of our choosing, not when you next need it) plus a freshly-patched `:latest` for the next pin bump. It explicitly does *not* update running jobs. ### 🟡 Medium — Renovate actively generates pin drift — **FIXED with a mechanical guard** (`07048b8c`) The best finding. Renovate manages `docker/ci/Dockerfile`'s base pins but **cannot** bump an opaque `:<sha>` in `container.image` — so a Renovate base bump would publish a new image, run its jobs against the **old** pin (CI green-lighting a toolchain it never executed), and merge with the Dockerfile disagreeing with the pin. Silent, recurring, and inevitable. Given this repo gates by construction rather than memory (H6/H9/H10/H11/H12), a doc note wasn't enough. Added a blocking **`ci-image-pin`** job (`small` lane, seconds) that fails when the pin isn't the last commit to touch `docker/ci/**`/`ci-image.yml`, or when the five jobs pin different tags. It makes the two-step *enforced*. It also caught a bug in itself before CI did: comparing **abbreviated** shas fails always, because git auto-scales abbreviation length with object count — the tag built from `fetch-depth: 1` is 7 chars, `%h` on the guard's full clone is 8. Now resolves both sides to full shas. Verified non-vacuous locally: PASS on the real pin, FAIL on a stale pin, FAIL on an unresolvable one. I did **not** take the content-hash-tag alternative: it would race `ci-image.yml` against `docker-build.yml` on the same push (jobs pulling a tag that isn't pushed yet). ### 🟢 Low — all three **FIXED** (`07048b8c`) - **`FFMPEG_TAG` never existed** — I referenced an ARG I never introduced, in both the Dockerfile and the docs. Reworded to name the `FROM` line. - **`paths:` claim mismatch** — filtered the exact file while claiming `docker/ci/**`. Now genuinely `docker/ci/**`. - **Pin names a feature-branch commit** — accepted. This repo merges with merge commits (not squash), so the sha survives in `main`'s history and the new guard keeps it honest. ### ⚪ Nits - **`ENTRYPOINT []` isn't needed** — right, act overrides it (`entrypoint=["/bin/sleep" "10800"]`). Kept as belt-and-braces for running the image by hand, but the docs no longer sell it as a real gotcha (marked *defensive, not load-bearing*) — the reviewer was right that it sat misleadingly next to `safe.directory`, which genuinely is. - **Env leakage from the ffmpeg base** (`LD_LIBRARY_PATH`, `VIRTUAL_ENV`, `MAKEFLAGS`) — accepted knowingly: prod copies the runtime onto this same base, so CI now inherits *prod's* env, which is the fidelity we want. - **Node pinning inverted** (`22.x`-per-run → NodeSource-per-image-rebuild) — accepted; frozen-per-image-sha is the better property. ### Coverage gap — being closed The reviewer flagged that `api-docs`/`format` passed only via their **trivial skip paths** (this PR touches no `.cs` and no API path), so `dotnet format --include` and `update-openapi.sh` have never actually run inside the image. Fair. Closing that with a temporary commit that touches an API-surface `.cs` — exercising both full paths — then reverting it. ### What the reviewer checked and found sound Worth recording: it exec'd the pushed artifact on bumblebee and confirmed every binary the converted jobs invoke is present (`awk sed grep tar zstd curl git python3 jq node npm dotnet ffmpeg ffprobe reportgenerator dotnet-ef …`); that `sudo` is correctly absent now its only consumer is gone; that **no JRE is needed** (the openapi-generator jar is the *prod* image's build stage — `update-openapi.sh` is `dotnet` + `python3` only); that the npm cache replacement resolves correctly (`npm config get cache` → `/root/.npm`, HOME=/root) with no job forgotten; and that the `small` lane move doesn't uncap anything (`Memory:10g NanoCPUs:4` — same cap as bumblebee-runner).
Author
Owner

Verification complete — all review findings closed, full run green

CI: run 993 @ ed52892d — every job green

test ✅ · migrations ✅ · functional-e2e ✅ · api-docs ✅ · format ✅ · ci-image-pin ✅ · docs-reminder ✅ · decisions-guard ✅ (build correctly skipped on PR)

The two open review items are now settled with evidence, not argument:

  • High (MySQL from inside a job container) — RESOLVED. migrations is green end-to-end; the MySql step ran and Server=mysql;Port=3306 resolves from inside the job container on ci-net. This was genuinely unproven before (the job had always died at the earlier SQLite step).
  • Blocker fix verified. Logs now show shell: bash --noprofile --norc -e -o pipefail {0} — workflow-level defaults works on act_runner v0.6.1, settling the reviewer's doubt empirically. functional-e2e reports "all functional-E2E assertions passed", booting with the image's prod ffmpeg and zero apt-ffmpeg occurrences. The coverage summary, previously dead-but-green, now reports "Found 6 coverage report(s)" and runs reportgenerator.

Coverage gap — CLOSED (throwaway PR #400, now closed; branch deleted)

The reviewer was right that api-docs/format had only ever taken their skip paths. Proven on a throwaway branch (#399's head + a comment on ErsatzTV/Controllers/Api/ArtistsController.cs) so this PR's head stayed untouched:

  • format: cs_changed=true → detected ArtistsController.cs → ran dotnet format --include inside the image → green.
  • api-docs: API surface changed → ran ./scripts/update-openapi.sh (needs dotnet build + python3) → "Generated API artifacts are in sync."

Both real paths execute correctly in the CI image.

Measured result (baseline run 978 → run 993)

Job Queue before Queue after Runtime before Runtime after
api-docs 1722s 3s 9s 14s
format 1731s 15s 41s 7s
migrations 1428s 679s 647s 272s
functional-e2e 1447s 840s 539s 274s
test 1363s 522s 359s 373s

Worst-job wall-clock: 34 min → 18 min.

Caveats, stated plainly: n=1 per side, and the large runtime drops on migrations/functional-e2e are substantially reduced lane contention, not toolchain savings — I am not claiming those for the image. The structural, non-confounded wins are: the queue collapse on api-docs/format (~29 min → seconds), format's 41s → 7s (setup-dotnet gone), and functional-e2e's −110s (apt-ffmpeg gone). The image itself remains worth ~15–40s/job, exactly as the audit predicted. Queue wait (server-management#604) and the 742s triple-build (#398) remain the dominant costs and are untouched by design.

Review-verdict: MERGEABLE @ ed52892d0aa4e6f0e6c9a0c40ff33bb4e2fa9d8f

## Verification complete — all review findings closed, full run green ### CI: run 993 @ `ed52892d` — every job green `test` ✅ · `migrations` ✅ · `functional-e2e` ✅ · `api-docs` ✅ · `format` ✅ · `ci-image-pin` ✅ · `docs-reminder` ✅ · `decisions-guard` ✅ (`build` correctly skipped on PR) The two open review items are now settled with evidence, not argument: - **High (MySQL from inside a job container) — RESOLVED.** `migrations` is green end-to-end; the MySql step ran and `Server=mysql;Port=3306` resolves from inside the job container on `ci-net`. This was genuinely unproven before (the job had always died at the earlier SQLite step). - **Blocker fix verified.** Logs now show `shell: bash --noprofile --norc -e -o pipefail {0}` — **workflow-level `defaults` works on act_runner v0.6.1**, settling the reviewer's doubt empirically. `functional-e2e` reports *"all functional-E2E assertions passed"*, booting with the image's prod ffmpeg and **zero** apt-ffmpeg occurrences. The coverage summary, previously dead-but-green, now reports *"Found 6 coverage report(s)"* and runs reportgenerator. ### Coverage gap — CLOSED (throwaway PR #400, now closed; branch deleted) The reviewer was right that `api-docs`/`format` had only ever taken their skip paths. Proven on a throwaway branch (#399's head + a comment on `ErsatzTV/Controllers/Api/ArtistsController.cs`) so this PR's head stayed untouched: - `format`: `cs_changed=true` → detected `ArtistsController.cs` → ran `dotnet format --include` **inside the image** → green. - `api-docs`: `API surface changed` → ran `./scripts/update-openapi.sh` (needs `dotnet build` + `python3`) → *"Generated API artifacts are in sync."* Both real paths execute correctly in the CI image. ### Measured result (baseline run 978 → run 993) | Job | Queue before | Queue after | Runtime before | Runtime after | |---|---|---|---|---| | `api-docs` | 1722s | **3s** | 9s | 14s | | `format` | 1731s | **15s** | 41s | **7s** | | `migrations` | 1428s | 679s | 647s | 272s | | `functional-e2e` | 1447s | 840s | 539s | 274s | | `test` | 1363s | 522s | 359s | 373s | **Worst-job wall-clock: 34 min → 18 min.** **Caveats, stated plainly:** n=1 per side, and the large runtime drops on `migrations`/`functional-e2e` are substantially *reduced lane contention*, not toolchain savings — I am not claiming those for the image. The structural, non-confounded wins are: the queue collapse on `api-docs`/`format` (~29 min → seconds), `format`'s 41s → 7s (setup-dotnet gone), and `functional-e2e`'s −110s (apt-ffmpeg gone). The image itself remains worth ~15–40s/job, exactly as the audit predicted. Queue wait (server-management#604) and the 742s triple-build (#398) remain the dominant costs and are untouched by design. Review-verdict: MERGEABLE @ ed52892d0aa4e6f0e6c9a0c40ff33bb4e2fa9d8f
Author
Owner

Re-posting the verdict in the short-sha form the H10 gate matches on (my previous comment used the full 40-char sha, which the gate correctly refused to accept as referencing the head).

Scope of this verdict — the cold adversarial review covered the original diff, and I have since re-reviewed every fix commit on top of it (84756eeb bash defaults, 07048b8c review fixes, ed52892d pin bump + guard fix), per the "re-review the fix commit, not just the initial diff" rule (#242). Evidence for each is in the two comments above: full run 993 green (including the previously-unproven MySQL-from-container path), both previously-skipped full paths proven on throwaway #400, and the pin guard verified non-vacuous with negative controls.

Review-verdict: MERGEABLE @ ed52892

Re-posting the verdict in the short-sha form the H10 gate matches on (my previous comment used the full 40-char sha, which the gate correctly refused to accept as referencing the head). Scope of this verdict — the cold adversarial review covered the original diff, and I have since re-reviewed every fix commit on top of it (`84756eeb` bash defaults, `07048b8c` review fixes, `ed52892d` pin bump + guard fix), per the "re-review the fix commit, not just the initial diff" rule (#242). Evidence for each is in the two comments above: full run 993 green (including the previously-unproven MySQL-from-container path), both previously-skipped full paths proven on throwaway #400, and the pin guard verified non-vacuous with negative controls. Review-verdict: MERGEABLE @ ed52892
timothy merged commit 23a615ecd5 into main 2026-07-17 12:40:25 +02:00
Sign in to join this conversation.