From 985eed072c34321eb8ca1ddf6adfc97c961bc1e8 Mon Sep 17 00:00:00 2001 From: Timothy Date: Mon, 13 Jul 2026 19:50:39 +0200 Subject: [PATCH] docs(release): prepare v26.8.0 promotion [decisions-edit] refs #335 Co-Authored-By: Codex --- CLAUDE.md | 6 +- docs/ci-cd.md | 24 +++++++- docs/decisions.md | 84 ++++++++++---------------- docs/handoffs/chicorytv-issue-queue.md | 7 +++ 4 files changed, 63 insertions(+), 58 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c84e53320..e95f5423b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` + `:` (test image); push `v*` tag → `:prod` + `:` + `:`. 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` + `:` (test image); push `v*` tag → `:prod` + `:` + `:`. Prod's **Komodo GitOps** `media-servers` stack follows floating `:prod`; after the immutable `:` 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 diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 3aff5bee5..5cc248ba3 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -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` + `:` + `:`. 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 `:` + `:` images. Run + `scripts/security-scan.sh` on bumblebee against **the immutable `:` 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. diff --git a/docs/decisions.md b/docs/decisions.md index c5e600aed..fbc8c9211 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 `:` 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. diff --git a/docs/handoffs/chicorytv-issue-queue.md b/docs/handoffs/chicorytv-issue-queue.md index dcd4e7f21..09f77d659 100644 --- a/docs/handoffs/chicorytv-issue-queue.md +++ b/docs/handoffs/chicorytv-issue-queue.md @@ -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. -- 2.47.3