Add Roslynator, SonarAnalyzer.CSharp, Meziantou.Analyzer, and AsyncFixer as central analyzer references (Directory.Build.targets, CPM-versioned, guarded on ManagePackageVersionsCentrally so the gitignored .mcp tool isn't affected). .editorconfig defaults dotnet_analyzer_diagnostic.severity to `suggestion` so the packs surface findings without failing the TreatWarningsAsErrors (TWAE) build; rules are promoted to warning/error incrementally (promotion = enforcement via the existing TWAE build, so no separate lint step is needed). StyleCop.Analyzers is intentionally excluded: its latest stable (1.1.118) crashes (AD0001) on C# records and overlaps the existing .editorconfig/Roslynator. Blazor .razor: editorconfig severity overrides don't reach analyzer diagnostics in Razor @code (source-generator limitation; dotnet format can't fix them either), so the currently-firing SonarAnalyzer rules are temporarily NoWarn-ed in ErsatzTV.csproj and burned down rule-by-rule in #25. The same rules run at suggestion on .cs. Formatting normalization (mixed UTF-8 BOM + whitespace) is deferred to its own PR. Full-solution Release build green (0 errors, 0 warnings). Refs #15 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
CI/CD for the ErsatzTV Fork
The fork builds its own Docker image via Gitea Actions on the homelab and pushes to the Gitea container registry. Runner + registry were provisioned in server-management#172; the build pipeline is ersatztv#4; test/prod containers are server-management#481.
Versioning & releases
The fork inherits upstream ErsatzTV's scheme: vYY.<release-seq>.<patch> (lightweight, v-prefixed git tags).
YY— two-digit year.<release-seq>— a sequential release counter within the year, reset at each year boundary. It is not the calendar month. (Evidence:v25.2.0shipped in June 2025,v25.5.0in Sep,v26.3.0in Feb 2026 — minors don't track months; andv25.9.0→v26.1.0shows the year-reset.)<patch>— a small follow-up/hotfix on the same release line (e.g.v26.1.0→v26.1.1, days later).
Upstream's final release was v26.3.0 (archived). Our line continues from there:
| Tag | Meaning |
|---|---|
v26.3.1 |
Upstream 26.3.0 rebuilt on our infra (Gitea CI/registry, fork ffmpeg base) — no application changes. A patch bump, because nothing functional changed. |
v26.4.0 |
Reserved for our first release that carries actual app changes (e.g. the #1 M3U fix). Later 2026 releases: 26.5.0, 26.6.0, …; a new year resets to 27.1.0. |
Cutting a release: push a vYY.N.P tag on main → CI builds :prod + :<version> + :<sha>; server-management does the prod switch (server-management#481).
Gotcha: never put a [skip ci] token in a commit you intend to tag — Gitea reads skip-ci from the tagged commit and will suppress the release build. (Also, workflow_dispatch on a tag ref isn't supported on this Gitea version, so the tag push must do the triggering.) Release commits, and anything you'll tag, must not contain skip-ci.
Also avoid firing several pushes back-to-back (e.g. a [skip ci] commit, then main, then a tag, all within ~1s). Observed once on this Gitea instance: the later events were silently dropped — no ActionRun records created at all, even though the runner was online and the workflow active. Pushing again, spaced out, created the runs normally. If a push/tag doesn't produce a run, re-push (or push an empty commit) rather than assuming the runner is broken.
The workflow: .gitea/workflows/docker-build.yml
Single workflow, two jobs (test → build).
Triggers & tags
| Trigger | test job |
build job |
Image tags pushed |
|---|---|---|---|
pull_request |
✅ | — (skipped) | none |
push to main |
✅ | ✅ | :latest + :<short-sha> |
push tag v* |
✅ | ✅ | :prod + :<version> + :<short-sha> |
workflow_dispatch |
✅ | ✅ | only if ref is main/v*, else build-only (no push) |
:latest is the test/dev channel (every main commit). Prod pins :prod,
never :latest — enforced in the prod compose (server-management#481). :prod is
only produced by pushing a v* tag.
concurrency: { group: ersatztv-build, cancel-in-progress: false } serializes all
runs — the single runner on jazz can't safely run the push-main-then-push-v*
release flow in parallel (shared :buildcache tag, shared smoke container).
test job
dotnet restore → strip the Scanner project ref (sed -i '/Scanner/d', matching the
Docker build) → dotnet build -c Release → dotnet test -c Release --no-build. Gates
the image build.
build job
- Compute
INFO_VERSION(git describe+ short sha onmain; tag version onv*). docker/setup-buildx-actionwithbuildkitd-config-inlinesettinghttp = truefor192.168.1.95:3000— BuildKit does not inherit the host daemon'sinsecure-registries, so without this, cache/base-image/push over the HTTP registry fails (http: server gave HTTP response to HTTPS client).docker/login-actionwith repo secretsREGISTRY_USER/REGISTRY_PASSWORD.docker/build-push-action@v6: amd64-only,docker/Dockerfile,INFO_VERSIONbuild-arg, registry layer cache (type=registry,ref=…:buildcache,cache-to … ignore-error=true).- Smoke test: pull the just-pushed
:<sha>, run it, and poll ErsatzTV's web server from inside the container (docker exec … python3→http://localhost:8409/). Unique container name +trap … EXITcleanup. Fails if it never serves HTTP.
Dockerfile notes (docker/Dockerfile)
- Base image:
192.168.1.95:3000/timothy/ersatztv-ffmpeg:7.1.1(our Gitea fork of the archivedghcr.io/ersatztv/ersatztv-ffmpeg). FFmpeg 8 upgrade is backlogged: base image → ersatztv-ffmpeg#4, app-side compat → ersatztv#9. - Copies
Directory.Build.props,Directory.Build.targets,Directory.Packages.props,global.json,.editorconfigbeforedotnet restoreso the image build uses the same MSBuild config, central package versions, SDK pin, and analyzer severities as local/CI builds (it previously copied only*.sln).Directory.Packages.propsis required here: under Central Package Management the csproj carry no inline versions, so the image's restore fails (NU1015) without the central manifest. - amd64-only (jazz is x86_64). No arm32/arm64, no DMG/exe artifacts, no GHCR/DockerHub.
Dependency management (Central Package Management + scans)
Central Package Management (CPM) — package versions live in a single repo-root
Directory.Packages.props (ManagePackageVersionsCentrally=true); the per-project
csproj reference packages by name only (no Version=). One source of truth, atomic
one-line bumps, and cross-project version drift is structurally impossible. To add or
change a dependency, edit the <PackageVersion> entry centrally — never put a Version=
back on a <PackageReference> (that trips NU1008). The Docker build must copy this file
before restore (see Dockerfile notes). The .mcp/ vendored tool (gitignored, not in the
solution) keeps inline versions via a local-only .mcp/Directory.Packages.props
opt-out (ManagePackageVersionsCentrally=false). (ersatztv#14)
NuGet audit — .NET 10 runs NuGet audit on restore. Several projects set
TreatWarningsAsErrors=true, so vulnerable transitive packages failed the build.
Directory.Build.props demotes low/moderate/high advisories (NU1901-1903) to warnings
and promotes NU1904 (critical) to an error in every project via WarningsAsErrors.
The advisories that prompted this were resolved in ersatztv#8 (NCalcSync→6.x; SQLitePCLRaw
bundle 3.x). The NU1901-1903 demotion is kept by design: criticals (NU1904) still hard-
block, while low/moderate/high advisories surface as warnings + via the weekly scan and
Renovate security PRs, rather than breaking unrelated PRs the moment a new transitive
advisory drops.
Scheduled vulnerability scan — .gitea/workflows/dependency-scan.yml runs weekly
(cron 0 6 * * 1) + on workflow_dispatch: dotnet list package --vulnerable --include-transitive over the full solution (incl. Scanner, which the image build
strips). dotnet list exits 0 even with findings, so the step (bash -euo pipefail)
greps for the "has the following vulnerable packages" marker and fails the run if present.
Detection only — it surfaces advisories on a schedule, a Gitea-native stand-in for
Dependabot; it does not open update PRs (that's Renovate — server-management#484).
Gitea registers schedule triggers only from the default branch, so the cron starts
after merge to main; use workflow_dispatch to run on demand. It went green once
ersatztv#8 cleared the NCalcSync/SQLitePCLRaw advisories — a red run now means a new
advisory has appeared. (ersatztv#14, ersatztv#8)
Renovate (automated update PRs) — .gitea/workflows/renovate.yml runs self-hosted
Renovate weekly (cron 0 3 * * 1) + on workflow_dispatch,
as a renovate/renovate:43 container job on the shared act_runner. This is the proposing
layer the scan above deliberately omits: it opens grouped dependency-update PRs and
OSV-driven vulnerability-fix PRs against main, and maintains a Dependency Dashboard
issue listing the full backlog. Config is the repo-root renovate.json — managers nuget
(via CPM), github-actions, and dockerfile (scoped to the built docker/Dockerfile; it reads the
HTTP-only Gitea registry for the ersatztv-ffmpeg base via a RENOVATE_HOST_RULES host rule —
insecureRegistry + registry read creds, set in the workflow env, not the committed config). The
docker-compose manager is unused (repo compose files are build:-only). Auth: a dedicated
renovate Gitea bot (Write
collaborator) via repo Actions secrets RENOVATE_TOKEN (bot PAT) + GH_COM_TOKEN (no-scope
github.com PAT for changelogs — named GH_, not GITHUB_, a prefix Gitea reserves).
Patch bumps to test/dev-only packages (NUnit*, NSubstitute, Shouldly, coverlet,
Microsoft.NET.Test.Sdk, Testably.Abstractions*, threading analyzer) auto-merge once
the Build & test (.NET) check passes — branch protection on main requires that context;
everything else is manual review (ersatztv is prod-bearing). Range-pinned packages (e.g. EF
Core [9.0.x,10)) are respected — no v10 jump. PR volume is throttled (prConcurrentLimit
5 + config:recommended's prHourlyLimit 2); tick a dashboard checkbox or raise the limits
to drain faster. workflow_dispatch defaults to a safe dry run. Cross-repo rollout
tracked in server-management#484. (server-management#484)
Static analysis & formatting
Analyzer packs — Directory.Build.targets references Roslynator, SonarAnalyzer.CSharp,
Meziantou.Analyzer, and AsyncFixer for every project (versions central via CPM; guarded on
ManagePackageVersionsCentrally so the gitignored .mcp tool isn't pulled in). They are introduced
incrementally (ersatztv#15): .editorconfig sets dotnet_analyzer_diagnostic.severity = suggestion
so the packs surface findings without failing the TreatWarningsAsErrors (TWAE) build. Promotion is
the enforcement — raising a rule to warning makes it a CI-blocking error via the existing TWAE
build, so no separate lint step is needed.
- StyleCop.Analyzers is intentionally excluded: its latest stable (1.1.118) crashes (
AD0001) on C#recorddeclarations, and its rules overlap the existing.editorconfig/Roslynator. Revisit via the record-compatible1.2.0-betaonly if specifically wanted. - Blazor
.razorcaveat: editorconfig severity overrides don't reach analyzer diagnostics in Razor@code(source-generator limitation —dotnet formatcan't fix them either), so the currently-firing SonarAnalyzer rules are temporarilyNoWarn-ed inErsatzTV.csprojand burned down rule-by-rule in ersatztv#25. The same rules run atsuggestionon.cs.
Formatting — the tree isn't yet dotnet format-clean (mixed UTF-8 BOM + whitespace inherited from
upstream: ~1,500 BOM files + ~480 whitespace). A one-time normalization lands as its own dedicated
PR (kept out of the analyzer work to stay reviewable); afterwards dotnet format whitespace --verify-no-changes (+ style) joins the test job so drift can't return. .gitattributes already
pins line endings.
Registry
Gitea Packages, HTTP-only at 192.168.1.95:3000. jazz's Docker daemon has it as an
insecure-registry (server-management#172). Images: 192.168.1.95:3000/timothy/ersatztv:<tag>.
Test / prod environments
Container/compose wiring lives in server-management (project boundary): test
ersatztv-test on 8410 (:latest), prod ersatztv on 8409 (:prod). See
server-management#481 for the full spec (registry pull on jazz, volumes, Jellyfin
isolation for test, Watchtower/manual promotion).
Retired upstream workflows
The upstream .github/workflows/ (ci.yml, docker.yml, artifacts.yml,
release.yml, pr.yml, issue-stale.yml) were removed — they targeted
GHCR/DockerHub + Azure/Apple signing and called reusable workflows at dead
ersatztv/ersatztv@main paths, and ran as noise (incl. a daily stale-issue cron) on
the Gitea runner. Upstream is archived, so there are no future merges to preserve them
for. The dead .github/dependabot.yml and FUNDING.yml (upstream-pointed) were also
removed.
Known follow-ups
- Pin third-party actions to commit SHAs (currently floating major tags cloned from github.com at runtime) — low priority for a homelab; tracked informally.