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