The dedupe DML had zero automated coverage on MySql: the migrations job only applies migrations to a fresh EMPTY database, so no dedupe row ever executed there. Two MySql-only collation defects escaped that gate in this session and were caught only by hand-run containers. Parameterize LibraryFolderDedupeMigrationTests over both providers from ONE fixture body - same seeded rows, same expected survivors - rather than adding a MySql-only copy that would drift and recreate the gap. Assertions no longer use WHERE Path = '...', which is itself collation-dependent and would quietly mean something different per provider; rows are read once and compared ordinally in memory. A new step in the existing migrations job runs it against that job's mysql:8.4 service, on a per-test database of its own. Proven red when the collation is wrong: restoring COLLATE utf8mb4_bin fails the MySql half with survivors [1,4,5,6,7,9] - the trailing-space sibling deleted - while SQLite stays green. Proven non-skippable: without ETV_TEST_MYSQL_CONNECTION the fixture ignores visibly, and with ETV_REQUIRE_MYSQL_TESTS=1 (which CI sets) that skip becomes a hard failure, so it cannot pass having connected to nothing. Local runs need no MySql. Also correct an overstated comment. The schema pins only the utf8mb4 charset, never a collation, so the effective comparison is the server default: always case-insensitive, but PAD SPACE only on utf8mb4_general_ci - 8.4's default utf8mb4_0900_ai_ci is NO PAD, verified on the real column. The migration bug was independent of that because the old code applied an EXPLICIT utf8mb4_bin, which is PAD SPACE everywhere; the runtime simply tolerates both. Refs #488 #308 fix #491
852 lines
46 KiB
YAML
852 lines
46 KiB
YAML
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 + :<short-sha> (test image; does NOT touch prod)
|
|
# push tag v* -> :prod + :<version> + :<short-sha> (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 :<sha>, never :latest — a bad toolchain push would otherwise
|
|
# break every converted job at once. It is repeated per job because `jobs.<id>.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" "<tv" && check "/app/" "ChicoryTV"; then
|
|
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide; /app/ serves the ChicoryTV SPA"
|
|
else
|
|
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&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 <sln>`.
|
|
# `--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 <files>' (or the full 'dotnet format ErsatzTV.sln --include <files>') 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."
|