`pretooluse-merge-consent.sh` proves all three consent conditions at the moment the merge tool is called. With `merge_when_checks_succeed=true`, condition (a) is delegated to Gitea, which then merges whatever head is green at ITS merge time — while (b) Done-when and (c) the head-referencing verdict were proven against the head at SCHEDULING time. Every commit pushed in between merges unreviewed. The gate is not bypassed; it is satisfied against a snapshot that stops being true. Demonstrated as a controlled A/B rather than inferred, with a CI check left pending so Gitea waits as it really does: without a required verdict context: unreviewed commit B MERGED with it: same sequence REFUSED after reviewing B and posting it: merges — blocked, not deadlocked NOTE the anecdote in #622 is wrong and is corrected in the docs rather than repeated: PR #619 does carry `Review-verdict: MERGEABLE @ 02c82b35`, posted six seconds before the merge, explicitly re-reviewing the follow-up commits. #622 was filed off a lagging API read. The hole is real regardless, and structural — nothing FORCED that re-review inside the window Gitea would have merged in. This turns a property that held by diligence into one that holds by construction. The fix is the sha, not a smarter check. A Gitea commit status belongs to exactly one commit, so a status written for a parent cannot be inherited by a child that did not exist yet. `review-verdict/h10` becomes a REQUIRED status check on main: push a new head and the context is simply absent, which Gitea reads as not-passing (verified against 1.25.4: missing AND pending both block, and auto-merge re-checks the current head). It also covers merge paths the hook never sees — Gitea UI, raw API, another agent's session. - scripts/post-review-verdict.sh writes the comment and the status together so they cannot drift, and re-reads head after commenting: if a commit landed mid-flight it writes NO status and exits non-zero rather than retargeting the verdict at a commit nobody read. - .gitea/workflows/review-verdict.yml auto-passes the two exempt classes that would otherwise deadlock — Renovate-authored (platformAutomerge) and docs-only — and marks everything else pending. Exemptions are void when the PR touches .claude/, .gitea/, .husky/, scripts/ or docker/ci/. - The hook refuses to SCHEDULE an auto-merge unless that status is green on head, and no longer claims "CI green" on the mwcs path, where it never read CI. Two silent false negatives in the exemption path, both found by verifying rather than reasoning, both fixed at BOTH call sites (workflow and the hook's pre-existing docs-only carve-out): - The files endpoint caps at 50 rows and IGNORES a larger `limit` — PR #619 has 194 changed files and `?limit=100` returns 50. A single page saw ZERO protected paths there where the full enumeration finds ten. Both now page to exhaustion and withhold the exemption if they cannot complete. - A rename is ONE row whose `filename` is the destination, the source only in `previous_filename`. Verified live: `.gitea/workflows/renovate.yml` -> `docs/innocuous-note.md` presented as docs-only with no protected path visible. Both now read BOTH sides. Limits are documented, not papered over: base changes leave the head sha (and so the verdict) unchanged, and a PR editing the workflow is judged by its own edited copy — so PROTECTED is a guardrail against accident, not a tamper-proof control. fixes #622
12 KiB
ErsatzTV Fork
Custom IPTV channel server for Jellyfin. Forked from ErsatzTV/ErsatzTV after upstream archival (Feb 2026, v26.3.0). Our fork lives on Gitea.
Architecture
- UI: ChicoryTV React SPA (
web/, Vite, served at/app) over the REST API — the ONLY UI. The legacy Blazor Server UI (MudBlazor) was removed in #91 phase (b); root/and every legacy route now 302 to/app, either via an explicit redirect inErsatzTV/LegacyUiRedirects.csor the Startup catch-all fallback (any unmatched non-/api//artwork//docs//openapipath →/app). Historical parity work: media detail pages + image folder browser landed via #141 (PR #183); scheduling parity #144/#162, #141/#158/#161/#180, #145, #151/#152/#153/#155, and the media-source write API/SPA #202 are all DONE. - Pattern: CQRS via MediatR — queries/commands in
ErsatzTV.Application/ - Database: EF Core (SQLite default, MySQL optional) — context in
ErsatzTV.Infrastructure/Data/TvContext.cs
Key Files
- M3U generation:
ErsatzTV.Core/Iptv/ChannelPlaylist.cs→ToM3U() - XMLTV generation:
ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs - IPTV controller:
ErsatzTV/Controllers/IptvController.cs—/iptv/*routes - Logo generation:
ErsatzTV.Core/Images/ChannelLogoGenerator.cs - Channel entities:
ErsatzTV.Core/Domain/Channel.cs - DB context:
ErsatzTV.Infrastructure/Data/TvContext.cs
Deployment
- Docker host: jazz (192.168.1.29), container
ersatztv, port 8409. Media transcoders (Jellyfin,ersatztv,ersatztv-test) moved here from bumblebee on 2026-07-20 (server-management#633); bumblebee (192.168.1.99) still hosts the CI runners and the rest of the stacks. Name-reuse trap:jazzwas an earlier name for the .99 host, so pre-2026-07-20 docs/commits saying "jazz" mean today's bumblebee — go by the IP, not the name. - Config volume:
~/downloadswarm/ersatztv/on jazz →/configin container - SQLite DB:
/config/ersatztv.sqlite3(WAL mode, root-owned) - Images (our fork, built by
.gitea/workflows/docker-build.yml→192.168.1.95:3000/timothy/ersatztv): push tomain→:latest+:<sha>(test image); pushv*tag →:prod+:<version>+:<sha>. Prod's Komodo GitOps stack — namedjazz-media(the compose project is stillmedia-servers; a deadmedia-serversstack lingers on bumblebee) — follows floating:prod; after the immutable:<version>candidate passes the release scans, manuallyDeployStack jazz-media. There is no auto-update fallback (auto_update: false) — promotion is manual. Both paths run the fail-closed pre-deploy backup and prod-copy migration smoke before recreation. Test tracks:latest. Pipeline details:docs/ci-cd.md.
Development
# Docker build
docker build -f docker/Dockerfile -t ersatztv:dev .
Conventions
-
Read
docs/contributing.mdbefore non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, the ChicoryTV SPA, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the deviation policy: match the established style; diverge only with a concrete, stated reason. -
Docs-first is a HARD RULE — read before you explore: before ANY API / SPA / E2E / parity / scheduling work, read the
docs/README.mdtask-signal map and only the sections it points to for your task — not the whole corpus. Do NOT reverse-engineer conventions from source (Grep/Read) before reading these — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). This applies to delegated subagents too: tell each agent which doc section to read; never let one re-derive conventions from code. Decision/convention lookups start at the active catalog,docs/decisions/README.md— resolve by topic/key, never by chasing a file path named in a historical comment (the breadcrumb rule; seedocs/README.md→ "Knowledge retrieval"). -
Docs-update is part of "done" — same PR, never a follow-up: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a
/api/*endpoint, or reverses a decision MUST update the relevant doc in that same PR:Change Update in the same PR Migrate / add / redirect a route (new web/src/screens/*.tsx,LegacyUiRedirects.cs)docs/blazor-route-parity.md+docs/domain-model.mdAdd / change a /api/*endpointdocs/api-conventions.mdchecklist, then regeneratev1.json+endpoint-index.mdvia./scripts/update-openapi.shChange a SPA screen convention docs/spa-conventions.mdEstablish / reverse a convention or decision a new docs/decisions/records/<area>/<topic>.md(filename = key; lifecycle: add record,git mvpredecessor toarchive/<area>/) + regenerate the catalog + the affected docAdd / remove / retitle a doc docs/README.mdindexThe
docs-reminderCI job flags a screen/route change that skipsblazor-route-parity.md, but it's a non-blocking nudge — the rule is on you, not the check. -
Follow existing MediatR CQRS pattern for new features
-
Domain logic in
ErsatzTV.Core, infrastructure inErsatzTV.Infrastructure -
Keep UI thin: the SPA talks to
/api/*only; controllers delegate to MediatR handlers. All UI is in the SPA (web/) -
Test with NUnit + Shouldly + NSubstitute (the existing
*.Testsprojects); xUnit is not used here -
Dependencies use Central Package Management: versions live in the repo-root
Directory.Packages.props; csproj reference packages by name only. Add/upgrade by editing the central<PackageVersion>— never putVersion=back on a<PackageReference>(tripsNU1008). Seedocs/ci-cd.md→ Dependency management. -
DB migrations target BOTH providers: a
TvContextmodel change needs a migration inErsatzTV.Infrastructure.SqliteandErsatzTV.Infrastructure.MySql— runscripts/add-migration.sh <Name>(does both). CI'smigrationsjob enforces model-drift + apply-to-fresh-DB per provider. Seedocs/ci-cd.md→ Migration integrity. -
Renovate is live (
.gitea/workflows/renovate.yml, weekly +workflow_dispatch): opens dependency-update + OSV vuln-fix PRs and a Dependency Dashboard issue; patch bumps to test/dev-only packages auto-merge onceBuild & testpasses (theirreview-verdict/h10required check is auto-passed as a bot PR — unless they touch.claude//.gitea//.husky//scripts//docker/ci/, which need a real verdict), the rest are manual. Cross-repo rollout: server-management#484. Seedocs/ci-cd.md→ Dependency management. -
Versioning: release tags are
vYY.<release-seq>.<patch>(year · sequential release-within-year · patch) — inherited from upstream, not year.month.v26.3.1= our infra rebuild of upstream 26.3.0 (no app changes);v26.4.0is reserved for the first release with app changes. Never[skip ci]a commit you'll tag (it suppresses the release build). Full policy:docs/ci-cd.md→ Versioning & releases. -
Backlog tracked via Gitea Issues
Task Completion Protocol
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use /done <issue> to run through this automatically.
Merge-consent is derived from state, not asserted (## Done-when convention — ersatztv#303 H6 + H10). Any issue whose PR will merge to main should carry a ## Done-when section in its issue body — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked by construction, not by memory:
pretooluse-merge-consent.sh(Claude PreToolUse on the Gitea merge tool) — auto-grants a merge (emitspermissionDecision: allow, so no redundant mechanical prompt fires) only when the PR's CI is green and every## Done-whenbox on the linked issue (fixes #N) is ticked and aReview-verdict:comment references the PR's current head sha (H10); denies on an unticked box, red CI, or a stale/negative review verdict; asks (falls back to a human prompt) when it can't derive state (no linked issue, no## Done-whensection, noReview-verdict:comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state is the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate asks (ersatztv#314). The H10 review-verdict convention: after an adversarial/Codex review of a PR (or its latest fix commit), runscripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> [note]— it posts both theReview-verdict: … @ <head-sha>comment and the sha-boundreview-verdict/h10commit status, proving the latest commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the status is the required check branch protection enforces, and a comment alone leaves it absent.- The gate is enforced server-side, per sha (ersatztv#622).
review-verdict/h10is a required status check onmain. Because a commit status belongs to one sha, a commit pushed after an auto-merge is scheduled clears it and blocks the merge — closing the hole wheremerge_when_checks_succeedfroze consent at scheduling time and Gitea later merged an unreviewed head. Renovate-authored and docs-only PRs are auto-passed by.gitea/workflows/review-verdict.yml, except when they touch.claude/,.gitea/,.husky/,scripts/ordocker/ci/. Seedocs/ci-cd.md→ Review-verdict gate. .husky/pre-push→prepush-donewhen.sh— a fail-open backstop that blocks a directgit push origin mainwhose commitsfix #Nan issue with unticked boxes.
Both need Gitea read creds in the env to enforce (ETV_GITEA_BASICAUTH=user:pass or ETV_GITEA_TOKEN; ETV_GITEA_URL overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs/pushes are exempt.
The 7 mandatory completion steps and the ## Closing record comment template live in the
closing-an-issue skill (.claude/skills/closing-an-issue/SKILL.md) — invoke it (or /done)
when finishing a task that closes an issue.
Project Boundaries
ersatztv OWNS: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
ersatztv does NOT own:
- Docker compose configs → server-management (
~/downloadswarm/stacks/ersatztv/) - NFS mounts, Ansible, DNS, networking → server-management
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management (planned)
- Jellyfin skill → server-management (symlinked)
For infrastructure changes (Docker, NFS, ports, Authelia): open an issue in timothy/server-management.
For content/media sourcing questions (what goes into channels, yt-dlp pipelines): open an issue in timothy/media-management once it exists; for now, timothy/server-management.
For plan/audit reviews: open ~/adversarial-reviewer before significant architecture changes.
Full cross-project rules: ~/homelab-docs/Operations/Project Boundaries.md (https://docs.tblindustries.be).
ErsatzTV docs: ~/homelab-docs/Docker/ErsatzTV.md + project-local docs/ (fork strategy, channels, M3U/XMLTV).