Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 28s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m24s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 3m45s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m16s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Extends scripts/e2e-functional.sh with the two IEntityLocker 409 contracts the first cut (ersatztv#299) deferred as "racy", made DETERMINISTIC by firing the racing request only once the lock is provably held (no sleep-and-hope): - library-scan "already scanning" 409: seed ~60 tiny ffmpeg clips into the built-in Shows library so the scanner subprocess runs a few seconds, poll GET /libraries/scan-status until the library is active (that window is a strict subset of the scan lock's held window — StartScan after LockLibrary, EndScan before UnlockLibrary), then a second POST .../scan is a guaranteed 409. Self-skips (advisory) when ffmpeg is absent. - external-collections "already scanning" 409: seed a Jellyfin media-source row pointing at a non-routable address so the background sync hangs and the per-family lock stays held; the lock is taken synchronously before the 202, so the 202 proves it held. collections-scan-status corroborates; unknown source 404. Seeding uses python3's stdlib sqlite3 (already a harness dep) to insert rows the API can't create (LibraryPath, media-source); WAL mode tolerates the second writer. No new CI step/dependency — ffmpeg ships in the toolchain image. Verified: 4/4 fresh-instance runs green (38/38), lock section deterministic. Still deferred to #363 follow-ups: the playout-build lock 409 + isLocked projection (#215) and the UI-interactive Playwright flows. Docs updated same PR: docs/e2e-local.md, docs/ci-cd.md, the functional-e2e job comment in .gitea/workflows/docker-build.yml. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
923 lines
48 KiB
YAML
923 lines
48 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
|
|
#
|
|
# 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:07048b8
|
|
#
|
|
# 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".
|
|
|
|
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:07048b8
|
|
credentials:
|
|
username: ${{ secrets.REGISTRY_USER }}
|
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
# only the test job's steps below need the working tree; git history/tags
|
|
# are only needed by the `build` job's `git describe` (ersatztv#190)
|
|
fetch-depth: 1
|
|
|
|
# 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: Cache NuGet packages
|
|
if: steps.detect.outputs.docs_only != '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'
|
|
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'
|
|
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'
|
|
working-directory: web
|
|
run: npm ci
|
|
|
|
- name: Check generated SPA API client
|
|
if: steps.detect.outputs.docs_only != 'true'
|
|
working-directory: web
|
|
run: npm run check:api
|
|
|
|
- name: Lint SPA
|
|
if: steps.detect.outputs.docs_only != 'true'
|
|
working-directory: web
|
|
run: npm run lint
|
|
|
|
- name: Typecheck SPA
|
|
if: steps.detect.outputs.docs_only != 'true'
|
|
working-directory: web
|
|
run: npm run typecheck
|
|
|
|
- name: Test SPA
|
|
if: steps.detect.outputs.docs_only != 'true'
|
|
working-directory: web
|
|
run: npm test -- --run
|
|
|
|
- name: Build SPA
|
|
if: steps.detect.outputs.docs_only != 'true'
|
|
working-directory: web
|
|
run: npm run build
|
|
|
|
- name: Strip Scanner project ref (matches Docker build)
|
|
if: steps.detect.outputs.docs_only != 'true'
|
|
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
|
|
|
- name: Build
|
|
if: steps.detect.outputs.docs_only != 'true'
|
|
run: dotnet build --configuration Release --no-restore
|
|
|
|
- name: Test
|
|
if: steps.detect.outputs.docs_only != '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'
|
|
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, server-management#604).
|
|
# #604 sizes the runners' per-job caps on these numbers, and until now they were inherited
|
|
# rather than measured: the 10g cap traces back to server-management#570 observing the image
|
|
# build peg 5.999/6 GiB, which is a different job entirely.
|
|
#
|
|
# ⚠️ READ THE BREAKDOWN, NOT JUST THE PEAK. `memory.peak` is the high-water mark of
|
|
# `memory.current`, which charges **page cache** to the cgroup as well as anonymous memory —
|
|
# it is NOT "peak RSS", and for a build job (NuGet/npm/obj/bin/coverage I/O) the cache
|
|
# dominates. Demonstrated on bumblebee: a container with anon=0 that merely reads an 800 MB
|
|
# file reports memory.peak=826 MiB, of which file=800 MiB. This matters because the naive
|
|
# reading inverts the decision: page cache is **reclaimed** under a tighter cap, not
|
|
# OOM-killed, so a large peak that is mostly `file` is NOT evidence that the cap must stay
|
|
# high. `anon` (+ a little kernel/sock) is the part that actually forces an OOM.
|
|
#
|
|
# The split below is read at end-of-job, so it is the *current* composition rather than the
|
|
# composition at the peak instant — indicative, not exact. Sizing a cap off one run is still
|
|
# wrong; take a few runs, and treat anon as the floor and peak as the (cache-inflated)
|
|
# ceiling. Refining this into a true peak-anon sample is ersatztv#412.
|
|
#
|
|
# Runs LAST on purpose: memory.peak read at step N reports the peak only up to N, so this
|
|
# sits after Coverage summary to include reportgenerator, the job's last real workload.
|
|
# cgroup v2 first, v1 fallback.
|
|
#
|
|
# Skipped on docs-only runs (ersatztv#416): nothing ran, so there is nothing to measure.
|
|
- name: Report peak container memory
|
|
# `always()` controls whether this step RUNS, not whether its failure fails the job — and
|
|
# `defaults.run.shell: bash` means `-e -o pipefail` is on, so a failed `cat`/redirect here
|
|
# would redden a green test job. `continue-on-error` is what actually makes it advisory,
|
|
# the same guarantee the Coverage summary step above uses.
|
|
if: ${{ always() && steps.detect.outputs.docs_only != 'true' }}
|
|
continue-on-error: true
|
|
run: |
|
|
mib() { echo "$(( ${1:-0} / 1048576 ))"; }
|
|
peak=""; src=""
|
|
for f in /sys/fs/cgroup/memory.peak /sys/fs/cgroup/memory/memory.max_usage_in_bytes; do
|
|
if [ -r "$f" ]; then peak=$(cat "$f" 2>/dev/null || echo ""); src="$f"; break; fi
|
|
done
|
|
if [ -z "$peak" ]; then
|
|
echo "No cgroup peak-memory file readable in this container -- skipping."
|
|
exit 0
|
|
fi
|
|
anon=""; file=""
|
|
if [ -r /sys/fs/cgroup/memory.stat ]; then
|
|
anon=$(awk '/^anon /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
|
|
file=$(awk '/^file /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
|
|
fi
|
|
echo "::group::Container memory (ersatztv#406 / server-management#604)"
|
|
printf 'peak (incl. page cache): %s MiB [%s bytes, %s]\n' "$(mib "$peak")" "$peak" "$src"
|
|
if [ -n "$anon" ]; then
|
|
printf 'end-of-job anon (the part that OOMs): %s MiB\n' "$(mib "$anon")"
|
|
printf 'end-of-job file (page cache, reclaimable): %s MiB\n' "$(mib "${file:-0}")"
|
|
echo 'NOTE: peak counts reclaimable page cache. Size caps on anon, not on peak.'
|
|
else
|
|
echo 'NOTE: no memory.stat breakdown available; peak includes reclaimable page cache.'
|
|
fi
|
|
echo "::endgroup::"
|
|
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
|
{
|
|
printf '**Container memory (test job):** peak %s MiB *(incl. reclaimable page cache)*' \
|
|
"$(mib "$peak")"
|
|
[ -n "$anon" ] && printf ' · end-of-job anon %s MiB · file %s MiB' \
|
|
"$(mib "$anon")" "$(mib "${file:-0}")"
|
|
printf '\n'
|
|
} >> "$GITHUB_STEP_SUMMARY" || true
|
|
fi
|
|
|
|
migrations:
|
|
name: EF migration integrity (SQLite + MySql)
|
|
runs-on: ubuntu-latest
|
|
container:
|
|
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
|
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
|
|
# default fetch-depth: 1 -- this job never runs git describe/log, only
|
|
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
|
|
|
|
# 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: Cache NuGet packages
|
|
if: steps.detect.outputs.docs_only != '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'
|
|
run: dotnet restore
|
|
|
|
- name: Build
|
|
if: steps.detect.outputs.docs_only != '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'
|
|
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'
|
|
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::"
|
|
|
|
functional-e2e:
|
|
name: Functional E2E (curl 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:07048b8
|
|
credentials:
|
|
username: ${{ secrets.REGISTRY_USER }}
|
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 1
|
|
|
|
# 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: Cache NuGet packages
|
|
if: steps.detect.outputs.docs_only != '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'
|
|
run: dotnet restore
|
|
|
|
- name: Cache npm packages
|
|
if: steps.detect.outputs.docs_only != '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'
|
|
working-directory: web
|
|
run: npm ci
|
|
|
|
- name: Build SPA
|
|
if: steps.detect.outputs.docs_only != 'true'
|
|
working-directory: web
|
|
run: npm run build
|
|
|
|
- name: Build (Release)
|
|
if: steps.detect.outputs.docs_only != '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'
|
|
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"
|
|
|
|
build:
|
|
name: Build & push image (amd64)
|
|
# `small` = the dedicated small-jobs runner lane (server-management#574).
|
|
# On PR runs this job only resolves its skip, but Gitea still dispatches it
|
|
# as a task — on the ubuntu-latest runners that skip queued behind long
|
|
# builds (observed 31 min). Real builds (main/tags) run on bumblebee,
|
|
# capped at 4 CPUs / 10g.
|
|
runs-on: small
|
|
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 (ersatztv#390): the CI toolchain image pin in this file must name the image that
|
|
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
|
|
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
|
|
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
|
|
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
|
|
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
|
|
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
|
|
#
|
|
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
|
|
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
|
|
# git+grep -> keep it off the build runners.
|
|
ci-image-pin:
|
|
name: CI image pin matches docker/ci
|
|
runs-on: small
|
|
if: github.event_name == 'pull_request'
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
# need real history: `git log -- <path>` on a shallow clone can't find the last
|
|
# commit that touched the image sources
|
|
fetch-depth: 0
|
|
- name: Verify the pin matches the last-published image
|
|
run: |
|
|
set -euo pipefail
|
|
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
|
|
# only builds on pushes touching these paths — so the published image is named by the last
|
|
# commit to touch them.
|
|
#
|
|
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
|
|
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
|
|
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
|
|
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
|
|
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
|
|
echo "Image sources last changed in: ${expected}"
|
|
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
|
|
if [ "${#pins[@]}" -ne 1 ]; then
|
|
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
|
|
exit 1
|
|
fi
|
|
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
|
|
if [ -z "$pin_full" ]; then
|
|
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
|
|
exit 1
|
|
fi
|
|
if [ "$pin_full" != "$expected" ]; then
|
|
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
|
|
exit 1
|
|
fi
|
|
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
|
|
|
|
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
|
|
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
|
|
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
|
|
# cache-save issues seen on the relocated runner (server-management#570).
|
|
docs-reminder:
|
|
name: Docs update reminder
|
|
runs-on: small # seconds-long git diff; keep it off the build runners
|
|
if: github.event_name == 'pull_request'
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
- name: Warn when a screen/route change skips the parity doc
|
|
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"
|
|
screen_or_route=no
|
|
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
|
|
screen_or_route=yes
|
|
fi
|
|
parity=no
|
|
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
|
|
parity=yes
|
|
fi
|
|
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
|
|
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
|
|
else
|
|
echo "Parity-doc reminder: nothing to flag."
|
|
fi
|
|
|
|
# BLOCKING (ersatztv#303 H9): docs/decisions.md is an append-only log. Fails a PR that deletes or
|
|
# rewrites a settled entry (numstat reports >0 deleted lines) unless a commit in the range carries
|
|
# the [decisions-edit] override token for a documented factual fix. Same script the Husky commit-msg
|
|
# hook calls, so local and CI enforcement can't drift. Seconds-long git diff -> keep it off the build runners.
|
|
decisions-guard:
|
|
name: decisions.md append-only
|
|
runs-on: small
|
|
if: github.event_name == 'pull_request'
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
- name: Enforce append-only
|
|
run: |
|
|
base_ref="${{ github.base_ref }}"
|
|
git fetch --no-tags --depth=200 origin "$base_ref" || true
|
|
./.claude/hooks/decisions-guard.sh range "origin/${base_ref}" HEAD
|
|
- name: Consolidation-floor reminder (non-blocking)
|
|
run: |
|
|
# Consolidation is primarily a release step; this is the between-releases floor. The metric is
|
|
# the file's LINE COUNT — the context an agent actually burns reading the log — not entry count.
|
|
# Floor 1800 keeps the whole log inside one default 2000-line Read (headroom for the reader's
|
|
# own overhead). Nudge (never fail) past it so append-only can't grow past what agents can read.
|
|
n=$(wc -l < docs/decisions.md | tr -d ' ')
|
|
echo "docs/decisions.md is ${n} lines (consolidation floor: 1800; one Read caps at 2000)."
|
|
if [ "${n:-0}" -gt 1800 ]; then
|
|
echo "::warning::docs/decisions.md is ${n} lines (>1800) — larger than agents can comfortably read in one pass. Do a consolidation pass (prune/merge superseded entries with [decisions-edit]); don't wait for the next release. See the decisions.md header."
|
|
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:07048b8
|
|
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 (style + 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 expensive steps and passes trivially (always reports
|
|
# a status, so it is safe as a required check).
|
|
format:
|
|
name: Formatting (changed .cs conform to .editorconfig)
|
|
# Was on the `small` lane (ersatztv#390) to dodge a ~29 min queue; reverted to `ubuntu-latest`
|
|
# in ersatztv#406 — `dotnet format` needs the .NET SDK and real memory, so it does not belong
|
|
# in a lane sized for seconds-long shell jobs. See the api-docs job above for the full
|
|
# rationale; server-management#604 grew this lane so the queue it was dodging is gone.
|
|
runs-on: ubuntu-latest
|
|
container:
|
|
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
|
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: Cache NuGet packages
|
|
if: steps.detect.outputs.cs_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.cs_changed == 'true'
|
|
run: dotnet restore
|
|
|
|
- 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..."
|
|
if ! dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"; then
|
|
echo "::error::One or more .cs files this PR touches don't conform to .editorconfig (formatting or a UTF-8 BOM). Run '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."
|