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
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
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
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
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
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
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
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).
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 --includeinside 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.
## 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
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.
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 main2026-07-17 12:40:25 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 patterndocker/Dockerfileuses for prod (our ffmpeg base islinuxserver/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):
functional-e2erun. It was never load-bearing. The app's only startup ffmpeg touch is aLogWarningfromFFmpegLocatorService; the readiness line has zero ffmpeg dependency; the harness drives curl-only contracts that never transcode; andgit log -Sshows 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.dotnet-ef+reportgeneratorinto the image — two per-rundotnet tool installs gone, versions now in one tracked place.api-docs(5s) andformat(37s) to the idlesmalllane. They were each queueing ~29 min in the saturatedubuntu-latestlane. This also drops that lane from 5 jobs to 3, shortening the queue for the three heavy jobs. Only possible becausecontainer:makes them self-contained.Filed, not bundled:
dotnet buildruns 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.Honest ROI
The image itself is worth only ~15–40s/job (≈3–8%) —
setup-dotnetis 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
:<sha>, never:latest— a bad toolchain push would otherwise break all five jobs at once. Repeated per job becausejobs.<id>.container.imagecan't read the workflowenvcontext; bumping is a documented two-step.actions/checkout+e2e-local.sh'sgit rev-parse), and resetting the ffmpeg base'sENTRYPOINT ["ffmpeg"].setup-node's implicit npm cache is replaced with an explicitactions/cacheon~/.npm; NuGet cache unchanged. Project deps stay cached — only the toolchain moved into the image.dotnet --info, node, ffmpeg, …) so a broken toolchain fails the image build rather than a CI run. It already earned its keep: it caught thatreportgenerator --versionexits 1.migrations— it's the one job with aservices:container; watch that it still reachesmysql:3306from 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 thefunctional-e2effmpeg correction.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 #390The 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 #390Cold 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 tosh(dash) — FIXED (84756eeb)Independently confirmed by CI, not just argued: inside a
container:, act_runner defaultsrunsteps tosh -e {0}, not bash (it can't assume bash exists in an arbitrary image — nektos/act#1809). Every bashism dies withset: Illegal option -o pipefail. Run 986 evidence:migrationsfunctional-e2etestcontinue-on-error: trueThe
testcase is the one that matters: without this fix we'd have merged a green pipeline whose coverage reporting was silently dead.formatpassed only because it already declaredshell: 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 indocs/ci-cd.mdas 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:
migrationsdied at the SQLite step, so theservices: mysqlresolution 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=maxbuildcache would restore theapt-getlayer 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:latestfor 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>incontainer.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-pinjob (smalllane, seconds) that fails when the pin isn't the last commit to touchdocker/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: 1is 7 chars,%hon 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.ymlagainstdocker-build.ymlon the same push (jobs pulling a tag that isn't pushed yet).🟢 Low — all three FIXED (
07048b8c)FFMPEG_TAGnever existed — I referenced an ARG I never introduced, in both the Dockerfile and the docs. Reworded to name theFROMline.paths:claim mismatch — filtered the exact file while claimingdocker/ci/**. Now genuinelydocker/ci/**.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 tosafe.directory, which genuinely is.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.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/formatpassed only via their trivial skip paths (this PR touches no.csand no API path), sodotnet format --includeandupdate-openapi.shhave 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 …); thatsudois 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.shisdotnet+python3only); that the npm cache replacement resolves correctly (npm config get cache→/root/.npm, HOME=/root) with no job forgotten; and that thesmalllane move doesn't uncap anything (Memory:10g NanoCPUs:4— same cap as bumblebee-runner).Verification complete — all review findings closed, full run green
CI: run 993 @
ed52892d— every job greentest✅ ·migrations✅ ·functional-e2e✅ ·api-docs✅ ·format✅ ·ci-image-pin✅ ·docs-reminder✅ ·decisions-guard✅ (buildcorrectly skipped on PR)The two open review items are now settled with evidence, not argument:
migrationsis green end-to-end; the MySql step ran andServer=mysql;Port=3306resolves from inside the job container onci-net. This was genuinely unproven before (the job had always died at the earlier SQLite step).shell: bash --noprofile --norc -e -o pipefail {0}— workflow-leveldefaultsworks on act_runner v0.6.1, settling the reviewer's doubt empirically.functional-e2ereports "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/formathad only ever taken their skip paths. Proven on a throwaway branch (#399's head + a comment onErsatzTV/Controllers/Api/ArtistsController.cs) so this PR's head stayed untouched:format:cs_changed=true→ detectedArtistsController.cs→ randotnet format --includeinside the image → green.api-docs:API surface changed→ ran./scripts/update-openapi.sh(needsdotnet build+python3) → "Generated API artifacts are in sync."Both real paths execute correctly in the CI image.
Measured result (baseline run 978 → run 993)
api-docsformatmigrationsfunctional-e2etestWorst-job wall-clock: 34 min → 18 min.
Caveats, stated plainly: n=1 per side, and the large runtime drops on
migrations/functional-e2eare substantially reduced lane contention, not toolchain savings — I am not claiming those for the image. The structural, non-confounded wins are: the queue collapse onapi-docs/format(~29 min → seconds),format's 41s → 7s (setup-dotnet gone), andfunctional-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
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 (
84756eebbash defaults,07048b8creview fixes,ed52892dpin 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