Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only `docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep over every tracked file outside the detector, for the detector's own phrasings plus the attribution and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer proved") added residuals in the same files. Every site was classified with #812's three dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and traps stay; the attribution of who found them and the round in which they were found go. The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus `scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as the records' own exemption, and what keeps the record's depth measurement true), and `--all` lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812 argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff` sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill the withdrawn parity test ran on. The population oracle is restated over segments with the new arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures exemption, list only markdown, drop the symlink filter, test the mode per row instead of per path) each redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++` filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no notice — fixed, with a positive control and its own mutant. Code is unchanged by construction, measured per file type against `origin/main`: Python modules are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs (string literals) of three test modules; workflows differ only in `#` lines inside `run:` block scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated exceptions: the detector and its test, 26 vitest titles that carried review-round or severity labels or a reviewer attribution (call sites whose title changed — every changed title line walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed. Web: lint, typecheck, 1319 tests green. Closes #876. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
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 — a line-number citation here has gone stale within two lines
|
|
# of being 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. A stdlib hand-parser of the workflow
|
|
# YAML was TRIED AND REJECTED in #767 (its appeal: no PyYAML to provision on `build`'s bare
|
|
# runner). That parser had ~10 false NEGATIVES, all found at once (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. DEMONSTRATED: ONE repo-root `pytest.ini`
|
|
# (`addopts = -k "not delimiter_banned"`) or `conftest.py` (`pytest_collection_modifyitems`)
|
|
# disarms the entire gate, deselecting 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, so a check run in a copy reports healthy while the
|
|
# job's real invocation is 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."
|