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

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
This commit is contained in:
2026-07-17 09:34:46 +02:00
parent 84756eebab
commit 07048b8c96
4 changed files with 74 additions and 10 deletions
+15 -3
View File
@@ -25,11 +25,19 @@ on:
workflow_dispatch:
push:
paths:
- 'docker/ci/Dockerfile'
- 'docker/ci/**'
- '.gitea/workflows/ci-image.yml'
schedule:
# Mondays 05:00 UTC — an hour before the dependency scan, so a fresh toolchain is in place
# before the week's runs. Gitea registers `schedule` only from the default branch (main).
# Mondays 05:00 UTC. Gitea registers `schedule` only from the default branch (main).
#
# What this cron does and does NOT do — it does **not** update any running job. The jobs in
# docker-build.yml pin an immutable :<sha> (deliberately), so a rebuilt image is consumed only
# when a human bumps that pin. Its actual value is twofold:
# 1. a weekly CANARY — catches "the toolchain image no longer builds" (a NodeSource/apt/base
# change) at a time of our choosing, rather than when you next need to bump the pin;
# 2. it leaves a freshly-patched :latest so the next pin bump starts from a current base.
# `no-cache` on this path is what makes both real: with the shared :buildcache, the
# `apt-get update && apt-get install` layer would restore from cache and re-fetch nothing.
- cron: '0 5 * * 1'
# Serialize per ref: concurrent builds would race on the shared :buildcache tag.
@@ -98,6 +106,10 @@ jobs:
push: true
provenance: false
tags: ${{ steps.meta.outputs.tags }}
# The scheduled rebuild must bypass the cache or it is pointless: `mode=max` buildcache
# would restore the `apt-get update && apt-get install` layer verbatim and pull in none of
# the base updates the cron exists to collect. Push-triggered builds keep the cache.
no-cache: ${{ github.event_name == 'schedule' }}
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv-ci:buildcache
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv-ci:buildcache,mode=max,ignore-error=true
+42
View File
@@ -453,6 +453,48 @@ jobs:
exit 1
fi
# BLOCKING (ersatztv#390): the CI toolchain image pin in this file must name the image that
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
#
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
# git+grep -> keep it off the build runners.
ci-image-pin:
name: CI image pin matches docker/ci
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# need real history: `git log -- <path>` on a shallow clone can't find the last
# commit that touched the image sources
fetch-depth: 0
- name: Verify the pin matches the last-published image
run: |
set -euo pipefail
# The tag ci-image.yml publishes is `git rev-parse --short HEAD` of the push that built it,
# and it only builds on pushes touching these paths — so the currently-published image is
# named by the last commit to touch them. Same abbreviation rules on both sides (core.abbrev).
expected="$(git log -1 --format=%h -- docker/ci .gitea/workflows/ci-image.yml)"
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
echo "Image sources last changed in: ${expected}"
echo "Pins found in docker-build.yml: ${pins[*]}"
if [ "${#pins[@]}" -ne 1 ]; then
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
exit 1
fi
if [ "${pins[0]}" != "$expected" ]; then
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]}, but docker/ci was last changed in ${expected}. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish :${expected}, then update the pin in ALL jobs to that sha (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
echo "Pin is current: ersatztv-ci:${pins[0]} matches docker/ci @ ${expected}."
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
+2 -2
View File
@@ -17,8 +17,8 @@
# (see renovate.json). The two dotnet tool versions mirror what docker-build.yml used to
# `dotnet tool install` per run — bump them here, not in the workflow.
# Bumping FFMPEG_TAG: keep it equal to the tag docker/Dockerfile pins, so CI's ffmpeg stays
# prod-identical. Renovate manages that Dockerfile's pin; mirror it here.
# Keep the ffmpeg tag on the FROM below equal to the one docker/Dockerfile pins, so CI's ffmpeg
# stays prod-identical. Renovate manages both pins (dockerfile manager); mirror any bump here.
FROM --platform=linux/amd64 192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2 AS ci-base
# The ffmpeg base sets ENTRYPOINT ["ffmpeg"] / CMD ["--help"] because it ships as an ffmpeg CLI.
+15 -5
View File
@@ -231,9 +231,9 @@ commit and stay on `actions/cache` (`~/.nuget/packages`, `~/.npm`).
**How it's layered:** `FROM ersatztv-ffmpeg:8.1.2` + `COPY --from=mcr.microsoft.com/dotnet/sdk:10.0-noble-amd64
/usr/share/dotnet` — the same pattern `docker/Dockerfile` uses for the prod image. Our ffmpeg base is
`ghcr.io/linuxserver/baseimage-ubuntu:noble`, the same Ubuntu release as the SDK image, so the copied
SDK matches the base's glibc/ICU. Keep `FFMPEG_TAG` equal to the tag `docker/Dockerfile` pins, so CI's
ffmpeg stays prod-identical — that fidelity is what the ersatztv#299 seeded-media/scanner E2E
follow-ups will need.
SDK matches the base's glibc/ICU. Keep the ffmpeg tag on that `FROM` equal to the one
`docker/Dockerfile` pins, so CI's ffmpeg stays prod-identical — that fidelity is what the
ersatztv#299 seeded-media/scanner E2E follow-ups will need.
**Bumping the toolchain is a deliberate two-step.** The jobs pin an immutable `:<sha>`, never
`:latest`, so a bad toolchain push cannot break every job at once:
@@ -260,8 +260,18 @@ registry. Renovate tracks the Dockerfile's image pins (`dockerfile` manager, see
- `actions/checkout` clones as root into a mounted workspace, which trips git's *"detected dubious
ownership"* guard and breaks every `git` call in a step. Fixed in the Dockerfile with
`git config --global --add safe.directory '*'`.
- The ffmpeg base sets `ENTRYPOINT ["ffmpeg"]` because it ships as an ffmpeg CLI. A job container is
started long-lived with steps exec'd into it, so the Dockerfile resets both `ENTRYPOINT` and `CMD`.
- *(defensive, not load-bearing)* The ffmpeg base sets `ENTRYPOINT ["ffmpeg"]` because it ships as an
ffmpeg CLI, so the Dockerfile resets `ENTRYPOINT`/`CMD`. act overrides the entrypoint anyway
(`entrypoint=["/bin/sleep" "10800"]`), so this is belt-and-braces for anyone running the image by
hand — unlike the two above, which are real.
**Bumping the pin is enforced, not remembered.** The `ci-image-pin` job (blocking, PR-only) fails if
`docker-build.yml`'s pin isn't the short sha of the last commit to touch `docker/ci/**` or
`ci-image.yml`, or if the five jobs ever pin different tags. This exists because **Renovate manages
`docker/ci/Dockerfile`'s base pins but cannot bump an opaque `:<sha>`** in `container.image` — so a
Renovate base bump would otherwise publish a new image, test the *old* one, and merge with the
Dockerfile disagreeing with the pin. A red `ci-image-pin` means: let `ci-image.yml` publish the new
`:<sha>`, then update all five pins to it.
**What it is and isn't worth.** Measured honestly (ersatztv#390): the image saves ~1540s per job
(`setup-dotnet` is 819s, `setup-node` 25s cached, the two tool installs ~9s) plus the 110s