docs(release): prepare v26.8.0 promotion #337
@@ -35,10 +35,10 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker host**: jazz (192.168.1.99), container `ersatztv`, port 8409
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
|
||||
- **Docker host**: bumblebee (192.168.1.99), container `ersatztv`, port 8409
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on bumblebee → `/config` in 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 to `main` → `:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod deploys via **Komodo GitOps**: the `media-servers` compose in `timothy/server-management` (`docker/bumblebee/stacks/media-servers/compose.yaml`) pins the version tag (currently `26.5.0`, deployed 2026-07-07); releasing = tag here, wait for the image build, bump that pin and push (the Komodo pre-deploy hook backs up before recreating). Test container tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
|
||||
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml` → `192.168.1.95:3000/timothy/ersatztv`): push to `main` → `:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod's **Komodo GitOps** `media-servers` stack follows floating `:prod`; after the immutable `:<version>` candidate passes the release scans, manually deploy the stack (Global Auto Update is the daily fallback). 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
|
||||
|
||||
|
||||
+22
-2
@@ -18,7 +18,9 @@ Upstream's final release was **`v26.3.0`** (archived). Our line continues from t
|
||||
| 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`. |
|
||||
| `v26.4.0` | First fork release carrying application changes. Later 2026 releases continue `26.5.0`, `26.6.0`, …; a new year resets to `27.1.0`. |
|
||||
| `v26.7.0` | Blazor-removal release: ChicoryTV became the only UI. |
|
||||
| `v26.8.0` | Secured/versioned ChicoryTV SPA + REST API go-live release (#335). |
|
||||
|
||||
**Before cutting a release — consolidate `docs/decisions.md`.** The log is append-only between releases
|
||||
(ersatztv#303 H9), so a release boundary is where superseded entries get pruned/merged and the Index
|
||||
@@ -26,7 +28,25 @@ refreshed. Fold and drop any entry marked `> **Superseded …**`, then commit wi
|
||||
the message (the append-only guard blocks history edits otherwise). Mark-and-keep during the arc,
|
||||
consolidate here — or sooner if the `decisions-guard` job's 1800-line consolidation-floor warning fires.
|
||||
|
||||
**Cutting a release:** push a `vYY.N.P` tag on `main` → CI builds `:prod` + `:<version>` + `:<sha>`. Prod's `media-servers` compose **follows the floating `:prod` tag** (Timothy's decision, 2026-07-11 — reverted from the version-pin approach), so **Komodo's Global Auto Update** (`poll_for_updates` is on for all stacks) detects the new `:prod` digest and redeploys the media-servers stack — no CI push to server-management, no manual pin bump. ⚠️ **Open caveat:** confirm Global Auto Update runs the #553 host-side **pre-deploy backup** hook; if it doesn't, a release would deploy without a backup — fall back to a manual `execute DeployStack media-servers` (which *does* run pre-deploy). See `homelab-docs/Docker/ErsatzTV.md` + the `komodo` skill.
|
||||
**Cutting a release:** keep build and promotion as two explicit phases (#335):
|
||||
|
||||
1. Confirm `main` CI is green; run the full local gate plus `dotnet list package --vulnerable
|
||||
--include-transitive`; then push a `vYY.N.P` tag on that exact `main` commit.
|
||||
2. Wait for tag CI to build `:prod` + the immutable `:<version>` + `:<sha>` images. Run
|
||||
`scripts/security-scan.sh` on bumblebee against **the immutable `:<version>` image**, not a
|
||||
moving tag, and triage every ZAP/semgrep finding.
|
||||
3. Only after the candidate passes, manually `DeployStack media-servers` and observe its pre-deploy
|
||||
output. Prod's compose deliberately follows floating `:prod` (Timothy's 2026-07-11 decision), so
|
||||
no CI push or pin bump is needed. Global Auto Update (`auto_update: true` on `media-servers`) is
|
||||
the daily fallback, not the pre-scan promotion mechanism; do not cut a tag close enough to its
|
||||
03:00 run that an unscanned digest could be promoted first.
|
||||
|
||||
server-management#585 source-confirmed that Global Auto Update invokes the **same** `DeployStack`
|
||||
execution as a manual promotion, and extended the #553 pre-deploy hook to detect a floating-tag
|
||||
**digest** change. Either path now takes the fail-closed prod backup; server-management#589 then
|
||||
wired `migration-smoke.sh` into that hook, against the exact candidate and the backup it just made.
|
||||
A backup, fetch, or migration-smoke failure aborts before the live container is recreated. See
|
||||
`homelab-docs/Docker/ErsatzTV.md` for the operational evidence and rollback procedure.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
+31
-53
@@ -42,7 +42,7 @@ keep append-only from accreting stale, contradictory, or unreadably-large histor
|
||||
- [2026-07-09 — YAML playout validator: paste-textarea instead of a server file path](#2026-07-09--yaml-playout-validator-paste-textarea-instead-of-a-server-file-path)
|
||||
- [2026-07-09 — Channel numbers: prompt-driven sequential renumber instead of drag-to-reorder](#2026-07-09--channel-numbers-prompt-driven-sequential-renumber-instead-of-drag-to-reorder)
|
||||
- [2026-07-09 — "Table, not calendar" convention also covers the deco-templates editor](#2026-07-09--table-not-calendar-convention-also-covers-the-deco-templates-editor)
|
||||
- [2026-07-09 — Trash "see all" is capped at 100 items per kind; true paging deferred](#2026-07-09--trash-see-all-is-capped-at-100-items-per-kind-true-paging-deferred)
|
||||
- [2026-07-11 — Trash "See all" reuses library-browse paging; search stays capped per kind (#213)](#2026-07-11--trash-see-all-reuses-library-browse-paging-search-stays-capped-per-kind-213)
|
||||
- [2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement](#2026-07-11--logs-page-size-is-a-client-local-preference-not-a-server-configelement)
|
||||
- [2026-07-11 — Logs column sorting: allow-listed `sortField`/`sortDirection` on `GET /api/logs`](#2026-07-11--logs-column-sorting-allow-listed-sortfieldsortdirection-on-get-apilogs)
|
||||
- [2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode](#2026-07-09--per-playout-schedule-reset-button-dropped-reset-uses-the-server-default-build-mode)
|
||||
@@ -62,7 +62,6 @@ keep append-only from accreting stale, contradictory, or unreadably-large histor
|
||||
- [2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)](#2026-07-11--optimistic-concurrency-contract-for-replace-all-puts-253-pr1-infra--block-reference)
|
||||
- [2026-07-11 — Post-commit side effects run on `CancellationToken.None` (generalized from #251 to #254)](#2026-07-11--post-commit-side-effects-run-on-cancellationtokennone-generalized-from-251-to-254)
|
||||
- [2026-07-11 — #253 PR3: Diff + Scalar concurrency fan-out (Collection / Playout×2 / MultiCollection / RerunCollection)](#2026-07-11--253-pr3-diff--scalar-concurrency-fan-out-collection--playout2--multicollection--reruncollection)
|
||||
- [2026-07-11 — External Collections in the SPA derives from `getMediaSources()`; collections scans are optimistically bounded (#91b Libraries.razor parity)](#2026-07-11--external-collections-in-the-spa-derives-from-getmediasources-collections-scans-are-optimistically-bounded-91b-librariesrazor-parity)
|
||||
- [2026-07-12 — External-collections scans get an authoritative status surface (#271); the SPA timeout is retired](#2026-07-12--external-collections-scans-get-an-authoritative-status-surface-271-the-spa-timeout-is-retired)
|
||||
- [2026-07-11 — Blazor Server UI removed (#91 phase b)](#2026-07-11--blazor-server-ui-removed-91-phase-b)
|
||||
- [2026-07-11 — Stable child identity for schedule-item replace (#259, split from #252/#253)](#2026-07-11--stable-child-identity-for-schedule-item-replace-259-split-from-252253)
|
||||
@@ -70,6 +69,7 @@ keep append-only from accreting stale, contradictory, or unreadably-large histor
|
||||
- [2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)](#2026-07-12--artwork-content-type-is-sniffed-never-reflected-283-s4s9-stored-xss)
|
||||
- [2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)](#2026-07-12--fail-closed-api-auth--sensitive-read-tier--corsforwardedheaders-lockdown-197-bundle-a-pr-292)
|
||||
- [2026-07-12 (#197 Bundle C — contract-freeze honesty)](#2026-07-12-197-bundle-c--contract-freeze-honesty)
|
||||
- [2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump)](#2026-07-12-269--non-if-match-root-writers-force-write-past-a-concurrent-version-bump)
|
||||
- [2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)](#2026-07-12--browser-spa-session-auth-api-accepts-session-or-machine-key-295-pr1-server-only)
|
||||
- [2026-07-12 — Merge-consent derived from state via a `## Done-when` issue checklist (#303 H6)](#2026-07-12--merge-consent-derived-from-state-via-a--done-when-issue-checklist-303-h6)
|
||||
- [2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)](#2026-07-12--blocking-ci-gate-for-api-contract-artifacts-303-h45)
|
||||
@@ -82,6 +82,11 @@ keep append-only from accreting stale, contradictory, or unreadably-large histor
|
||||
- [2026-07-12 — Release path rehearses migrations on a prod-DB copy before promoting (#315)](#2026-07-12--release-path-rehearses-migrations-on-a-prod-db-copy-before-promoting-315)
|
||||
- [2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification](#2026-07-12--295-pr2-spa-session-cutover--301-side-effecting-get-post-ification)
|
||||
- [2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)](#2026-07-12--enforcing-csp--permissions-policy-on-the-host-319-zap-baseline)
|
||||
- [2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265)](#2026-07-12--if-match-evaluates-per-rfc-7232-valid-but-non-matching--412-only-grammar-violations--400-265)
|
||||
- [2026-07-12 — TopBar primary-action button: wire creates, drop the rest (#238)](#2026-07-12--topbar-primary-action-button-wire-creates-drop-the-rest-238)
|
||||
- [2026-07-13 — API versioning: the whole `/api` surface is mounted at `/api/v1`, additive-only after freeze (#286)](#2026-07-13--api-versioning-the-whole-api-surface-is-mounted-at-apiv1-additive-only-after-freeze-286)
|
||||
- [2026-07-13 — Scheduling API hardening: null-name 500s, duplicate template items, unreachable 404 (#172)](#2026-07-13--scheduling-api-hardening-null-name-500s-duplicate-template-items-unreachable-404-172)
|
||||
- [2026-07-13 — Release promotion: floating `:prod`, exact-image scan before manual deploy (#335)](#2026-07-13--release-promotion-floating-prod-exact-image-scan-before-manual-deploy-335)
|
||||
|
||||
---
|
||||
|
||||
@@ -251,25 +256,13 @@ table rather than reproducing Blazor's drag-and-drop calendar grid. Same rationa
|
||||
accepted-deviation status — don't "fix" either editor to match Blazor's interaction model without
|
||||
discussing it first.
|
||||
|
||||
## 2026-07-09 — Trash "see all" is capped at 100 items per kind; true paging deferred
|
||||
## 2026-07-11 — Trash "See all" reuses library-browse paging; search stays capped per kind (#213)
|
||||
|
||||
`TrashScreen.tsx` requests `GET /api/search?query=state:FileNotFound&pageSize=100` — 100 is
|
||||
`SearchController`'s `MaxPageSize`, and the endpoint has **no page-number parameter**, so a client
|
||||
can't page past the first 100 matches of a given media kind. The SPA shows the first 100 per kind
|
||||
(movies, shows, episodes, etc. are separate groups, so in practice most trash lists fit well within
|
||||
that per-kind cap); this mirrors the legacy Blazor Trash page, which had the same underlying search
|
||||
behavior and cap. True paging is deferred until the search API grows a page param — not attempted
|
||||
here, since it would mean adding a paging contract server-side, out of scope for this pass.
|
||||
|
||||
**Superseded 2026-07-11 (#213)**: the cap is lifted via a per-kind "See all N …" button, without
|
||||
adding any new API surface. `GET /api/library/browse` (`LibraryBrowseController` /
|
||||
`GetLibraryBrowseItems`) already accepts `mediaType` + `pageNum` + `pageSize` and runs the same
|
||||
underlying query as `GetSearchResults` (which itself fans out to `GetLibraryBrowseItems` per kind,
|
||||
just always at `pageNum=0`) — so `TrashScreen.tsx` pages `pageNum=1, 2, …` through
|
||||
`/api/library/browse?query=state:FileNotFound&mediaType={kind}&pageSize=100` for a kind once the
|
||||
user asks to see past the first 100, and appends the results client-side. The 100/kind **first
|
||||
page** still comes from `/api/search` (unchanged, cheapest for the common case where a kind has
|
||||
few matches); only kinds that exceed the cap ever issue the follow-up `/api/library/browse` calls.
|
||||
`GET /api/v1/search` still returns at most 100 items per media kind, which is the cheap first page for
|
||||
the common case. For an overflowing kind, the SPA's "See all N …" action pages `GET
|
||||
/api/v1/library/browse` with `query=state:FileNotFound`, `mediaType`, `pageNum`, and `pageSize=100`, then
|
||||
appends the results client-side. This reuses the same `GetLibraryBrowseItems` query behind search,
|
||||
adds no API surface, and only pays for follow-up requests when a kind exceeds the first-page cap.
|
||||
|
||||
## 2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement
|
||||
|
||||
@@ -900,45 +893,20 @@ UPDATE writers were fixed in-PR with `ConcurrencyExtensions.SaveChangesForcingVe
|
||||
force-write on conflict: adopt the stored token, retry, never revert the concurrent bump). The deferral
|
||||
above is re-scoped to the DELETE handlers + repository `Add*` writers only (→ #269).
|
||||
|
||||
## 2026-07-11 — External Collections in the SPA derives from `getMediaSources()`; collections scans are optimistically bounded (#91b Libraries.razor parity)
|
||||
|
||||
**Context.** The last SPA pre-work before deleting Blazor `Libraries.razor` was wiring the shipped
|
||||
`scanLibrary(id, deep)` + `scanCollections(family, id, deep)` clients (F9 API, #235) into `LibrariesScreen`,
|
||||
matching the page's four scan actions: quick-library (already had it), deep-library, quick-collections,
|
||||
deep-collections. Landed as its own pure-SPA PR ahead of the removal PR so the deletion diff stays surgical.
|
||||
|
||||
**Decision 1 — derive the External Collections rows client-side, no new endpoint.** Blazor's second table
|
||||
comes from the `GetExternalCollections` MediatR query (one row per Plex/Jellyfin/Emby source with
|
||||
`Libraries.Any(ShouldSyncItems)`), which has no REST wrapper. Rather than add one, the SPA derives the rows
|
||||
from the existing `GET /api/media-sources`: `GetAllMediaSourcesForApiHandler` already filters each source's
|
||||
`libraries` to sync-enabled entries (`ShouldIncludeLibrary` = `ShouldSyncItems` for remote families), so a
|
||||
remote source with a **non-empty `libraries` list** is *exactly* the `Libraries.Any(ShouldSyncItems)` filter.
|
||||
No extra endpoint, no extra fetch, single source of truth. (If `MediaSourceLibraryResponseModel` ever stops
|
||||
filtering to sync-enabled libraries, this equivalence breaks — a test asserting the row set guards it.)
|
||||
|
||||
**Decision 2 — collections-scan buttons use optimistic, timeout-bounded pending (no poll reconcile).** Library
|
||||
scans reconcile their optimistic "pending" flag against `GET /api/libraries/scan-status`. Collections scans
|
||||
have **no** such surface — that endpoint is library-keyed, and Blazor only ever observed collections locks via
|
||||
in-process `IEntityLocker` `Are{X}CollectionsLocked` events with no HTTP mirror. So the `useCollectionsScan`
|
||||
hook disables a button optimistically on click and clears it after a fixed `COLLECTIONS_PENDING_TIMEOUT_MS`
|
||||
(a 409 "already scanning" is benign and keeps it disabled until the timeout; a 404/network clears it and
|
||||
surfaces the error). This is the honest ceiling of what the current API exposes. **Follow-up (backlog):** a
|
||||
collections scan-status endpoint would let the SPA reconcile collections pending against a live active set the
|
||||
way library scans do; filed as **#271** so the timeout isn't mistaken for the intended end state.
|
||||
|
||||
## 2026-07-12 — External-collections scans get an authoritative status surface (#271); the SPA timeout is retired
|
||||
|
||||
**Context.** The 2026-07-11 entry above shipped collections-scan buttons with an optimistic,
|
||||
`COLLECTIONS_PENDING_TIMEOUT_MS`-bounded pending flag because collections locks had **no** HTTP mirror
|
||||
(unlike library scans, which reconcile against `GET /api/libraries/scan-status`). That was the honest
|
||||
ceiling of the API at the time and was filed as **#271** so the timeout wasn't mistaken for the end state.
|
||||
This entry closes that follow-up.
|
||||
**External Collections rows stay client-derived.** The SPA derives them from `GET
|
||||
/api/v1/media-sources`: remote sources expose only sync-enabled libraries, so a non-empty `libraries` list is
|
||||
equivalent to Blazor's `Libraries.Any(ShouldSyncItems)` filter. No second listing endpoint or fetch is needed.
|
||||
|
||||
The first scan-button implementation used a bounded optimistic timeout because collections locks had no HTTP
|
||||
mirror. #271 replaces that temporary ceiling with the authoritative status contract below.
|
||||
|
||||
**Decision 1 — one family-global status endpoint, reading `IEntityLocker`.** New
|
||||
`GET /api/media-sources/collections-scan-status` (`MediaSourcesController` → `GetCollectionsScanStatus`
|
||||
`GET /api/v1/media-sources/collections-scan-status` (`MediaSourcesController` → `GetCollectionsScanStatus`
|
||||
handler) returns one `CollectionsScanStatusResponseModel { family }` entry per media-source family
|
||||
(`plex`/`jellyfin`/`emby`) whose collections lock is currently held, and only for active ones — the direct
|
||||
counterpart to `GET /api/libraries/scan-status`. It reads `IEntityLocker.Are{X}CollectionsLocked()` (the lock
|
||||
counterpart to `GET /api/v1/libraries/scan-status`. It reads `IEntityLocker.Are{X}CollectionsLocked()` (the lock
|
||||
*is* the running scan — the scan-collections controllers acquire it before enqueueing and the scanner releases
|
||||
it on completion), analogous to how the library endpoint reads `IScannerProxyService.GetActiveScans()`. Two
|
||||
deliberate shape differences from libraries: (a) **family-global, not per-source** — the collections lock takes
|
||||
@@ -1793,3 +1761,13 @@ Deliberately **not** fixed (documented as accepted): the §8 "(none)" synthetic
|
||||
non-existence-check, and the missing `Name=` on `PlayoutController` Create/Delete/Update (moot — the
|
||||
"v1"-doc `OperationIdOpenApiTransformer` (#197 Bundle C) already synthesizes stable operationIds for
|
||||
`Name=`-less ops, and adding `Name=` would risk renaming generated SPA client methods). Refs #172 #197.
|
||||
|
||||
## 2026-07-13 — Release promotion: floating `:prod`, exact-image scan before manual deploy (#335)
|
||||
|
||||
Prod keeps the floating `:prod` image reference; PR #191's workflow-driven immutable pin bump is
|
||||
closed as superseded. server-management#585 proved that automatic and manual promotions share
|
||||
`DeployStack` and made a changed `:prod` digest trigger the fail-closed backup; #589 added the
|
||||
prod-copy migration smoke before live recreation. Tagging and promotion remain separate: scan the
|
||||
tag build's immutable `:<version>` image, then deploy manually. Daily auto-update is only a fallback,
|
||||
so cut tags with enough runway before 03:00 to prevent an unscanned promotion. Refs #335 and
|
||||
server-management#585/#589.
|
||||
|
||||
@@ -43,6 +43,13 @@ Otherwise use the active model for every slice. Independent review MUST start fr
|
||||
brief; prefer a different model family/client when one is available, otherwise use a fresh agent with
|
||||
no implementation role.
|
||||
|
||||
**Queue selection is a fast/small slice, not orchestrator work, when routing exists.** Dispatch one
|
||||
low-effort selector with only #237's body, last ~6 comments, live issue/milestone/label state, and the
|
||||
reviewer-repo candidate list; ask it for a ranked proposal, then have the orchestrator confirm the winner
|
||||
is still open before claiming. Do not load implementation docs or issue bodies into the selector. If the
|
||||
client cannot route a cheaper model, perform this small live-state sort inline instead of spawning an
|
||||
equally expensive agent; delegate only after the pick has a concrete task-specific slice.
|
||||
|
||||
FIRST read `AGENTS.md` and `CLAUDE.md` when present, then `docs/README.md`, the convention docs it
|
||||
indexes, and the Lessons below. Apply both client instruction files; where they differ, follow the
|
||||
stricter safety/completion requirement unless a higher-priority instruction resolves the conflict.
|
||||
|
||||
Reference in New Issue
Block a user