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". # # 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: 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". - 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 # 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: npm ci - name: Check generated SPA API client if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' working-directory: web run: npm run check:api - name: Lint SPA if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' working-directory: web run: npm run lint - name: Typecheck SPA if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' working-directory: web run: npm run typecheck - name: Test SPA if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' working-directory: web run: npm test -- --run - name: Build SPA if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' working-directory: web run: npm run build - name: Strip Scanner project ref (matches Docker build) if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: 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: dotnet build --configuration Release --no-restore - name: Test if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: >- 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 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. - 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: Build if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' run: 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 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 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::" # The two checks above only ever apply migrations to a fresh EMPTY database, so they execute no # rows of any data-migration logic. The #491 LibraryFolder dedupe DELETES rows irreversibly and its # correctness depends on MySql string-comparison semantics that SQLite does not share — two # MySql-only collation defects (a case-insensitive grouping, then a PAD SPACE one) escaped exactly # this gate and were caught only by hand-run servers. LibraryFolderDedupeMigrationTests is # parameterized over both providers from ONE fixture, so running it here against the live service # closes that gap and keeps the two providers from silently diverging. # ETV_REQUIRE_MYSQL_TESTS turns "no MySql reachable" from a skip into a failure, so this can never # quietly pass having connected to nothing. It reuses the `mysql` service already declared by this # job, on its own per-test database, so it does not disturb the fresh-DB apply above. - name: MySql — data-migration fixture (#491 dedupe, same fixture as SQLite) if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' env: ETV_TEST_MYSQL_CONNECTION: "Server=mysql;Port=3306;Uid=root;Pwd=ersatztv;DefaultCommandTimeout=300;" ETV_REQUIRE_MYSQL_TESTS: "1" run: | set -euo pipefail dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --no-build --configuration Release \ --filter "FullyQualifiedName~LibraryFolderDedupeMigrationTests" 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 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]` means this job cannot be # dispatched until those two 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 needs: [test, migrations] 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 - 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' }} run: | IMG="${IMAGE}:${{ steps.meta.outputs.short }}" NAME="etv-smoke-${{ github.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."