name: Build ErsatzTV Image # Builds the fork's own amd64 image and pushes it to the Gitea container registry. # pull_request -> test job only (no image build/push) # push to main -> :latest + : (test image; does NOT touch prod) # push tag v* -> :prod + : + : (prod release) # workflow_dispatch -> manual run; only publishes when the ref is main or a v* tag # # The PR-only git-diff gates (ci-image-pin, docs-reminder, decisions-guard) live in the sibling # .gitea/workflows/pr-checks.yml (`on: pull_request`). They were split out of this file so they # are not dispatched-and-killed on a tag/main push (ersatztv#535 — see that file's header). # # Runner + registry provisioned in server-management#172. The Gitea registry is # HTTP-only, so BuildKit needs the inline `http = true` config below (it does not # inherit the host daemon's insecure-registries setting). # # `:latest` is intentionally the test/dev channel (per ersatztv#3); prod pins # `:prod`, never `:latest` (enforced in the prod compose — server-management#481). # # TOOLCHAIN IMAGE (ersatztv#390): the jobs that need a toolchain (`test`, `migrations`, # `functional-e2e`, `api-docs`, `format`) run inside our shared CI image via `container:` # instead of installing .NET/Node/ffmpeg per run. It ships the .NET 10 SDK, Node 22, # prod-identical ffmpeg, and the dotnet-ef/reportgenerator global tools — so those jobs carry # no setup-dotnet, no setup-node, no apt, no `dotnet tool install`. Built by ci-image.yml from # docker/ci/Dockerfile. Project deps (NuGet/npm) are NOT baked in and stay on actions/cache. # # The pin below is an IMMUTABLE :, never :latest — a bad toolchain push would otherwise # break every converted job at once. It is repeated per job because `jobs..container.image` # cannot read the workflow `env` context. **Bump all five together**; see docs/ci-cd.md -> # "CI toolchain image" for the two-step procedure. # # CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 # # DOCS-ONLY SKIP (ersatztv#416): a change that touches only docs/** or *.md has nothing for the # heavy jobs to validate. `test`, `migrations`, `functional-e2e` and `build` each run # `scripts/ci-detect-docs-only.sh` as their first post-checkout step (id: detect) and gate every # real step on `steps.detect.outputs.docs_only != 'true'`. Crucially they STILL RUN and STILL # report `success` in seconds — the two REQUIRED contexts (`Build & test (.NET)`, `EF migration # integrity (SQLite + MySql)`) must keep reporting or a docs-only PR could never merge. We do NOT # `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped` # (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped # REQUIRED context. See docs/ci-cd.md -> "Docs-only skip". # # RELEASE-PATH DELIMITER GATE (ersatztv#767): the `scan` job runs the PyYAML-based delimiter-ban # test and is a `needs:` of `build`, so a `${{` opener in a banned job's `run:` body means `build` # never runs. It is deliberately NOT gated by either skip below: the gate's coverage must not depend # on a detector the gate is not allowed to trust, and it is cheap enough that gating it buys nothing. # (Do NOT justify that with "the docs-only path still builds an image" — it does not. `Build and # push` carries the docs_only gate too; a tag build is unaffected only because the script forces # docs_only=false there.) Note it installs from PyPI (setup-python + pip), putting a NEW network # dependency between a `v*` tag and its image. Not the only one on this path — `test` runs # `dotnet restore` and `npm ci` behind actions/cache, and a cache miss reaches nuget.org/npm — but # newly added here. Fail-closed and loud, and still a real availability dependency. # # ALREADY-VALIDATED SKIP (ersatztv#420): a second, sibling gate in `test`, `migrations` and # `functional-e2e` only (NOT `build`). On a push-to-main merge commit, `id: revalidate` runs # `scripts/ci-detect-already-validated.sh`, which emits `skip=true` only when the merged tree is # byte-identical to a PR head that already has a green Gitea combined status — i.e. the exact # source was already validated in the PR run. Every heavy step in those three jobs additionally # gates on `steps.revalidate.outputs.skip != 'true'`. `build` is untouched and always runs on # main, so the image is still built (from already-validated source) even when the skip fires. on: workflow_dispatch: pull_request: push: branches: - main tags: - 'v*' # Concurrency is scoped per ref (originally one global group for the single # jazz runner; with 3 runners that serialized the whole queue). PR runs # parallelize across PRs and a new sync auto-cancels its superseded run. # Real image builds (main / v* tags) still serialize within their own ref; # don't push main and a v* tag simultaneously — they share :buildcache and # the smoke container name. concurrency: group: ersatztv-build-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} # Inside a `container:`, act_runner does NOT default `run` steps to bash — it falls back to # `sh -e {0}` (dash), because it can't assume bash exists in an arbitrary image. Every multi-line # script here is bash (`set -o pipefail`, arrays, `shopt`, `mapfile`), so dash fails them # immediately: `set: Illegal option -o pipefail`. Declare the shell once for the whole workflow # rather than per step. Non-container jobs already defaulted to bash, so this changes nothing for # them. (ersatztv#390 — see docs/ci-cd.md -> "CI toolchain image".) defaults: run: shell: bash env: REGISTRY: 192.168.1.95:3000 IMAGE: 192.168.1.95:3000/timothy/ersatztv # --- CI build memory (ersatztv#406, server-management#604) --- # Roslyn's `VBCSCompiler` is a *persistent* compiler server: it outlives the `dotnet build` that # started it and keeps its managed heap warm for the next one. Locally that is a real speedup. # In CI it buys nothing — each job container is torn down at the end of the run, so there is # never a "next build" to warm — while costing a lot: 7.8 GB RSS was measured live on bumblebee, # the single largest consumer on a 25 GiB host that also runs prod media. Several of those, one # per concurrent job container, is what drove the host to load 340 with 21 GiB swapped. # # These are MSBuild properties/switches, set here as environment variables so they apply to every # dotnet invocation in every job (restore/build/test/format/api-docs) without touching each call # site. MSBuild surfaces environment variables as properties, and `UseSharedCompilation` is only # defaulted to true when empty, so setting it here wins. # # NOTE: this reaches the *runner-side* dotnet jobs only. The `build` job compiles inside # `docker build`, where these do not propagate — the same switches are set as ENV in the # Dockerfile's SDK stage (docker/Dockerfile) to cover it. UseSharedCompilation: "false" # no persistent VBCSCompiler; csc runs per-project and exits DOTNET_CLI_USE_MSBUILD_SERVER: "0" # no persistent MSBuild server process MSBUILDDISABLENODEREUSE: "1" # MSBuild worker nodes exit with the build instead of lingering jobs: # Answers "is the toolchain image still there?" in ONE place, so a deleted pin does not read as # five broken jobs and a broken diff (ersatztv#772). Deliberately container-free and deliberately # NOT a `needs:` of the jobs it diagnoses — see scripts/ci-toolchain-image-resolves.sh for both # decisions and for the cleanup-rule root cause it cannot fix from this repo. toolchain-preflight: name: CI toolchain image resolves runs-on: small steps: - name: Checkout uses: actions/checkout@v4 - name: Resolve the pinned toolchain tag in the registry env: ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }} run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark resolve scripts/ci-toolchain-image-resolves.sh - name: Assert every expected step executed (ersatztv#756) run: >- scripts/ci-step-ran.sh assert --always resolve test: name: Build & test (.NET) runs-on: ubuntu-latest container: image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 credentials: username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} steps: - name: Checkout uses: actions/checkout@v4 with: # git history/tags are needed by the `build` job's `git describe` (ersatztv#190) and, # here, by the #420 revalidate step's `HEAD^2` tree comparison on a main merge commit. fetch-depth: 2 # ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this # REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required # context keeps reporting — see the workflow header and docs/ci-cd.md -> "Docs-only skip". # EVERY consequential `run:` step in this job marks itself as its FIRST act (ersatztv#756), # and the trailing `Assert every expected step executed` guard fails the job when one is # missing. This is a REQUIRED context on `main`, and a step the runner drops takes the job # GREEN having done no work — see scripts/ci-step-ran.sh for why that is fail-OPEN here while # the same drop in review-verdict.yml is fail-CLOSED. - name: Detect docs-only changes id: detect run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark detect scripts/ci-detect-docs-only.sh - name: Detect already-validated tree (#420) id: revalidate env: ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }} run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate scripts/ci-detect-already-validated.sh - name: Cache NuGet packages if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' uses: actions/cache@v4 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }} restore-keys: nuget-${{ runner.os }}- - name: Restore if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark restore dotnet restore # Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but # the SPA's package downloads are project deps, so they stay cached per lockfile. - name: Cache npm packages if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }} restore-keys: npm-${{ runner.os }}- - name: Install SPA dependencies if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' working-directory: web run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark npm-ci npm ci - name: Check generated SPA API client if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' working-directory: web run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark check-api npm run check:api - name: Lint SPA if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' working-directory: web run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark lint npm run lint - name: Typecheck SPA if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' working-directory: web run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark typecheck npm run typecheck - name: Test SPA if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' working-directory: web run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark web-test npm test -- --run - name: Build SPA if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' working-directory: web run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark web-build npm run build - name: Strip Scanner project ref (matches Docker build) if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark strip-scanner sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj # Start the true peak-anon sampler just before the memory-heavy dotnet Build/Test/Coverage so # its high-water mark spans them (SPA build/test above are comparatively light). Paired with the # "Report peak container memory" step below. continue-on-error + a fail-open script => this # instrumentation never reddens a build. Why anon and not memory.peak: ersatztv#412 / # scripts/ci-peak-anon.sh header / docs/ci-cd.md "CI build memory". - name: Start peak-anon sampler (ersatztv#412) if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' continue-on-error: true run: scripts/ci-peak-anon.sh start - name: Build if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark build dotnet build --configuration Release --no-restore - name: Test if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark dotnet-test dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal \ --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage # Coverage reporting (ersatztv#15 scope item 4): coverlet.collector emits a Cobertura report # per test project (via --collect above); ReportGenerator merges them into a human-readable # summary printed to the log and the job step summary. No floor is enforced yet ("decide on a # floor later"), so this step is purely informational — continue-on-error keeps a missing # report or a transient tool-install failure from ever blocking a build. - name: Coverage summary if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' continue-on-error: true run: | set -euo pipefail shopt -s globstar nullglob reports=(coverage/**/coverage.cobertura.xml) if [ ${#reports[@]} -eq 0 ]; then echo "No coverage reports found under ./coverage -- skipping summary." exit 0 fi echo "Found ${#reports[@]} coverage report(s)." # reportgenerator is baked into the CI toolchain image (docker/ci/Dockerfile) and already # on PATH — no per-run `dotnet tool` install. Bump its version there (ersatztv#390). reportgenerator \ "-reports:coverage/**/coverage.cobertura.xml" \ "-targetdir:coverage/report" \ "-reporttypes:TextSummary;MarkdownSummaryGithub" echo "::group::Coverage summary" cat coverage/report/Summary.txt echo "::endgroup::" if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f coverage/report/SummaryGithub.md ]; then cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY" fi # Memory of THIS job container, reported every run (ersatztv#406/#412, server-management#604). # #604 sizes the runners' per-job caps on these numbers. The headline is the TRUE PEAK ANON # sampled by the "Start peak-anon sampler" step above — NOT `memory.peak`, which is the # high-water mark of memory.current and charges reclaimable page cache to the cgroup (a build # job does heavy NuGet/npm/obj/bin/coverage I/O, so cache can dominate the peak). Page cache is # reclaimed under a tighter cap, not OOM-killed, so sizing a cap off `memory.peak` inverts the # decision. peak anon is the OOM-forcing number. Full rationale + the bumblebee demo: # scripts/ci-peak-anon.sh header and docs/ci-cd.md "CI build memory". # # Runs LAST on purpose (after Coverage summary / reportgenerator, the job's last real workload) # and stops the sampler. `always()` so a failed Build/Test still gets a peak reading; the split # is read here (end-of-job = composition then, not at the peak instant — that is exactly why the # sampler exists). Skipped on docs-only/already-validated runs (nothing ran to measure). - name: Report peak container memory # `always()` controls whether this step RUNS, not whether its failure fails the job. With # `defaults.run.shell: bash` (`-e -o pipefail`) a stray non-zero here would redden a green # test job, so `continue-on-error` makes it advisory — the same guarantee Coverage summary uses. if: ${{ always() && steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' }} continue-on-error: true run: scripts/ci-peak-anon.sh report # THE DROPPED-STEP GUARD (ersatztv#756). Every `run:` step above records that it began; this # asserts the whole expected SET was recorded. A step the runner declines to interpolate is # DROPPED and still concludes `success` (ersatztv#751), so without this a REQUIRED context # reports green having done no work — fail-OPEN, and strictly worse than the fail-CLOSED # version of the same bug that #751 fixed in review-verdict.yml. # # NO `if:` HERE, WHICH IS A DELIBERATE DEPARTURE FROM THE #751 GUARD and the one decision in # this block that is easy to "fix" wrongly. That guard uses `if: always()` because its job has # exactly one real step, so there is no ordinary red for it to talk over. Here there are # twelve, and a genuine failure in an early one (a lint error, a failing test) SKIPS every # later step — an `always()` guard would then announce "these steps never executed: typecheck # web-test build dotnet-test" on top of every normal red build. That is not a dropped step, it # is the runner doing what it is told, and a guard that cries wolf on every red build is a # guard that gets deleted. # # The default `if:` is `success()`, which is exactly the condition wanted, and the invariant it # rests on is worth stating because it is what makes the omission safe rather than lucky: this # step is skipped ONLY when an earlier step failed, and an earlier step failing already fails # the job. So `guard skipped => job red`, and the only path to a green job runs the guard. A # dropped step is invisible precisely because it concludes `success`, which keeps the job green # and therefore reaches here. # # ITS OWN BODY CANNOT BE DROPPED BY THE MECHANISM IT GUARDS AGAINST: it is a single command # with no expression delimiter anywhere in the scalar, so the runner has nothing to rewrite. # The two gate values come in through `env:`, which is interpolated PER VALUE — a bad payload # there cannot take the body with it (`ci.workflow-run-body-no-expressions`), and both paths # are held to naming a real context by # test_every_workflow_expression_names_a_REAL_context_or_function. - name: Assert every expected step executed (ersatztv#756) env: ETV_DOCS_ONLY: ${{ steps.detect.outputs.docs_only }} ETV_REVALIDATE_SKIP: ${{ steps.revalidate.outputs.skip }} run: >- scripts/ci-step-ran.sh assert --always detect revalidate --gated restore npm-ci check-api lint typecheck web-test web-build strip-scanner build dotnet-test migrations: name: EF migration integrity (SQLite + MySql) runs-on: ubuntu-latest container: image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 credentials: username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} # Independent gate (not a 'needs' of build yet) so the new MySql-service dependency # can't block image builds until it's proven reliable on the runner. Promote to a # required check / build dependency once green. (ersatztv#13) services: mysql: image: mysql:8.4 env: MYSQL_ROOT_PASSWORD: ersatztv MYSQL_DATABASE: ersatztv_migrations # No host-port binding: the job reaches this service as mysql:3306 on the shared # runner network. Publishing 3306 made concurrent runs collide ("port is already # allocated") whenever two migrations jobs overlapped. # # `--memory`/`--cpus` here because the runner's `container.options` (`--memory=10g`) # applies to the JOB container ONLY, not to `services:` — verified by inspecting a live # migrations job: the job container reported HostConfig.Memory=10737418240, its mysql # service reported `mem=0 nanocpus=0`, i.e. unbounded. So every migrations run was adding # an uncapped MySQL to an already-tight host (ersatztv#406, server-management#604). # # NOTE (ersatztv#416): a `services:` container starts whenever the JOB starts, regardless # of step `if:`. So a docs-only migrations run still spins this mysql (capped, seconds) even # though the DDL-replay steps below are skipped. Fully skipping the service would require an # `if:`-skipped job, which we deliberately do NOT do for a required context — the heavy cost # (the 787-migration replay) is what the step gating removes. # # `--memory-swap=2g` is NOT redundant with `--memory=2g` — it is the point. Docker defaults # an unset `--memory-swap` to *twice* `--memory`, so `--memory=2g` alone would grant 2g RAM # **plus 2g of swap** (verified on bumblebee: `--memory=2g` alone → memory.max=2147483648 # AND memory.swap.max=2147483648; with `--memory-swap=2g` → memory.swap.max=0). Setting it # equal to --memory disables swap for this container. That matters more here than anywhere: # swap thrash on this host is the whole reason this cap exists, and a swapping mysqld mid-DDL # is precisely the pathology behind the known `Command Timeout expired` migrations flake. We # want a loud OOM over silent swapping — an OOM is a clear signal to raise the cap. # # 2g is sized on measurement rather than inheritance, but honestly: a mysql:8.4 container # with this exact env peaked at 543 MiB during init and settled at 481 MiB idle (probed on # bumblebee 2026-07-17). That is init+idle, NOT the 787-migration replay, which grows caches # idle never touches — so treat 2g as a measured floor with headroom, not a measured # ceiling. The migrations job going green is what validates it. If this OOM-kills the # service, raise it deliberately — do not remove the cap, and do not re-enable swap. # # `--cpus=2` is a ceiling, not a reservation, and is the one number here with no measurement # behind it: 787 sequential DDL statements on one connection are ~1-core-bound, so 2 is # judgement. Revisit if the apply step's tail latency grows. options: >- --memory=2g --memory-swap=2g --cpus=2 --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent" --health-interval=5s --health-timeout=5s --health-retries=30 steps: - name: Checkout uses: actions/checkout@v4 with: # was the default fetch-depth: 1 (ersatztv#190); bumped to 2 so the #420 revalidate # step's `HEAD^2` tree comparison can resolve on a main merge commit. fetch-depth: 2 # ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in # seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header. # Same per-step marker contract as the `test` job above (ersatztv#756) — this is the other # REQUIRED context, so a dropped migration-replay step would report EF integrity green having # replayed nothing. - name: Detect docs-only changes id: detect run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark detect scripts/ci-detect-docs-only.sh - name: Detect already-validated tree (#420) id: revalidate env: ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }} run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate scripts/ci-detect-already-validated.sh - name: Cache NuGet packages if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' uses: actions/cache@v4 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }} restore-keys: nuget-${{ runner.os }}- - name: Restore if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark restore dotnet restore - name: Build if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark build dotnet build --configuration Release --no-restore # dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH # — no per-run `dotnet tool install`. Bump its version there (ersatztv#390). # SQLite is the prod provider; both checks validated locally. - name: SQLite — model drift + apply all migrations to a fresh DB if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: | set -euo pipefail "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark sqlite echo "::group::SQLite model drift (has-pending-model-changes)" dotnet ef migrations has-pending-model-changes --no-build --configuration Release \ --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite echo "::endgroup::" echo "::group::SQLite apply all migrations to a fresh DB" export ETV_CONFIG_FOLDER="$(mktemp -d)" ETV_TRANSCODE_FOLDER="$(mktemp -d)" dotnet ef database update --no-build --configuration Release \ --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite echo "::endgroup::" # MySql uses ServerVersion.AutoDetect (connects at config time), so it runs against the # service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString". - name: MySql — model drift + apply all migrations to a fresh DB if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' env: # DefaultCommandTimeout is raised from MySqlConnector's 30s default: replaying every # migration to a fresh DB issues DDL commands that can exceed 30s when two migration jobs # share a runner host (each spins its own mysql:8.4 service) and starve each other. That # contention produced both "Command Timeout expired" and mid-replay connection drops # (MySqlEndOfStreamException) — neither is a model problem. See #13 / #236. MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;DefaultCommandTimeout=300;" run: | set -euo pipefail "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark mysql echo "::group::MySql model drift (has-pending-model-changes)" dotnet ef migrations has-pending-model-changes --no-build --configuration Release \ --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql echo "::endgroup::" echo "::group::MySql apply all migrations to a fresh DB" # Retry the apply: under concurrent-runner MySQL contention the server can drop the # connection mid-replay. Each attempt resumes from __EFMigrationsHistory (EF wraps each # migration in its own transaction, so an interrupted migration rolls back cleanly and the # retry continues from the last committed one) — so this only papers over infra flakiness, # never a real migration failure, which fails deterministically on every attempt. attempt=1 max=3 until dotnet ef database update --no-build --configuration Release \ --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql; do if [ "$attempt" -ge "$max" ]; then echo "MySql apply failed after ${max} attempts" >&2 exit 1 fi echo "MySql apply attempt ${attempt} failed (likely runner MySQL contention); retrying in 15s..." >&2 attempt=$((attempt + 1)) sleep 15 done echo "::endgroup::" # NOTE (ersatztv#491 -> #627): running the LibraryFolder dedupe fixture against the live `mysql` # service was implemented here and then REMOVED. The coverage gap it closes is real — the two # checks above only ever apply migrations to a fresh EMPTY database, so they execute no rows of any # data-migration logic, and two MySql-only collation defects escaped exactly this gate. But the # fixture proved non-deterministic in CI across three attempts (stale pooled session after a drop, # then lost isolation from a shared database name, then a connect-before-create), and an # intermittently-red gate is worse than none: it trains everyone to re-run instead of read, which is # how the original defects escaped. The fixture itself is retained and is opt-in via # ETV_TEST_MYSQL_CONNECTION (skipped, visibly, without it). Re-arming it here is tracked by #627. # THE DROPPED-STEP GUARD (ersatztv#756). Every `run:` step above records that it began; this # asserts the whole expected SET was recorded. A step the runner declines to interpolate is # DROPPED and still concludes `success` (ersatztv#751), so without this a REQUIRED context # reports green having done no work — fail-OPEN, and strictly worse than the fail-CLOSED # version of the same bug that #751 fixed in review-verdict.yml. # # NO `if:` HERE, WHICH IS A DELIBERATE DEPARTURE FROM THE #751 GUARD and the one decision in # this block that is easy to "fix" wrongly. That guard uses `if: always()` because its job has # exactly one real step, so there is no ordinary red for it to talk over. Here a genuine # failure in an early step (a failing `dotnet build`, a MySql replay error) SKIPS every later # step — an `always()` guard would then announce "these steps never executed: sqlite mysql" on # top of every normal red build. That is not a dropped step, it is the runner doing what it is # told, and a guard that cries wolf on every red build is a guard that gets deleted. # # The default `if:` is `success()`, which is exactly the condition wanted, and the invariant it # rests on is worth stating because it is what makes the omission safe rather than lucky: this # step is skipped ONLY when an earlier step failed, and an earlier step failing already fails # the job. So `guard skipped => job red`, and the only path to a green job runs the guard. A # dropped step is invisible precisely because it concludes `success`, which keeps the job green # and therefore reaches here. # # ITS OWN BODY CANNOT BE DROPPED BY THE MECHANISM IT GUARDS AGAINST: it is a single command # with no expression delimiter anywhere in the scalar, so the runner has nothing to rewrite. # The two gate values come in through `env:`, which is interpolated PER VALUE — a bad payload # there cannot take the body with it (`ci.workflow-run-body-no-expressions`), and both paths # are held to naming a real context by # test_every_workflow_expression_names_a_REAL_context_or_function. - name: Assert every expected step executed (ersatztv#756) env: ETV_DOCS_ONLY: ${{ steps.detect.outputs.docs_only }} ETV_REVALIDATE_SKIP: ${{ steps.revalidate.outputs.skip }} run: >- scripts/ci-step-ran.sh assert --always detect revalidate --gated restore build sqlite mysql functional-e2e: name: Functional E2E (curl + UI contracts) runs-on: ubuntu-latest # Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E # flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract, # If-Match/412, and since ersatztv#363 two lock-contention 409s) that sessions have been # re-running by hand. Deliberately NOT a `needs:` of `build` and not (yet) a required check, so a # functional-E2E flake can't block image builds or the unit-test gate — promote it to a required # check / build dependency once it's proven reliable (same rollout the `migrations` job used). # SQLite default provider -> no DB service. Runs on PRs and on main (regression net); skipped for # v* tag builds. if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' container: image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 credentials: username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} steps: - name: Checkout uses: actions/checkout@v4 with: # bumped from 1 (ersatztv#190 default) so the #420 revalidate step's `HEAD^2` tree # comparison can resolve on a main merge commit. fetch-depth: 2 # ersatztv#416: docs-only? Skip the boot + curl harness (advisory job; safe to no-op). - name: Detect docs-only changes id: detect run: scripts/ci-detect-docs-only.sh - name: Detect already-validated tree (#420) id: revalidate env: ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }} run: scripts/ci-detect-already-validated.sh - name: Cache NuGet packages if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' uses: actions/cache@v4 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }} restore-keys: nuget-${{ runner.os }}- - name: Restore if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: dotnet restore - name: Cache npm packages if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }} restore-keys: npm-${{ runner.os }}- - name: Install SPA dependencies if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' working-directory: web run: npm ci - name: Build SPA if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' working-directory: web run: npm run build - name: Build (Release) if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: dotnet build ErsatzTV.sln --configuration Release --no-restore # The old `command -v ffmpeg || sudo apt-get install ffmpeg` step is gone (ersatztv#390): # the toolchain image ships the same ffmpeg build prod runs, so the binary is already here. # That step also cost 110s of every run. The harness never *transcodes*, but since ersatztv#363 # it does use ffmpeg to synthesize ~60 tiny testsrc clips to seed the scan-lock 409 flow (and # python3's stdlib sqlite3 to seed the DB rows the API can't create) — both already present in # the image, so still no per-run install. The scan flow self-skips if ffmpeg is ever absent. - name: Boot instance and run functional-E2E harness if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: | set -euo pipefail export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409 CFG="$(mktemp -d)" # e2e-local.sh copies wwwroot, launches the DLL in the background (logging to a file, so # this command substitution returns as soon as the app is ready), and prints PID/CONFIG_DIR. OUT="$(scripts/e2e-local.sh "$CFG")" printf '%s\n' "$OUT" PID="$(printf '%s\n' "$OUT" | awk -F= '/^PID=/{print $2}')" trap 'kill "$PID" 2>/dev/null || true' EXIT scripts/e2e-functional.sh "http://localhost:${ETV_UI_PORT}" "$CFG" # ersatztv#445: the UI-interactive flows the curl harness structurally CANNOT express — # client-side form validation, AuthGate's rendered states, the session cookie authenticating the # SPA's own /api XHRs, and sign-out through the UserMenu. # # Why in THIS job rather than its own: the dominant cost here is `npm ci` + the Release build, # which are already done above. A separate job would duplicate both to add ~5s of browser work. # The browser itself is baked into the toolchain image (docker/ci/Dockerfile — # chromium-headless-shell), so this step installs nothing. # # It boots its OWN fresh instance on a DIFFERENT port: the first spec asserts the one-shot Setup # gate, which the curl harness's auth section has already claimed on its own config dir, and a # separate port keeps this independent of the previous step's teardown timing. - name: Run UI-E2E Playwright flows (headless) if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: | set -euo pipefail export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8410 # e2e-ui.sh owns the whole lifecycle: fresh config dir, boot, run specs, always kill the # server. Its exit status is Playwright's. scripts/e2e-ui.sh # THE DELIMITER BAN, RE-CHECKED ON THE RELEASE PATH ITSELF (ersatztv#767). # # The ban that keeps `build`'s `Smoke + IPTV E2E` from being silently dropped was enforced only by # `test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body` in the # `script-tests` job of pr-checks.yml — `on: pull_request`, and NOT a required context. So the ban # was REVIEW-TIME only: nothing re-checked it on a `v*` tag push, which is precisely when the # candidate image is published and `DeployStack jazz-media` promotes it. # # WHY A JOB AND NOT A STEP INSIDE `build`. A step cannot protect the thing it shares a job with: # `build` is what publishes, so a guard step there fails OPEN if the runner drops it, and "my body # has no opener so I cannot be dropped" is circular when the only thing enforcing that property is # the same PR-only test being backstopped. As a `needs:` of `build`, a red here means `build` never # runs at all — the image is not built, let alone pushed. Fail-closed by dependency, not by # assertion. # # WHY IT RUNS THE REAL PYTEST rather than a bespoke scanner. The first cut of #767 hand-parsed the # workflow YAML in stdlib Python, to avoid provisioning PyYAML on `build`'s bare runner. Two # independent reviews found ~10 false NEGATIVES in that parser within one round (flow mappings # `{run: …}`, a quoted `"run":` key, aliases, multiline quoted scalars) — i.e. it was strictly # WEAKER than the check it was meant to backstop, in the one direction that matters for a security # gate. Running the existing PyYAML-based test needs no second implementation of "what is a `run:` # body" and therefore has no drift surface. `small` is git-only, so Python is provisioned here the # same way `script-tests` does it. # # This job's OWN steps carry #756 markers and a trailing assert, so a drop inside THIS job is # caught too. That terminates the regress at the same axiom the sibling guards already rest on — # to fail open you must now drop the pytest step AND the assert step, rather than either one. # # THIS PUTS A `small`-LANE JOB BACK ON THE TAG PATH, which ersatztv#535 deliberately moved away # from — say so rather than letting it look accidental. #535 split the git-only gates into # pr-checks.yml because on the v26.12.0 tag they wedged in act's setup phase, were killed, and # reported `failure` with no logs. The blast radius here is WORSE than it was then: as a `needs:` # of `build`, that flake would not merely redden a status, it would skip the build and produce no # release image at all. # # It is acceptable now for a stated reason rather than an assumed one, and the evidence is weaker # than it first looks — so read the limits. Per `ci.small-lane-git-only`, the lane's per-job cap was # forced to 10g by its two HEAVIEST members (this file's `build` AND ci-image.yml's toolchain # buildx), not by `build` alone, and that cap is what pinned the lane to one slot on a 25 GiB host; # both were moved off in server-management#639, after which the lane is git-only and runs wide and # tiny. What has NOT been demonstrated is this lane on a TAG PUSH: `script-tests` runs there happily # but lives in pr-checks.yml (`on: pull_request`), so it has never exercised the condition #535 # measured, and #767's own runs (1928/1929) were `workflow_dispatch` on a scratch branch. The # lane-width argument is what carries this, not a like-for-like observation. If the wedging returns, # move this job to `ubuntu-latest` rather than weakening the `needs:` edge — a slower gate is fine, # an optional one is not. scan: name: Delimiter ban (release path) runs-on: small steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install test dependencies run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark deps python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml # The ban test plus the structural tests that hold this job's own shape. NOT the whole # scripts/tests suite: that is `script-tests`'s job, it needs jq/git preflights, and an # unrelated pytest regression must not be able to block a release. - name: Run the delimiter-ban tests run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark ban PYTHONPATH=. python3 -m pytest scripts/tests/test_ci_dropped_step_guard.py scripts/tests/test_ci_release_path_scan_job.py -q # THE POSITIVE CONTROL, and it is deliberately NOT a test (ersatztv#767). The step above proves # the ban HOLDS; it cannot prove the ban would NOTICE. Review disarmed the entire gate with one # repo-root `pytest.ini` (`addopts = -k "not delimiter_banned"`) or `conftest.py` # (`pytest_collection_modifyitems`), which deselects the ban test and every test guarding it, # leaving all jobs green with a delimiter sitting in `Smoke`. Nothing inside pytest can be # trusted to catch that, because pytest's own configuration outranks it. # # So this poisons the checked-out workflow, re-runs the SAME command, and fails the job if it # PASSES. It runs in the real checkout — an isolated copy does not inherit the repo-root config # a disarm would live in, which made the first version of this script report healthy while the # job's real invocation was deselected. The workflow file is restored by an EXIT trap. - name: Prove the ban would DETECT a delimiter (ersatztv#767) run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark selfcheck scripts/ci-prove-ban-detects.sh # No `if:` — see the sibling guards in `test`/`migrations` for why the default `success()` is # the wanted condition. Both keys are `--always`: every step in this job is unconditional. # # THE MARKER-PATH RATIONALE DOES NOT TRANSFER HERE, and assuming it did would be the mistake # `ci.required-job-step-execution-markers` itself warns about. That record says the run-id and # attempt keying is "defence in depth" because "these jobs get a fresh container, which is the # primary protection". This job has NO `container:` — it is on `small`, where RUNNER_TEMP is # the shared host /tmp. So here the keying is the ONLY protection, and the residual is a # single-job re-run that does not increment GITHUB_RUN_ATTEMPT: it would find the previous # attempt's marker file and the assert would pass even had the pytest step been dropped. # Identity was read off a real run rather than assumed — run 1929 printed # `Marker identity: job=scan run=1929 attempt=1 (from the runner)`, so all three variables are # populated on this lane. - name: Assert every expected step executed (ersatztv#756) run: >- scripts/ci-step-ran.sh assert --always deps ban selfcheck build: name: Build & push image (amd64) # Moved back off `small` (server-management#639). This is the one HEAVY job that # was still in that lane, and its 10g requirement was what pinned the lane's # per-job cap at 10g — which in turn capped the lane at ONE slot on a 25 GiB # host. Four jobs sharing one slot is what starved the git-only checks in act's # setup phase (>10 min, no logs, then fail). With this job gone, `small` is # git-only and can run wide and tiny on two hosts. # # The `ubuntu-latest` queueing that sent it to `small` in the first place # (server-management#574: a PR-run skip stuck 31 min behind long builds) does not # come back, because `needs: [test, migrations, scan]` means this job cannot be # dispatched until those three have already finished — by which point the lane it # was queueing behind has drained. Real builds (main/tags) get the full # ubuntu-latest allotment: 4 CPUs / 10g on ci-runner (.127). runs-on: ubuntu-latest # `scan` (ersatztv#767) re-checks the delimiter ban on the release path. As a `needs:` its red # SKIPS this job outright, so a delimiter in `Smoke + IPTV E2E` can no longer reach the point # where an image is published and never booted. needs: [test, migrations, scan] if: github.event_name != 'pull_request' steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 # ersatztv#416: a docs-only push to main has nothing to rebuild (docs are not in the image), # so skip the build/push/smoke steps — the job still reports success. Tag builds force # docs_only=false in the script, so a release is never skipped. - name: Detect docs-only changes id: detect run: scripts/ci-detect-docs-only.sh - name: Compute version and tags id: meta if: steps.detect.outputs.docs_only != 'true' run: | SHORT=$(git rev-parse --short HEAD) if [ "${GITHUB_REF_TYPE}" = "tag" ]; then VERSION="${GITHUB_REF_NAME#v}" INFO_VERSION="${VERSION}" TAGS=("${IMAGE}:prod" "${IMAGE}:${VERSION}" "${IMAGE}:${SHORT}") else DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0) INFO_VERSION="${DESC#v}-${SHORT}" TAGS=("${IMAGE}:latest" "${IMAGE}:${SHORT}") fi echo "info_version=${INFO_VERSION}" >> "$GITHUB_OUTPUT" echo "short=${SHORT}" >> "$GITHUB_OUTPUT" { echo "tags<<__EOT__" printf '%s\n' "${TAGS[@]}" echo "__EOT__" } >> "$GITHUB_OUTPUT" echo "INFO_VERSION=${INFO_VERSION}" printf 'tag: %s\n' "${TAGS[@]}" - name: Set up Docker Buildx if: steps.detect.outputs.docs_only != 'true' uses: docker/setup-buildx-action@v3 with: buildkitd-config-inline: | [registry."192.168.1.95:3000"] http = true - name: Login to Gitea registry if: steps.detect.outputs.docs_only != 'true' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} - name: Build and push if: steps.detect.outputs.docs_only != 'true' uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile platforms: linux/amd64 # only publish from main or a v* tag; other refs (e.g. branch dispatch) build only push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }} provenance: false build-args: | INFO_VERSION=${{ steps.meta.outputs.info_version }} tags: ${{ steps.meta.outputs.tags }} cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true # THE TWO VALUES COME IN THROUGH `env:`, NOT INLINE (ersatztv#756). This step runs AFTER # `Build and push`, so on a `v*` tag the image is already in the registry as the release # candidate — and it is this smoke run that decides whether the candidate was ever booted at # all. A stray expression delimiter anywhere in this body (a comment is not inert — #751) would # DROP the step and conclude the job `success`: a candidate published, never smoke-tested, and # `DeployStack jazz-media` promotes exactly that image. `env:` is interpolated PER VALUE, so a # bad payload there fails that value instead of taking the whole body with it, and with the # body delimiter-free the class is unreachable here — held by # test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body. # # The ban IS re-checked on the release path now (ersatztv#767): the `scan` job above runs the # PyYAML-based ban test and is a `needs:` of this job, so a delimiter here means `build` never # runs and no image is published. Do not re-add the note that once stood here saying the ban is # "review-time only, tracked as #767" — that was true before the `scan` job existed. # # This step still carries no per-step markers, and that is a genuine (smaller) residual rather # than a dismissal: markers would additionally catch a drop caused by something OTHER than a # delimiter. Adding them needs a bucket modelling this step's publish-ref `if:`, which the # guard's always/gated buckets do not express. The delimiter class itself is covered. - name: Smoke + IPTV E2E (assert key endpoints) if: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && steps.detect.outputs.docs_only != 'true' }} env: SMOKE_SHORT_SHA: ${{ steps.meta.outputs.short }} SMOKE_RUN_ID: ${{ github.run_id }} run: | IMG="${IMAGE}:${SMOKE_SHORT_SHA}" NAME="etv-smoke-${SMOKE_RUN_ID}" trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT echo "Pulling ${IMG}" docker pull "$IMG" # --memory-swap equal to --memory disables swap. Without it Docker defaults --memory-swap # to 2x --memory, so `--memory 2g` alone silently grants 2g RAM + 2g swap (ersatztv#406). docker run -d --name "$NAME" --memory 2g --memory-swap 2g \ -e ETV_CONFIG_FOLDER=/tmp/etv/config \ -e ETV_TRANSCODE_FOLDER=/tmp/etv/transcode \ "$IMG" # probe ErsatzTV's web server from inside the container (image ships python3) cat > probe.py <<'PY' import urllib.request, urllib.error, sys try: urllib.request.urlopen("http://localhost:8409/", timeout=3) except urllib.error.HTTPError: pass # any HTTP status means the server is serving except Exception: sys.exit(1) # not listening yet PY ok=0 for _ in $(seq 1 60); do if [ -z "$(docker ps -q --filter name="$NAME" --filter status=running)" ]; then echo "Container exited early"; break fi if docker exec -i "$NAME" python3 - < probe.py >/dev/null 2>&1; then ok=1; break fi sleep 2 done if [ "$ok" != "1" ]; then echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true echo "Smoke test FAILED: ErsatzTV did not serve HTTP on :8409" exit 1 fi echo "HTTP ready; asserting key IPTV endpoints (ersatztv#16)" # E2E: assert the real Jellyfin-facing surfaces serve a valid playlist + guide, not just # that the app answers HTTP. xmltv.xml needs channels.xml, which the scheduler writes a # few seconds after boot, so poll each endpoint until it returns 2xx with the right shape. # urlopen() returns only on 2xx (raises on 4xx/5xx), so reaching sys.exit means status OK. check() { local path="$1" needle="$2" i for i in $(seq 1 20); do if docker exec "$NAME" python3 -c "import urllib.request,sys; b=urllib.request.urlopen('http://localhost:8409$path',timeout=5).read(512).decode('utf-8','replace'); sys.exit(0 if '$needle' in b else 1)" 2>/dev/null; then echo " OK $path (2xx, contains '$needle')"; return 0 fi sleep 3 done echo " FAIL $path (no 2xx containing '$needle' within timeout)"; return 1 } if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "&1 | tail -n 40 || true exit 1 fi # BLOCKING (unlike docs-reminder): the mechanizable half of the "docs-update in the # same PR" rule for the API contract (ersatztv#303 H4/H5). If a PR touches the API # surface (ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/**), the generated # artifacts — v1.json (OpenAPI spec), v1.d.ts (SPA client), endpoint-index.md — MUST # already be regenerated in the diff. We rebuild them from source and fail on any drift. # Also covers the "regenerate artifacts after merging main into a PR branch" lore bullet. # # Path-gated INSIDE the job (not via top-level `if:`) so the check always reports a # status on every PR and can be a required check without stalling API-free PRs: when no # API path changed, the expensive steps skip and the job passes trivially. api-docs: name: API docs in sync (OpenAPI + endpoint index) # `small` lane (ersatztv#390): this job is ~5s on the ~90% of PRs that touch no API path, but # it was queueing ~29 min behind the heavy jobs in the contended `ubuntu-latest` lane, which # only has bumblebee-runner (capacity 2) + ci-runner. `small` has capacity 4, the same base # image, and answers in ~5s. Moving it here (and `format`) also drops `ubuntu-latest` from 5 # jobs to 3, which shortens the queue for `test`/`migrations`/`functional-e2e` too. # This is only possible because `container:` makes the job self-contained — it no longer needs # the runner image to supply .NET/Node. # # REVERTED to `ubuntu-latest` (server-management#604 / ersatztv#406). The caveat below the # original #390 rationale turned out to be the deciding factor: on an API-touching PR this # job does a full `dotnet build`, so it is NOT a small job, and "capacity 4 absorbs that" was # only true while nothing enforced the SUM of the lanes' memory caps. It didn't: 6 slots x 10g # on a 25 GiB host drove bumblebee to load 713 with 21 GiB swapped. The `small` lane is now # sized for genuinely-tiny jobs, and #604 grew the `ubuntu-latest` lane instead (ci-runner # 48 GiB at capacity 4 + a bumblebee overflow slot), which fixes the queue at the source. runs-on: ubuntu-latest container: image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 credentials: username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} if: github.event_name == 'pull_request' steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Detect API-surface changes id: detect run: | base_ref="${{ github.base_ref }}" git fetch --no-tags --depth=100 origin "$base_ref" || true changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)" echo "Changed files in this PR:"; printf '%s\n' "$changed" if printf '%s\n' "$changed" | grep -Eq '^ErsatzTV/Controllers/Api/|^ErsatzTV\.Core/Api/'; then echo "api_changed=true" >> "$GITHUB_OUTPUT" echo "API surface changed -> will verify generated artifacts are in sync." else echo "api_changed=false" >> "$GITHUB_OUTPUT" echo "No API-surface change -> skipping regeneration (job passes)." fi - name: Cache NuGet packages if: steps.detect.outputs.api_changed == 'true' uses: actions/cache@v4 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }} restore-keys: nuget-${{ runner.os }}- - name: Restore if: steps.detect.outputs.api_changed == 'true' run: dotnet restore - name: Cache npm packages if: steps.detect.outputs.api_changed == 'true' uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }} restore-keys: npm-${{ runner.os }}- - name: Install SPA dependencies if: steps.detect.outputs.api_changed == 'true' working-directory: web run: npm ci - name: Regenerate OpenAPI spec + endpoint index if: steps.detect.outputs.api_changed == 'true' run: ./scripts/update-openapi.sh - name: Regenerate SPA API client types if: steps.detect.outputs.api_changed == 'true' working-directory: web run: npm run generate:api - name: Fail on stale generated artifacts if: steps.detect.outputs.api_changed == 'true' run: | if ! git diff --exit-code -- \ ErsatzTV/wwwroot/openapi/v1.json \ web/src/api/generated/v1.d.ts \ docs/endpoint-index.md; then echo "::error::This PR changes the API surface but its generated artifacts are stale. Run './scripts/update-openapi.sh && (cd web && npm run generate:api)' and commit v1.json / v1.d.ts / endpoint-index.md in THIS PR (CLAUDE.md → Conventions; ersatztv#303 H4/H5)." exit 1 fi echo "Generated API artifacts are in sync." # Formatting-as-you-touch gate (ersatztv#311): verify the .cs files THIS PR changed conform to # .editorconfig whitespace + charset=utf-8 (i.e. no UTF-8 BOM). Scoped to changed files so it # enforces "normalize a legacy file when you touch it" WITHOUT a big-bang reformat of the ~2500 # pre-existing BOM files. A PR that touches no .cs skips the check and passes trivially (always # reports a status, so it is safe as a required check). # # ersatztv#469: uses `dotnet format whitespace . --folder`, NOT the full `dotnet format `. # `--folder` treats the tree as a plain folder of files and skips the MSBuild/Roslyn workspace load # + per-project compilation that dominated the old recipe (~8 min locally on a whole-solution run) — # `--include` only ever narrowed *which* files were checked, never what got loaded. Folder mode # reads .editorconfig and still flags WHITESPACE (indent/EOL/trailing/final-newline) and CHARSET # (BOM) violations — exactly what this gate exists to catch — in ~0.5s with no `dotnet restore`. # What it drops is the style/analyzer pass (naming/`var`/qualification), which this gate never # meaningfully enforced: those .editorconfig rules are :suggestion/:none severity. Full rationale + # non-vacuity evidence: docs/ci-cd.md → Formatting; docs/decisions.md. format: name: Formatting (changed .cs conform to .editorconfig) # Folder-mode whitespace is now a seconds-long, low-memory job (no Roslyn workspace, unlike the # 3.95 GiB full `dotnet format` measured in #406), so it no longer needs the memory headroom that # kept it on `ubuntu-latest`. Left here to avoid re-touching the lane/memory-cap accounting; a # move to a lighter lane is a server-management capacity call (#604). runs-on: ubuntu-latest container: image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 credentials: username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} if: github.event_name == 'pull_request' steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Detect changed C# files id: detect run: | base_ref="${{ github.base_ref }}" git fetch --no-tags --depth=100 origin "$base_ref" || true changed="$(git diff --name-only --diff-filter=ACM "origin/${base_ref}...HEAD" -- '*.cs' 2>/dev/null || true)" echo "Changed .cs files in this PR:"; printf '%s\n' "$changed" if [ -n "$changed" ]; then printf '%s\n' "$changed" > /tmp/changed-cs.txt echo "cs_changed=true" >> "$GITHUB_OUTPUT" echo "-> will verify these files conform to .editorconfig." else echo "cs_changed=false" >> "$GITHUB_OUTPUT" echo "No .cs change -> skipping format verify (job passes)." fi - name: Verify formatting of changed .cs files if: steps.detect.outputs.cs_changed == 'true' shell: bash run: | mapfile -t files < /tmp/changed-cs.txt echo "Verifying ${#files[@]} changed .cs file(s) against .editorconfig (whitespace + charset)..." if ! dotnet format whitespace . --folder --verify-no-changes --include "${files[@]}"; then echo "::error::One or more .cs files this PR touches don't conform to .editorconfig (whitespace or a UTF-8 BOM). Run 'dotnet format whitespace . --folder --include ' (or the full 'dotnet format ErsatzTV.sln --include ') and commit the result in THIS PR — the fix-as-you-touch convention (docs/contributing.md §7; ersatztv#311). Legacy files you did NOT touch are unaffected." exit 1 fi echo "All changed .cs files conform to .editorconfig."