Round 4, and the third cold review found the same mechanism failing again, so it is
removed rather than patched a tenth time.
WHAT KEPT BREAKING. Three versions of this guard asked "does this command RUN the suite,
and can its failure be swallowed?" of arbitrary shell text. That predicate was wrong NINE
times across three review rounds, and twice a clause added to remove a FALSE RED opened a
FALSE GREEN on the guard's headline assertion:
* heredoc bodies were skipped as data, but BuildKit EXECUTES `RUN <<EOF` — and the
opener regex also fired inside quotes (`echo "tags<<__EOT__"`), which blinded the
whole-file scan over the last 303 lines of docker-build.yml. Wrong in both directions
at once, and measurably live on this tree.
* `shlex.shlex` does not clear `commenters` the way `shlex.split` does, so `#`
truncated a command mid-word — including the live `${#reports[@]}` idiom — and made
this file's own stated residual false.
* compound punctuation (`);`) welded two commands into one segment.
* `npm t`, `./node_modules/.bin/vitest`, `pnpm vitest`, `yarn vitest`,
`node …/vitest.mjs`, `timeout …`, `su -c …`, `if npm test; then` — all invisible.
* `true || npm test` counted as the gating run while never executing it.
* `continue-on-error: ${{ … }}` passed a check written against two literals — a
presence test that cannot see polarity, fail-OPEN in the one direction that matters.
WHAT REPLACES IT. Nothing in the file decides what a command means any more. The commands
that may run in the two risky places are PINNED as text: the `RUN` lines of every
SPA-carrying Dockerfile stage, and the gating step's `run:` body and `if:`. A suite run
re-added in ANY spelling is simply not equal to its pin — the pin does not have to
recognise a spelling in order to reject it. A pin cannot produce a false green, only a
false red, and a false red is a human reading a diff they should have read anyway.
The population/pin split is the load-bearing distinction, and it is now stated in the
inventory: a POPULATION decides what is CHECKED, so a hand-written one goes silently
short; a PIN decides what is EXPECTED, so a stale one goes loudly red. Only the second is
safe to write by hand. Populations stay derived from the git index.
Two premises that were prose are now assertions: the publish step keeps its own
`docs_only` gate (without it, a docs-only push skips the suite and publishes anyway), and
no step other than the pinned one mentions the suite — a SUBSTRING sweep, deliberately
not a predicate, whose failure mode is a false red asking someone to look.
41 mutants, 0 missed, including all nine spellings above and the three from the previous
round. Exactly ONE is declared in `mutation_manifest.py` and re-executed every suite; the
other 40 were witnessed during development and are NOT standing — stated in the row
rather than left to be assumed.
Also fixed: the truncated sentence the round-2 rewrite left in the Dockerfile comment,
and the `web/src/api/*.guard.test.ts` glob, which over-claimed — it matches three files
and only two of them need git.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
142 lines
7.8 KiB
Docker
142 lines
7.8 KiB
Docker
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-amd64 AS dotnet-runtime
|
|
|
|
FROM node:22-bookworm-slim AS web-build
|
|
WORKDIR /source
|
|
COPY web/package*.json ./web/
|
|
WORKDIR /source/web
|
|
RUN npm ci
|
|
WORKDIR /source
|
|
COPY design-system/. ./design-system/
|
|
COPY web/. ./web/
|
|
WORKDIR /source/web
|
|
# THE VITEST SUITE IS NOT RUN IN THIS STAGE, AND MUST NOT BE RE-ADDED (ersatztv#887).
|
|
# This stage is gitless in both senses at once. It copies only `web/` and `design-system/`, so it
|
|
# holds no `.git` — a property of the STAGE, not of the context, which is the repository root
|
|
# (`context: .`) and whose `.dockerignore` does not exclude `.git`: the directory is there to be
|
|
# copied and simply is not. And `node:22-bookworm-slim` ships no git binary (`command -v git` -> not
|
|
# found), which is why a `COPY .git` would not help either. Members of the SPA suite need one or the
|
|
# other: `pageSizeCallSites.guard.test.ts` and `completeAnnotations.guard.test.ts` derive their file
|
|
# population from `git ls-files` and refuse to fall back to a directory walk
|
|
# (`testing.guard-derives-population-from-source`), and `trackedSourceFiles.realgit.test.ts` builds
|
|
# its own temp repository. (Not every `*.guard.test.ts` — `completeRequest.guard.test.ts` needs no
|
|
# git at all.)
|
|
#
|
|
# Running the suite here anyway therefore costs a hand-maintained list of the members that cannot
|
|
# run — and that list is a population nothing derives. It went stale the first time a guard was
|
|
# added without updating it, and because `Build & push image (amd64)` is `if: github.event_name !=
|
|
# 'pull_request'`, the resulting red is unreachable on a PR and lands on `main` and on the `v*` tag
|
|
# path: every image build failed and `:latest` stopped being republished. Excluding one more file
|
|
# re-arms that; the list is removed instead.
|
|
#
|
|
# WHAT STILL VALIDATES THE SUITE. `docker-build.yml`'s `test` job runs it UNFILTERED on a real
|
|
# checkout, and `build` — the job that invokes this Dockerfile — carries `needs: [test, migrations,
|
|
# scan]`, so an image cannot be published past a red suite. Two skips exist inside `test` and
|
|
# neither leaves an image unvalidated: `docs_only` also gates `Build and push`, so that arm ships
|
|
# nothing; the #420 revalidate skip fires only on a tree byte-identical to a head that already
|
|
# carried a green combined status, i.e. this exact source already passed. Held by
|
|
# `scripts/tests/test_image_build_delegates_the_spa_suite.py`. That guard PINS the command line
|
|
# below as text: any edit to it reddens, whatever the edit says. Update the pin in the same commit,
|
|
# and do not use it to re-add a suite run.
|
|
#
|
|
# `lint` and `typecheck` stay. They are gitless-safe with no member that is not, so they carry no
|
|
# list and no trap; whether the image build should run them at all is a separate question this
|
|
# does not answer.
|
|
RUN npm run lint && npm run typecheck && npm run build
|
|
|
|
FROM --platform=linux/amd64 192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2 AS runtime-base
|
|
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
|
|
RUN apt-get update && \
|
|
apt-get install -y --no-install-recommends python3 python3-pip && \
|
|
python3 -m pip install --target=/app/pythonlibs --no-cache-dir streamlink && \
|
|
apt-get clean -y && \
|
|
rm -rf /var/lib/apt/lists/*
|
|
|
|
# https://hub.docker.com/_/microsoft-dotnet
|
|
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-amd64 AS build
|
|
RUN apt-get update && apt-get install -y ca-certificates gnupg default-jre-headless python3-pip
|
|
WORKDIR /source
|
|
|
|
# download the openapi-generator jar first so this layer is cached independently
|
|
# of the openapi spec dir below -- otherwise every spec change (e.g. v1.json)
|
|
# busts the layer and re-downloads the ~30MB jar (ersatztv#190)
|
|
RUN wget https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.15.0/openapi-generator-cli-7.15.0.jar
|
|
|
|
# generate openapi client
|
|
COPY ErsatzTV/wwwroot/openapi/. /app/ErsatzTV/wwwroot/openapi/
|
|
RUN java -jar openapi-generator-cli-7.15.0.jar generate -i /app/ErsatzTV/wwwroot/openapi/scripted-schedule.json -g python -o /app/etv-client --package-name etv_client
|
|
RUN rm -rf openapi-generator-cli-7.15.0.jar /app/ErsatzTV
|
|
RUN python3 -m pip install --target=/app/pythonlibs /app/etv-client
|
|
RUN rm -rf /app/etv-client
|
|
COPY scripts/scripted-schedules/. /app/scripted-schedules/
|
|
|
|
# copy csproj and restore as distinct layers
|
|
COPY *.sln .
|
|
# repo-wide build config (MSBuild props/targets incl. NuGet-audit warning
|
|
# exemptions, SDK pin, analyzer severities, and the Central Package Management
|
|
# version manifest) must be present before restore so the image build matches
|
|
# local/CI builds. Directory.Packages.props is REQUIRED here: with CPM the csproj
|
|
# carry no versions, so restore fails without the central manifest.
|
|
COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json .editorconfig ./
|
|
COPY eng/analyzers/sdk-all-suggestion.globalconfig ./eng/analyzers/
|
|
COPY artwork/* ./artwork/
|
|
COPY ErsatzTV/*.csproj ./ErsatzTV/
|
|
COPY ErsatzTV.Application/*.csproj ./ErsatzTV.Application/
|
|
COPY ErsatzTV.Core/*.csproj ./ErsatzTV.Core/
|
|
COPY ErsatzTV.Core.Nullable/*.csproj ./ErsatzTV.Core.Nullable/
|
|
COPY ErsatzTV.FFmpeg/*.csproj ./ErsatzTV.FFmpeg/
|
|
COPY ErsatzTV.Infrastructure/*.csproj ./ErsatzTV.Infrastructure/
|
|
COPY ErsatzTV.Infrastructure.Sqlite/*.csproj ./ErsatzTV.Infrastructure.Sqlite/
|
|
COPY ErsatzTV.Infrastructure.MySql/*.csproj ./ErsatzTV.Infrastructure.MySql/
|
|
COPY ErsatzTV.Scanner/*.csproj ./ErsatzTV.Scanner/
|
|
|
|
# Disable the persistent Roslyn/MSBuild compiler servers for the in-image build (ersatztv#406,
|
|
# server-management#604). The workflow sets these as env for the runner-side dotnet jobs, but this
|
|
# stage compiles inside `docker build`, so the workflow's env does NOT reach it — the restore and
|
|
# the two publishes below would otherwise spin up their own VBCSCompiler and hold its heap. This is
|
|
# the `build` job that server-management#570 measured pegging 5.999/6 GiB, so it is the one that
|
|
# most needs this.
|
|
#
|
|
# Placed here rather than at the top of the stage on purpose: an ENV invalidates every layer below
|
|
# it, and the wget of the ~30MB openapi-generator jar above is deliberately ordered early to stay
|
|
# cached (ersatztv#190). Nothing between that wget and this line compiles, so this is the earliest
|
|
# point where the ENV is free.
|
|
#
|
|
# Build-stage only: the final image is FROM runtime-base and only COPY --from=build /app (files,
|
|
# not ENV), so none of this lands in the shipped image or affects runtime.
|
|
ENV UseSharedCompilation=false \
|
|
DOTNET_CLI_USE_MSBUILD_SERVER=0 \
|
|
MSBUILDDISABLENODEREUSE=1
|
|
|
|
RUN dotnet restore -r linux-x64 ErsatzTV/ErsatzTV.csproj
|
|
|
|
# copy everything else and build app
|
|
COPY ErsatzTV/. ./ErsatzTV/
|
|
COPY --from=web-build /source/ErsatzTV/wwwroot/app/. ./ErsatzTV/wwwroot/app/
|
|
COPY ErsatzTV.Application/. ./ErsatzTV.Application/
|
|
COPY ErsatzTV.Core/. ./ErsatzTV.Core/
|
|
COPY ErsatzTV.Core.Nullable/. ./ErsatzTV.Core.Nullable/
|
|
COPY ErsatzTV.FFmpeg/. ./ErsatzTV.FFmpeg/
|
|
COPY ErsatzTV.Infrastructure/. ./ErsatzTV.Infrastructure/
|
|
COPY ErsatzTV.Infrastructure.Sqlite/. ./ErsatzTV.Infrastructure.Sqlite/
|
|
COPY ErsatzTV.Infrastructure.MySql/. ./ErsatzTV.Infrastructure.MySql/
|
|
COPY ErsatzTV.Scanner/. ./ErsatzTV.Scanner/
|
|
ARG INFO_VERSION="unknown"
|
|
ARG BUILD_CONFIG="release"
|
|
WORKDIR /source/ErsatzTV.Scanner
|
|
RUN dotnet publish ErsatzTV.Scanner.csproj -c ${BUILD_CONFIG} -o /app -r linux-x64 --self-contained false --no-restore /p:DebugType=Embedded /p:InformationalVersion=${INFO_VERSION}
|
|
WORKDIR /source/ErsatzTV
|
|
RUN sed -i '/Scanner/d' ErsatzTV.csproj
|
|
RUN dotnet publish ErsatzTV.csproj -c ${BUILD_CONFIG} -o /app -r linux-x64 --self-contained false --no-restore /p:DebugType=Embedded /p:InformationalVersion=${INFO_VERSION}
|
|
|
|
# final stage/image
|
|
FROM runtime-base
|
|
ENV FONTCONFIG_PATH=/etc/fonts
|
|
RUN fc-cache update
|
|
WORKDIR /app
|
|
COPY --from=build /app ./
|
|
ENV PYTHONPATH=/app/pythonlibs
|
|
ENV ETV_CONFIG_FOLDER=/config
|
|
ENV ETV_TRANSCODE_FOLDER=/transcode
|
|
ENV ETV_DISABLE_VULKAN=1
|
|
ENTRYPOINT ["./ErsatzTV"]
|