From d79de0115584bcbb205df015b4cf9f8b5ae0c037 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 28 Jul 2026 21:30:27 +0200 Subject: [PATCH] =?UTF-8?q?scratch(672):=20probe=20D=20=E2=80=94=20can=20d?= =?UTF-8?q?ocker-build.yml's=20creds=20write=20a=20status=3F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/docker-build.yml | 840 +----------------------------- .gitea/workflows/pr-checks.yml | 262 ---------- 2 files changed, 12 insertions(+), 1090 deletions(-) delete mode 100644 .gitea/workflows/pr-checks.yml diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index b90794028..058d6328c 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -1,841 +1,25 @@ name: Build ErsatzTV Image -# Builds the fork's own amd64 image and pushes it to the Gitea container registry. -# pull_request -> test job only (no image build/push) -# push to main -> :latest + : (test image; does NOT touch prod) -# push tag v* -> :prod + : + : (prod release) -# workflow_dispatch -> manual run; only publishes when the ref is main or a v* tag -# -# The PR-only git-diff gates (ci-image-pin, docs-reminder, decisions-guard) live in the sibling -# .gitea/workflows/pr-checks.yml (`on: pull_request`). They were split out of this file so they -# are not dispatched-and-killed on a tag/main push (ersatztv#535 — see that file's header). -# -# Runner + registry provisioned in server-management#172. The Gitea registry is -# HTTP-only, so BuildKit needs the inline `http = true` config below (it does not -# inherit the host daemon's insecure-registries setting). -# -# `:latest` is intentionally the test/dev channel (per ersatztv#3); prod pins -# `:prod`, never `:latest` (enforced in the prod compose — server-management#481). -# -# TOOLCHAIN IMAGE (ersatztv#390): the jobs that need a toolchain (`test`, `migrations`, -# `functional-e2e`, `api-docs`, `format`) run inside our shared CI image via `container:` -# instead of installing .NET/Node/ffmpeg per run. It ships the .NET 10 SDK, Node 22, -# prod-identical ffmpeg, and the dotnet-ef/reportgenerator global tools — so those jobs carry -# no setup-dotnet, no setup-node, no apt, no `dotnet tool install`. Built by ci-image.yml from -# docker/ci/Dockerfile. Project deps (NuGet/npm) are NOT baked in and stay on actions/cache. -# -# The pin below is an IMMUTABLE :, never :latest — a bad toolchain push would otherwise -# break every converted job at once. It is repeated per job because `jobs..container.image` -# cannot read the workflow `env` context. **Bump all five together**; see docs/ci-cd.md -> -# "CI toolchain image" for the two-step procedure. -# -# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 -# -# DOCS-ONLY SKIP (ersatztv#416): a change that touches only docs/** or *.md has nothing for the -# heavy jobs to validate. `test`, `migrations`, `functional-e2e` and `build` each run -# `scripts/ci-detect-docs-only.sh` as their first post-checkout step (id: detect) and gate every -# real step on `steps.detect.outputs.docs_only != 'true'`. Crucially they STILL RUN and STILL -# report `success` in seconds — the two REQUIRED contexts (`Build & test (.NET)`, `EF migration -# integrity (SQLite + MySql)`) must keep reporting or a docs-only PR could never merge. We do NOT -# `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped` -# (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped -# REQUIRED context. See docs/ci-cd.md -> "Docs-only skip". -# -# ALREADY-VALIDATED SKIP (ersatztv#420): a second, sibling gate in `test`, `migrations` and -# `functional-e2e` only (NOT `build`). On a push-to-main merge commit, `id: revalidate` runs -# `scripts/ci-detect-already-validated.sh`, which emits `skip=true` only when the merged tree is -# byte-identical to a PR head that already has a green Gitea combined status — i.e. the exact -# source was already validated in the PR run. Every heavy step in those three jobs additionally -# gates on `steps.revalidate.outputs.skip != 'true'`. `build` is untouched and always runs on -# main, so the image is still built (from already-validated source) even when the skip fires. +# ersatztv#672 PROBE D — THROWAWAY. Does a DIFFERENT head-resolved workflow that holds credentials +# give the same bypass? Posts a probe-named context only, never the real review-verdict/h10. on: - workflow_dispatch: pull_request: - push: - branches: - - main - tags: - - 'v*' - -# Concurrency is scoped per ref (originally one global group for the single -# jazz runner; with 3 runners that serialized the whole queue). PR runs -# parallelize across PRs and a new sync auto-cancels its superseded run. -# Real image builds (main / v* tags) still serialize within their own ref; -# don't push main and a v* tag simultaneously — they share :buildcache and -# the smoke container name. -concurrency: - group: ersatztv-build-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -# Inside a `container:`, act_runner does NOT default `run` steps to bash — it falls back to -# `sh -e {0}` (dash), because it can't assume bash exists in an arbitrary image. Every multi-line -# script here is bash (`set -o pipefail`, arrays, `shopt`, `mapfile`), so dash fails them -# immediately: `set: Illegal option -o pipefail`. Declare the shell once for the whole workflow -# rather than per step. Non-container jobs already defaulted to bash, so this changes nothing for -# them. (ersatztv#390 — see docs/ci-cd.md -> "CI toolchain image".) -defaults: - run: - shell: bash - -env: - REGISTRY: 192.168.1.95:3000 - IMAGE: 192.168.1.95:3000/timothy/ersatztv - - # --- CI build memory (ersatztv#406, server-management#604) --- - # Roslyn's `VBCSCompiler` is a *persistent* compiler server: it outlives the `dotnet build` that - # started it and keeps its managed heap warm for the next one. Locally that is a real speedup. - # In CI it buys nothing — each job container is torn down at the end of the run, so there is - # never a "next build" to warm — while costing a lot: 7.8 GB RSS was measured live on bumblebee, - # the single largest consumer on a 25 GiB host that also runs prod media. Several of those, one - # per concurrent job container, is what drove the host to load 340 with 21 GiB swapped. - # - # These are MSBuild properties/switches, set here as environment variables so they apply to every - # dotnet invocation in every job (restore/build/test/format/api-docs) without touching each call - # site. MSBuild surfaces environment variables as properties, and `UseSharedCompilation` is only - # defaulted to true when empty, so setting it here wins. - # - # NOTE: this reaches the *runner-side* dotnet jobs only. The `build` job compiles inside - # `docker build`, where these do not propagate — the same switches are set as ENV in the - # Dockerfile's SDK stage (docker/Dockerfile) to cover it. - UseSharedCompilation: "false" # no persistent VBCSCompiler; csc runs per-project and exits - DOTNET_CLI_USE_MSBUILD_SERVER: "0" # no persistent MSBuild server process - MSBUILDDISABLENODEREUSE: "1" # MSBuild worker nodes exit with the build instead of lingering jobs: - test: + probe: 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 }} + runs-on: small steps: - - name: Checkout - uses: actions/checkout@v4 - with: - # git history/tags are needed by the `build` job's `git describe` (ersatztv#190) and, - # here, by the #420 revalidate step's `HEAD^2` tree comparison on a main merge commit. - fetch-depth: 2 - - # ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this - # REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required - # context keeps reporting — see the workflow header and docs/ci-cd.md -> "Docs-only skip". - - name: Detect docs-only changes - id: detect - run: scripts/ci-detect-docs-only.sh - - name: Detect already-validated tree (#420) - id: revalidate + - name: Post a probe status using the docker-build credentials env: ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }} - run: scripts/ci-detect-already-validated.sh - - - name: Cache NuGet packages - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }} - restore-keys: nuget-${{ runner.os }}- - - - name: Restore - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - run: dotnet restore - - # Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but - # the SPA's package downloads are project deps, so they stay cached per lockfile. - - name: Cache npm packages - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - uses: actions/cache@v4 - with: - path: ~/.npm - key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }} - restore-keys: npm-${{ runner.os }}- - - - name: Install SPA dependencies - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - working-directory: web - run: npm ci - - - name: Check generated SPA API client - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - working-directory: web - run: npm run check:api - - - name: Lint SPA - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - working-directory: web - run: npm run lint - - - name: Typecheck SPA - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - working-directory: web - run: npm run typecheck - - - name: Test SPA - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - working-directory: web - run: npm test -- --run - - - name: Build SPA - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - working-directory: web - run: npm run build - - - name: Strip Scanner project ref (matches Docker build) - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj - - # Start the true peak-anon sampler just before the memory-heavy dotnet Build/Test/Coverage so - # its high-water mark spans them (SPA build/test above are comparatively light). Paired with the - # "Report peak container memory" step below. continue-on-error + a fail-open script => this - # instrumentation never reddens a build. Why anon and not memory.peak: ersatztv#412 / - # scripts/ci-peak-anon.sh header / docs/ci-cd.md "CI build memory". - - name: Start peak-anon sampler (ersatztv#412) - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - continue-on-error: true - run: scripts/ci-peak-anon.sh start - - - name: Build - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - run: dotnet build --configuration Release --no-restore - - - name: Test - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - run: >- - dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal - --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage - - # Coverage reporting (ersatztv#15 scope item 4): coverlet.collector emits a Cobertura report - # per test project (via --collect above); ReportGenerator merges them into a human-readable - # summary printed to the log and the job step summary. No floor is enforced yet ("decide on a - # floor later"), so this step is purely informational — continue-on-error keeps a missing - # report or a transient tool-install failure from ever blocking a build. - - name: Coverage summary - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - continue-on-error: true + SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail - shopt -s globstar nullglob - reports=(coverage/**/coverage.cobertura.xml) - if [ ${#reports[@]} -eq 0 ]; then - echo "No coverage reports found under ./coverage -- skipping summary." - exit 0 - fi - echo "Found ${#reports[@]} coverage report(s)." - # reportgenerator is baked into the CI toolchain image (docker/ci/Dockerfile) and already - # on PATH — no per-run `dotnet tool` install. Bump its version there (ersatztv#390). - reportgenerator \ - "-reports:coverage/**/coverage.cobertura.xml" \ - "-targetdir:coverage/report" \ - "-reporttypes:TextSummary;MarkdownSummaryGithub" - echo "::group::Coverage summary" - cat coverage/report/Summary.txt - echo "::endgroup::" - if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f coverage/report/SummaryGithub.md ]; then - cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY" - fi - - # Memory of THIS job container, reported every run (ersatztv#406/#412, server-management#604). - # #604 sizes the runners' per-job caps on these numbers. The headline is the TRUE PEAK ANON - # sampled by the "Start peak-anon sampler" step above — NOT `memory.peak`, which is the - # high-water mark of memory.current and charges reclaimable page cache to the cgroup (a build - # job does heavy NuGet/npm/obj/bin/coverage I/O, so cache can dominate the peak). Page cache is - # reclaimed under a tighter cap, not OOM-killed, so sizing a cap off `memory.peak` inverts the - # decision. peak anon is the OOM-forcing number. Full rationale + the bumblebee demo: - # scripts/ci-peak-anon.sh header and docs/ci-cd.md "CI build memory". - # - # Runs LAST on purpose (after Coverage summary / reportgenerator, the job's last real workload) - # and stops the sampler. `always()` so a failed Build/Test still gets a peak reading; the split - # is read here (end-of-job = composition then, not at the peak instant — that is exactly why the - # sampler exists). Skipped on docs-only/already-validated runs (nothing ran to measure). - - name: Report peak container memory - # `always()` controls whether this step RUNS, not whether its failure fails the job. With - # `defaults.run.shell: bash` (`-e -o pipefail`) a stray non-zero here would redden a green - # test job, so `continue-on-error` makes it advisory — the same guarantee Coverage summary uses. - if: ${{ always() && steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' }} - continue-on-error: true - run: scripts/ci-peak-anon.sh report - - migrations: - name: EF migration integrity (SQLite + MySql) - runs-on: ubuntu-latest - container: - image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 - credentials: - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_PASSWORD }} - # Independent gate (not a 'needs' of build yet) so the new MySql-service dependency - # can't block image builds until it's proven reliable on the runner. Promote to a - # required check / build dependency once green. (ersatztv#13) - services: - mysql: - image: mysql:8.4 - env: - MYSQL_ROOT_PASSWORD: ersatztv - MYSQL_DATABASE: ersatztv_migrations - # No host-port binding: the job reaches this service as mysql:3306 on the shared - # runner network. Publishing 3306 made concurrent runs collide ("port is already - # allocated") whenever two migrations jobs overlapped. - # - # `--memory`/`--cpus` here because the runner's `container.options` (`--memory=10g`) - # applies to the JOB container ONLY, not to `services:` — verified by inspecting a live - # migrations job: the job container reported HostConfig.Memory=10737418240, its mysql - # service reported `mem=0 nanocpus=0`, i.e. unbounded. So every migrations run was adding - # an uncapped MySQL to an already-tight host (ersatztv#406, server-management#604). - # - # NOTE (ersatztv#416): a `services:` container starts whenever the JOB starts, regardless - # of step `if:`. So a docs-only migrations run still spins this mysql (capped, seconds) even - # though the DDL-replay steps below are skipped. Fully skipping the service would require an - # `if:`-skipped job, which we deliberately do NOT do for a required context — the heavy cost - # (the 787-migration replay) is what the step gating removes. - # - # `--memory-swap=2g` is NOT redundant with `--memory=2g` — it is the point. Docker defaults - # an unset `--memory-swap` to *twice* `--memory`, so `--memory=2g` alone would grant 2g RAM - # **plus 2g of swap** (verified on bumblebee: `--memory=2g` alone → memory.max=2147483648 - # AND memory.swap.max=2147483648; with `--memory-swap=2g` → memory.swap.max=0). Setting it - # equal to --memory disables swap for this container. That matters more here than anywhere: - # swap thrash on this host is the whole reason this cap exists, and a swapping mysqld mid-DDL - # is precisely the pathology behind the known `Command Timeout expired` migrations flake. We - # want a loud OOM over silent swapping — an OOM is a clear signal to raise the cap. - # - # 2g is sized on measurement rather than inheritance, but honestly: a mysql:8.4 container - # with this exact env peaked at 543 MiB during init and settled at 481 MiB idle (probed on - # bumblebee 2026-07-17). That is init+idle, NOT the 787-migration replay, which grows caches - # idle never touches — so treat 2g as a measured floor with headroom, not a measured - # ceiling. The migrations job going green is what validates it. If this OOM-kills the - # service, raise it deliberately — do not remove the cap, and do not re-enable swap. - # - # `--cpus=2` is a ceiling, not a reservation, and is the one number here with no measurement - # behind it: 787 sequential DDL statements on one connection are ~1-core-bound, so 2 is - # judgement. Revisit if the apply step's tail latency grows. - options: >- - --memory=2g - --memory-swap=2g - --cpus=2 - --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent" - --health-interval=5s - --health-timeout=5s - --health-retries=30 - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - # was the default fetch-depth: 1 (ersatztv#190); bumped to 2 so the #420 revalidate - # step's `HEAD^2` tree comparison can resolve on a main merge commit. - fetch-depth: 2 - - # ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in - # seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header. - - name: Detect docs-only changes - id: detect - run: scripts/ci-detect-docs-only.sh - - name: Detect already-validated tree (#420) - id: revalidate - env: - ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }} - run: scripts/ci-detect-already-validated.sh - - - name: Cache NuGet packages - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }} - restore-keys: nuget-${{ runner.os }}- - - - name: Restore - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - run: dotnet restore - - - name: Build - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - run: dotnet build --configuration Release --no-restore - - # dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH - # — no per-run `dotnet tool install`. Bump its version there (ersatztv#390). - - # SQLite is the prod provider; both checks validated locally. - - name: SQLite — model drift + apply all migrations to a fresh DB - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - run: | - set -euo pipefail - echo "::group::SQLite model drift (has-pending-model-changes)" - dotnet ef migrations has-pending-model-changes --no-build --configuration Release \ - --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite - echo "::endgroup::" - echo "::group::SQLite apply all migrations to a fresh DB" - export ETV_CONFIG_FOLDER="$(mktemp -d)" ETV_TRANSCODE_FOLDER="$(mktemp -d)" - dotnet ef database update --no-build --configuration Release \ - --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite - echo "::endgroup::" - - # MySql uses ServerVersion.AutoDetect (connects at config time), so it runs against the - # service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString". - - name: MySql — model drift + apply all migrations to a fresh DB - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - env: - # DefaultCommandTimeout is raised from MySqlConnector's 30s default: replaying every - # migration to a fresh DB issues DDL commands that can exceed 30s when two migration jobs - # share a runner host (each spins its own mysql:8.4 service) and starve each other. That - # contention produced both "Command Timeout expired" and mid-replay connection drops - # (MySqlEndOfStreamException) — neither is a model problem. See #13 / #236. - MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;DefaultCommandTimeout=300;" - run: | - set -euo pipefail - echo "::group::MySql model drift (has-pending-model-changes)" - dotnet ef migrations has-pending-model-changes --no-build --configuration Release \ - --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql - echo "::endgroup::" - echo "::group::MySql apply all migrations to a fresh DB" - # Retry the apply: under concurrent-runner MySQL contention the server can drop the - # connection mid-replay. Each attempt resumes from __EFMigrationsHistory (EF wraps each - # migration in its own transaction, so an interrupted migration rolls back cleanly and the - # retry continues from the last committed one) — so this only papers over infra flakiness, - # never a real migration failure, which fails deterministically on every attempt. - attempt=1 - max=3 - until dotnet ef database update --no-build --configuration Release \ - --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql; do - if [ "$attempt" -ge "$max" ]; then - echo "MySql apply failed after ${max} attempts" >&2 - exit 1 - fi - echo "MySql apply attempt ${attempt} failed (likely runner MySQL contention); retrying in 15s..." >&2 - attempt=$((attempt + 1)) - sleep 15 - done - echo "::endgroup::" - - # 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. - - functional-e2e: - name: Functional E2E (curl + UI contracts) - runs-on: ubuntu-latest - # Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E - # flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract, - # If-Match/412, and since ersatztv#363 two lock-contention 409s) that sessions have been - # re-running by hand. Deliberately NOT a `needs:` of `build` and not (yet) a required check, so a - # functional-E2E flake can't block image builds or the unit-test gate — promote it to a required - # check / build dependency once it's proven reliable (same rollout the `migrations` job used). - # SQLite default provider -> no DB service. Runs on PRs and on main (regression net); skipped for - # v* tag builds. - if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' - container: - image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 - credentials: - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_PASSWORD }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - # bumped from 1 (ersatztv#190 default) so the #420 revalidate step's `HEAD^2` tree - # comparison can resolve on a main merge commit. - fetch-depth: 2 - - # ersatztv#416: docs-only? Skip the boot + curl harness (advisory job; safe to no-op). - - name: Detect docs-only changes - id: detect - run: scripts/ci-detect-docs-only.sh - - name: Detect already-validated tree (#420) - id: revalidate - env: - ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }} - run: scripts/ci-detect-already-validated.sh - - - name: Cache NuGet packages - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }} - restore-keys: nuget-${{ runner.os }}- - - - name: Restore - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - run: dotnet restore - - - name: Cache npm packages - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - uses: actions/cache@v4 - with: - path: ~/.npm - key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }} - restore-keys: npm-${{ runner.os }}- - - - name: Install SPA dependencies - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - working-directory: web - run: npm ci - - - name: Build SPA - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - working-directory: web - run: npm run build - - - name: Build (Release) - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - run: dotnet build ErsatzTV.sln --configuration Release --no-restore - - # The old `command -v ffmpeg || sudo apt-get install ffmpeg` step is gone (ersatztv#390): - # the toolchain image ships the same ffmpeg build prod runs, so the binary is already here. - # That step also cost 110s of every run. The harness never *transcodes*, but since ersatztv#363 - # it does use ffmpeg to synthesize ~60 tiny testsrc clips to seed the scan-lock 409 flow (and - # python3's stdlib sqlite3 to seed the DB rows the API can't create) — both already present in - # the image, so still no per-run install. The scan flow self-skips if ffmpeg is ever absent. - - - name: Boot instance and run functional-E2E harness - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - run: | - set -euo pipefail - export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409 - CFG="$(mktemp -d)" - # e2e-local.sh copies wwwroot, launches the DLL in the background (logging to a file, so - # this command substitution returns as soon as the app is ready), and prints PID/CONFIG_DIR. - OUT="$(scripts/e2e-local.sh "$CFG")" - printf '%s\n' "$OUT" - PID="$(printf '%s\n' "$OUT" | awk -F= '/^PID=/{print $2}')" - trap 'kill "$PID" 2>/dev/null || true' EXIT - scripts/e2e-functional.sh "http://localhost:${ETV_UI_PORT}" "$CFG" - - # ersatztv#445: the UI-interactive flows the curl harness structurally CANNOT express — - # client-side form validation, AuthGate's rendered states, the session cookie authenticating the - # SPA's own /api XHRs, and sign-out through the UserMenu. - # - # Why in THIS job rather than its own: the dominant cost here is `npm ci` + the Release build, - # which are already done above. A separate job would duplicate both to add ~5s of browser work. - # The browser itself is baked into the toolchain image (docker/ci/Dockerfile — - # chromium-headless-shell), so this step installs nothing. - # - # It boots its OWN fresh instance on a DIFFERENT port: the first spec asserts the one-shot Setup - # gate, which the curl harness's auth section has already claimed on its own config dir, and a - # separate port keeps this independent of the previous step's teardown timing. - - name: Run UI-E2E Playwright flows (headless) - if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' - run: | - set -euo pipefail - export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8410 - # e2e-ui.sh owns the whole lifecycle: fresh config dir, boot, run specs, always kill the - # server. Its exit status is Playwright's. - scripts/e2e-ui.sh - - build: - name: Build & push image (amd64) - # Moved back off `small` (server-management#639). This is the one HEAVY job that - # was still in that lane, and its 10g requirement was what pinned the lane's - # per-job cap at 10g — which in turn capped the lane at ONE slot on a 25 GiB - # host. Four jobs sharing one slot is what starved the git-only checks in act's - # setup phase (>10 min, no logs, then fail). With this job gone, `small` is - # git-only and can run wide and tiny on two hosts. - # - # The `ubuntu-latest` queueing that sent it to `small` in the first place - # (server-management#574: a PR-run skip stuck 31 min behind long builds) does not - # come back, because `needs: [test, migrations]` means this job cannot be - # dispatched until those two have already finished — by which point the lane it - # was queueing behind has drained. Real builds (main/tags) get the full - # ubuntu-latest allotment: 4 CPUs / 10g on ci-runner (.127). - runs-on: ubuntu-latest - needs: [test, migrations] - if: github.event_name != 'pull_request' - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # ersatztv#416: a docs-only push to main has nothing to rebuild (docs are not in the image), - # so skip the build/push/smoke steps — the job still reports success. Tag builds force - # docs_only=false in the script, so a release is never skipped. - - name: Detect docs-only changes - id: detect - run: scripts/ci-detect-docs-only.sh - - - name: Compute version and tags - id: meta - if: steps.detect.outputs.docs_only != 'true' - run: | - SHORT=$(git rev-parse --short HEAD) - if [ "${GITHUB_REF_TYPE}" = "tag" ]; then - VERSION="${GITHUB_REF_NAME#v}" - INFO_VERSION="${VERSION}" - TAGS=("${IMAGE}:prod" "${IMAGE}:${VERSION}" "${IMAGE}:${SHORT}") - else - DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0) - INFO_VERSION="${DESC#v}-${SHORT}" - TAGS=("${IMAGE}:latest" "${IMAGE}:${SHORT}") - fi - echo "info_version=${INFO_VERSION}" >> "$GITHUB_OUTPUT" - echo "short=${SHORT}" >> "$GITHUB_OUTPUT" - { - echo "tags<<__EOT__" - printf '%s\n' "${TAGS[@]}" - echo "__EOT__" - } >> "$GITHUB_OUTPUT" - echo "INFO_VERSION=${INFO_VERSION}" - printf 'tag: %s\n' "${TAGS[@]}" - - - name: Set up Docker Buildx - if: steps.detect.outputs.docs_only != 'true' - uses: docker/setup-buildx-action@v3 - with: - buildkitd-config-inline: | - [registry."192.168.1.95:3000"] - http = true - - - name: Login to Gitea registry - if: steps.detect.outputs.docs_only != 'true' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_PASSWORD }} - - - name: Build and push - if: steps.detect.outputs.docs_only != 'true' - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - platforms: linux/amd64 - # only publish from main or a v* tag; other refs (e.g. branch dispatch) build only - push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }} - provenance: false - build-args: | - INFO_VERSION=${{ steps.meta.outputs.info_version }} - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache - cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true - - - name: Smoke + IPTV E2E (assert key endpoints) - if: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && steps.detect.outputs.docs_only != 'true' }} - run: | - IMG="${IMAGE}:${{ steps.meta.outputs.short }}" - NAME="etv-smoke-${{ github.run_id }}" - trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT - echo "Pulling ${IMG}" - docker pull "$IMG" - # --memory-swap equal to --memory disables swap. Without it Docker defaults --memory-swap - # to 2x --memory, so `--memory 2g` alone silently grants 2g RAM + 2g swap (ersatztv#406). - docker run -d --name "$NAME" --memory 2g --memory-swap 2g \ - -e ETV_CONFIG_FOLDER=/tmp/etv/config \ - -e ETV_TRANSCODE_FOLDER=/tmp/etv/transcode \ - "$IMG" - # probe ErsatzTV's web server from inside the container (image ships python3) - cat > probe.py <<'PY' - import urllib.request, urllib.error, sys - try: - urllib.request.urlopen("http://localhost:8409/", timeout=3) - except urllib.error.HTTPError: - pass # any HTTP status means the server is serving - except Exception: - sys.exit(1) # not listening yet - PY - ok=0 - for _ in $(seq 1 60); do - if [ -z "$(docker ps -q --filter name="$NAME" --filter status=running)" ]; then - echo "Container exited early"; break - fi - if docker exec -i "$NAME" python3 - < probe.py >/dev/null 2>&1; then - ok=1; break - fi - sleep 2 - done - if [ "$ok" != "1" ]; then - echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true - echo "Smoke test FAILED: ErsatzTV did not serve HTTP on :8409" - exit 1 - fi - echo "HTTP ready; asserting key IPTV endpoints (ersatztv#16)" - # E2E: assert the real Jellyfin-facing surfaces serve a valid playlist + guide, not just - # that the app answers HTTP. xmltv.xml needs channels.xml, which the scheduler writes a - # few seconds after boot, so poll each endpoint until it returns 2xx with the right shape. - # urlopen() returns only on 2xx (raises on 4xx/5xx), so reaching sys.exit means status OK. - check() { - local path="$1" needle="$2" i - for i in $(seq 1 20); do - if docker exec "$NAME" python3 -c "import urllib.request,sys; b=urllib.request.urlopen('http://localhost:8409$path',timeout=5).read(512).decode('utf-8','replace'); sys.exit(0 if '$needle' in b else 1)" 2>/dev/null; then - echo " OK $path (2xx, contains '$needle')"; return 0 - fi - sleep 3 - done - echo " FAIL $path (no 2xx containing '$needle' within timeout)"; return 1 - } - if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "&1 | tail -n 40 || true - exit 1 - fi - - # BLOCKING (unlike docs-reminder): the mechanizable half of the "docs-update in the - # same PR" rule for the API contract (ersatztv#303 H4/H5). If a PR touches the API - # surface (ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/**), the generated - # artifacts — v1.json (OpenAPI spec), v1.d.ts (SPA client), endpoint-index.md — MUST - # already be regenerated in the diff. We rebuild them from source and fail on any drift. - # Also covers the "regenerate artifacts after merging main into a PR branch" lore bullet. - # - # Path-gated INSIDE the job (not via top-level `if:`) so the check always reports a - # status on every PR and can be a required check without stalling API-free PRs: when no - # API path changed, the expensive steps skip and the job passes trivially. - api-docs: - name: API docs in sync (OpenAPI + endpoint index) - # `small` lane (ersatztv#390): this job is ~5s on the ~90% of PRs that touch no API path, but - # it was queueing ~29 min behind the heavy jobs in the contended `ubuntu-latest` lane, which - # only has bumblebee-runner (capacity 2) + ci-runner. `small` has capacity 4, the same base - # image, and answers in ~5s. Moving it here (and `format`) also drops `ubuntu-latest` from 5 - # jobs to 3, which shortens the queue for `test`/`migrations`/`functional-e2e` too. - # This is only possible because `container:` makes the job self-contained — it no longer needs - # the runner image to supply .NET/Node. - # - # REVERTED to `ubuntu-latest` (server-management#604 / ersatztv#406). The caveat below the - # original #390 rationale turned out to be the deciding factor: on an API-touching PR this - # job does a full `dotnet build`, so it is NOT a small job, and "capacity 4 absorbs that" was - # only true while nothing enforced the SUM of the lanes' memory caps. It didn't: 6 slots x 10g - # on a 25 GiB host drove bumblebee to load 713 with 21 GiB swapped. The `small` lane is now - # sized for genuinely-tiny jobs, and #604 grew the `ubuntu-latest` lane instead (ci-runner - # 48 GiB at capacity 4 + a bumblebee overflow slot), which fixes the queue at the source. - runs-on: ubuntu-latest - container: - image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 - credentials: - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_PASSWORD }} - if: github.event_name == 'pull_request' - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Detect API-surface changes - id: detect - run: | - base_ref="${{ github.base_ref }}" - git fetch --no-tags --depth=100 origin "$base_ref" || true - changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)" - echo "Changed files in this PR:"; printf '%s\n' "$changed" - if printf '%s\n' "$changed" | grep -Eq '^ErsatzTV/Controllers/Api/|^ErsatzTV\.Core/Api/'; then - echo "api_changed=true" >> "$GITHUB_OUTPUT" - echo "API surface changed -> will verify generated artifacts are in sync." - else - echo "api_changed=false" >> "$GITHUB_OUTPUT" - echo "No API-surface change -> skipping regeneration (job passes)." - fi - - - name: Cache NuGet packages - if: steps.detect.outputs.api_changed == 'true' - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }} - restore-keys: nuget-${{ runner.os }}- - - - name: Restore - if: steps.detect.outputs.api_changed == 'true' - run: dotnet restore - - - name: Cache npm packages - if: steps.detect.outputs.api_changed == 'true' - uses: actions/cache@v4 - with: - path: ~/.npm - key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }} - restore-keys: npm-${{ runner.os }}- - - - name: Install SPA dependencies - if: steps.detect.outputs.api_changed == 'true' - working-directory: web - run: npm ci - - - name: Regenerate OpenAPI spec + endpoint index - if: steps.detect.outputs.api_changed == 'true' - run: ./scripts/update-openapi.sh - - - name: Regenerate SPA API client types - if: steps.detect.outputs.api_changed == 'true' - working-directory: web - run: npm run generate:api - - - name: Fail on stale generated artifacts - if: steps.detect.outputs.api_changed == 'true' - run: | - if ! git diff --exit-code -- \ - ErsatzTV/wwwroot/openapi/v1.json \ - web/src/api/generated/v1.d.ts \ - docs/endpoint-index.md; then - echo "::error::This PR changes the API surface but its generated artifacts are stale. Run './scripts/update-openapi.sh && (cd web && npm run generate:api)' and commit v1.json / v1.d.ts / endpoint-index.md in THIS PR (CLAUDE.md → Conventions; ersatztv#303 H4/H5)." - exit 1 - fi - echo "Generated API artifacts are in sync." - - # Formatting-as-you-touch gate (ersatztv#311): verify the .cs files THIS PR changed conform to - # .editorconfig whitespace + charset=utf-8 (i.e. no UTF-8 BOM). Scoped to changed files so it - # enforces "normalize a legacy file when you touch it" WITHOUT a big-bang reformat of the ~2500 - # pre-existing BOM files. A PR that touches no .cs skips the check and passes trivially (always - # reports a status, so it is safe as a required check). - # - # ersatztv#469: uses `dotnet format whitespace . --folder`, NOT the full `dotnet format `. - # `--folder` treats the tree as a plain folder of files and skips the MSBuild/Roslyn workspace load - # + per-project compilation that dominated the old recipe (~8 min locally on a whole-solution run) — - # `--include` only ever narrowed *which* files were checked, never what got loaded. Folder mode - # reads .editorconfig and still flags WHITESPACE (indent/EOL/trailing/final-newline) and CHARSET - # (BOM) violations — exactly what this gate exists to catch — in ~0.5s with no `dotnet restore`. - # What it drops is the style/analyzer pass (naming/`var`/qualification), which this gate never - # meaningfully enforced: those .editorconfig rules are :suggestion/:none severity. Full rationale + - # non-vacuity evidence: docs/ci-cd.md → Formatting; docs/decisions.md. - format: - name: Formatting (changed .cs conform to .editorconfig) - # Folder-mode whitespace is now a seconds-long, low-memory job (no Roslyn workspace, unlike the - # 3.95 GiB full `dotnet format` measured in #406), so it no longer needs the memory headroom that - # kept it on `ubuntu-latest`. Left here to avoid re-touching the lane/memory-cap accounting; a - # move to a lighter lane is a server-management capacity call (#604). - runs-on: ubuntu-latest - container: - image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 - credentials: - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_PASSWORD }} - if: github.event_name == 'pull_request' - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Detect changed C# files - id: detect - run: | - base_ref="${{ github.base_ref }}" - git fetch --no-tags --depth=100 origin "$base_ref" || true - changed="$(git diff --name-only --diff-filter=ACM "origin/${base_ref}...HEAD" -- '*.cs' 2>/dev/null || true)" - echo "Changed .cs files in this PR:"; printf '%s\n' "$changed" - if [ -n "$changed" ]; then - printf '%s\n' "$changed" > /tmp/changed-cs.txt - echo "cs_changed=true" >> "$GITHUB_OUTPUT" - echo "-> will verify these files conform to .editorconfig." - else - echo "cs_changed=false" >> "$GITHUB_OUTPUT" - echo "No .cs change -> skipping format verify (job passes)." - fi - - - name: Verify formatting of changed .cs files - if: steps.detect.outputs.cs_changed == 'true' - shell: bash - run: | - mapfile -t files < /tmp/changed-cs.txt - echo "Verifying ${#files[@]} changed .cs file(s) against .editorconfig (whitespace + charset)..." - if ! dotnet format whitespace . --folder --verify-no-changes --include "${files[@]}"; then - echo "::error::One or more .cs files this PR touches don't conform to .editorconfig (whitespace or a UTF-8 BOM). Run 'dotnet format whitespace . --folder --include ' (or the full 'dotnet format ErsatzTV.sln --include ') and commit the result in THIS PR — the fix-as-you-touch convention (docs/contributing.md §7; ersatztv#311). Legacy files you did NOT touch are unaffected." - exit 1 - fi - echo "All changed .cs files conform to .editorconfig." + if [ -z "${ETV_STATUS_AUTH:-}" ]; then echo "NO CREDS"; exit 0; fi + code=$(curl -s -o /tmp/o -w '%{http_code}' -u "$ETV_STATUS_AUTH" -X POST \ + -H 'Content-Type: application/json' \ + -d '{"state":"success","context":"review-verdict/h10-PROBE672D","description":"ersatztv#672 probe D: posted by docker-build.yml credentials"}' \ + "https://gitea.tblindustries.be/api/v1/repos/timothy/ersatztv/statuses/$SHA") + echo "HTTP $code"; cat /tmp/o | head -c 400 diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml deleted file mode 100644 index 315cdc6c2..000000000 --- a/.gitea/workflows/pr-checks.yml +++ /dev/null @@ -1,262 +0,0 @@ -name: PR Gates - -# Fast, git-only PR gates split out of docker-build.yml into a dedicated `on: pull_request` -# workflow (ersatztv#535) so they are NEVER created on a tag/main push. -# -# WHY THIS FILE EXISTS. These checks are cheap `checkout + git diff` gates (or, for `script-tests`, -# checkout + pytest): they carry no `container:`, run on the `small` lane (git-only, 1 GiB; -# server-management#639), and are PR-only. -# While they lived in docker-build.yml — which also triggers on push to main and on `v*` tags — -# Gitea still DISPATCHED them as runner tasks on every such push to evaluate the `if:` skip, because -# **Gitea dispatches a job as a runner task even when its `if` skips it** (docs/ci-cd.md -> the -# `small` lane). On the v26.12.0 release tag those dispatched skip-tasks wedged in act's setup phase -# and were killed by a runner restart mid-setup, so they reported `failure` (no logs) and reddened -# the tag's overall commit status even though the release built, scanned, and deployed fine -# (ersatztv#535). The two PR-only jobs on `ubuntu-latest` (`api-docs`, `format`) carry the identical -# `if:` and skipped cleanly on the same tag — the job logic was never the problem; the kill happens -# in the dispatch window before any step or `if:`-skip runs. -# -# Gitea evaluates a workflow's TRIGGER before creating any job, so a `pull_request`-only workflow -# produces ZERO jobs on a tag/main push: no dispatch, no kill, no spurious red. That is the whole -# fix. The per-job `if: github.event_name == 'pull_request'` guards are kept as belt-and-suspenders -# (they also encode "these steps need a PR base_ref"; harmless given the trigger). -# -# These stay on `runs-on: small` and carry NO CI toolchain image pin, so `ci-image-pin`'s grep of -# docker-build.yml still validates the five pin-bearing jobs (test/migrations/functional-e2e/ -# api-docs/format) that remain there. None of these jobs are required checks — branch protection -# requires only `Build & test (.NET)`, `EF migration integrity` and `review-verdict/h10` — so -# relocating them (which changes their status-context prefix from "Build ErsatzTV Image / …" to -# "PR Gates / …") does not affect merges. See docs/ci-cd.md -> "PR gates workflow". - -on: - pull_request: - -# git-only host-runner jobs: no `container:`, so the runner default shell would be bash anyway, but -# declare it explicitly — ci-image-pin uses `mapfile`/`set -o pipefail`, which die under dash. -defaults: - run: - shell: bash - -# Per-ref: a new push to the PR supersedes its in-flight gate run. Only runs on PRs, so always cancel. -concurrency: - group: ersatztv-pr-gates-${{ github.ref }} - cancel-in-progress: true - -jobs: - # BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml must name the image that - # ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's - # sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own - # jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's - # Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** — - # it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque - # `:` in `container.image`, so it would leave the pin behind every time. - # - # Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the - # Dockerfile change, let ci-image.yml publish `:`, then update the pin to that sha. Seconds-long - # git+grep -> keep it off the build runners. - ci-image-pin: - name: CI image pin matches docker/ci - runs-on: small - if: github.event_name == 'pull_request' - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - # need real history: `git log -- ` on a shallow clone can't find the last - # commit that touched the image sources - fetch-depth: 0 - - name: Verify the pin matches the last-published image - run: | - set -euo pipefail - # ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it - # only builds on pushes touching these paths — so the published image is named by the last - # commit to touch them. - # - # Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length - # with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone - # is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always. - expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)" - mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u) - echo "Image sources last changed in: ${expected}" - echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)" - if [ "${#pins[@]}" -eq 0 ]; then - echo "::error::No ersatztv-ci pin found in docker-build.yml at all. Every container: job must pin ersatztv-ci:<7-char-sha>; if the grep pattern stopped matching, fix it here too (docs/ci-cd.md -> 'CI toolchain image')." - exit 1 - fi - if [ "${#pins[@]}" -ne 1 ]; then - echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together." - exit 1 - fi - # LENGTH is a separate invariant from CORRECTNESS, and only this check covers it - # (ersatztv#594). The resolve + staleness checks below compare RESOLVED shas, so a - # 8/9/10-char abbreviation of the right commit sails through them green — while - # matching NO tag in the registry, because ci-image.yml tags with - # `git rev-parse --short HEAD` under `fetch-depth: 1`, which always yields exactly 7. - # The failure would otherwise surface far downstream as all five `container:` jobs - # dying at image-pull with `manifest unknown`, which reads like a registry outage. - # This is an easy mistake to make: the natural local command prints 8 chars. - # - # Deliberately a literal 7, not a derived `git rev-parse --short=7`: in this full - # clone git may widen an ambiguous abbreviation past 7, which would demand a pin - # ci-image.yml can never publish — the exact clone-depth asymmetry noted above. - # `${expected:0:7}` is plain string truncation, so it is safe to suggest. - # - # ESCAPE HATCH, if you are ever stuck: this makes 7 mandatory, so if `${expected:0:7}` ever - # became an AMBIGUOUS prefix (two objects sharing it), the resolve check below would fail - # and a longer pin — previously the workaround — is now rejected here first. There is no - # in-repo remedy in that state: relax this length check in the same PR and say why. Note - # that ci-image.yml still tags with a plain `--short` (auto-scaled), so "always 7" is an - # empirical property of today's shallow clone, not an enforced invariant. Making the - # publisher emit `--short=7` is tracked as ersatztv#597. It is not blocked, just out of - # scope here: editing ci-image.yml re-points `expected` (above) at that commit, so it needs - # the branch's own publish-then-pin two-step (docs/ci-cd.md -> 'CI toolchain image') — - # ci-image.yml's push trigger has no branches: filter, so a feature branch does publish. - if [ "${#pins[0]}" -ne 7 ]; then - echo "::error::CI toolchain image pin ersatztv-ci:${pins[0]} is ${#pins[0]} chars, but ci-image.yml publishes 7-char tags (it tags with 'git rev-parse --short HEAD' from a fetch-depth:1 clone). A differently-sized abbreviation still resolves to the right commit, so this would pass every other check here — but NO such tag exists in the registry, and all five container: jobs would fail at image-pull time with 'manifest unknown'. Pin exactly: ersatztv-ci:${expected:0:7} (locally: git rev-parse --short=7 HEAD). See docs/ci-cd.md -> 'CI toolchain image'." - exit 1 - fi - pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)" - if [ -z "$pin_full" ]; then - echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints." - exit 1 - fi - if [ "$pin_full" != "$expected" ]; then - echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')." - exit 1 - fi - echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change." - - # Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn. - # The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission. - # Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the - # cache-save issues seen on the relocated runner (server-management#570). - docs-reminder: - name: Docs update reminder - runs-on: small # seconds-long git diff; keep it off the build runners - if: github.event_name == 'pull_request' - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Warn when a screen/route change skips the parity doc - run: | - base_ref="${{ github.base_ref }}" - git fetch --no-tags --depth=100 origin "$base_ref" || true - changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)" - echo "Changed files in this PR:"; printf '%s\n' "$changed" - screen_or_route=no - if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then - screen_or_route=yes - fi - parity=no - if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then - parity=yes - fi - if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then - echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions." - else - echo "Parity-doc reminder: nothing to flag." - fi - - # BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision- - # record lifecycle invariants (metadata schema, one active record per key, reciprocal - # supersedes/superseded-by links, no rationale-prose rewrite without a Decisions-Edit: yes git - # trailer (ersatztv#609 — never a bare substring, which prose about the marker could arm), no record - # vanishing from the active set without an archive copy) and that the generated active catalog - # (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so - # local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners. - decisions-guard: - name: decisions lifecycle - runs-on: small - if: github.event_name == 'pull_request' - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - name: Validate decision lifecycle - run: | - base_ref="${{ github.base_ref }}" - git fetch --no-tags --depth=200 origin "$base_ref" || true - PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD - - name: Active catalog in sync - run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check - - name: Kickoff guard - run: bash scripts/check-kickoff-guard.sh - - # FAILS THE RUN on a red (ersatztv#631) — like its sibling gates here it is not (yet) a required - # status check, so it reddens the PR without hard-blocking the merge button; see the header. - # Runs scripts/tests/ — the pytest suite covering the decision-corpus - # parser/validator/catalog builder, the #610 migration-equivalence harness, the merge-consent - # exemption logic and the #622 review-verdict poster. Until #631 NOTHING executed these: no - # workflow and no Husky hook invoked pytest, so the suite guarding our merge-gating machinery was - # local-only and a regression in it was caught only by luck. `decisions-guard` above runs that - # code, but never its tests. - # - # WHY ITS OWN JOB rather than a step inside decisions-guard (which the issue proposed as the - # cheapest home): `ci.decisions-lifecycle-flake` is a STANDING instruction that a lone - # `decisions lifecycle` red is a known infra flake to be ignored — "do not investigate". Folding - # the suite into that job would make a genuine pytest regression present as exactly the red every - # session is told to wave through, which is the same silently-green failure mode #631 exists to - # close. A distinct job name keeps a real failure unambiguous. - # - # Runs UNCONDITIONALLY on every PR rather than behind a `scripts/**` path filter. The suite's - # corpus tests are fixture/tmp-repo based, but test_post_review_verdict.py and - # test_merge_consent_exemption.py execute the REAL `scripts/post-review-verdict.sh` and - # `.claude/hooks/pretooluse-merge-consent.sh`, so its true input set spans at least two top-level - # directories. A `scripts/**` filter would silently miss a `.claude/hooks/**` edit — and at ~10s a - # filter buys nothing but drift. - script-tests: - name: Script tests (pytest) - runs-on: small - if: github.event_name == 'pull_request' - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - # pytest + PyYAML. PyYAML is NOT a contradiction of the dependency-free decisions READ path: - # `decisions_lib._read_frontmatter` is hand-written precisely so validation runs where nothing - # is installed, but the one-shot WRITE path `migrate_decisions_split.py` uses PyYAML by - # design — and `test_migration_equivalence.py` imports that module, so the suite needs it. - # `pytest` and `yaml` are the complete third-party set, established by an AST import scan over - # all of scripts/ rather than by reading the files that seemed relevant: the first cut of this - # job claimed "pure stdlib", passed locally on a machine that happened to have PyYAML, and - # went red in CI on a collection error. - - name: Install test dependencies - run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml - # Preflight, not an install (ersatztv#390 removed run-time `apt-get` from CI on purpose). - # test_post_review_verdict.py and test_merge_consent_exemption.py exec the REAL - # post-review-verdict.sh / pretooluse-merge-consent.sh, which shell out to `jq` ~26 times. - # `curl` those tests shim on PATH; `jq` they do NOT. If it were missing, the suite would fail - # as ~20 opaque assertion errors — this turns that into one actionable line. - - name: Preflight external tools - run: | - if ! command -v git >/dev/null 2>&1; then - echo "::error::script-tests needs git on PATH but it is absent. The suite execs real" \ - "shell scripts that use it. Bake it into the runner image rather than apt-get" \ - "installing here (see ersatztv#390)." - exit 1 - fi - echo "Preflight OK: $(git --version)" - # jq gets its OWN step because its VERSION, not merely its presence, is load-bearing - # (ersatztv#648). `--expect` makes this a TRIPWIRE: scripts/tests exercises the jq 1.6 code path - # only because this runner ships 1.6, so an upgrade would silently delete that coverage — and - # the three divergences found in ersatztv#643/#647 all lived exactly there. Going red forces an - # explicit human decision instead of letting the coverage evaporate. - # - # The pin lives HERE and deliberately NOT in review-verdict.yml: that workflow writes the - # branch-protection-required `review-verdict/h10` status, so pinning a version there would turn - # any jq bump on the runner into a repo-wide merge deadlock. It gets the floor-only mode. - # See docs/ci-cd.md -> "The jq contract". - - name: Preflight jq version - run: ./scripts/jq-preflight.sh --expect 1.6 - - name: Run scripts/tests - run: PYTHONPATH=. python3 -m pytest scripts/tests -q -- 2.47.3