Merge pull request 'docs(release): record the v26.16.0 release notes' (#930) from release/v26.16.0-notes into main
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 14s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 11s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 8s
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 14s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 11s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 8s
This commit was merged in pull request #930.
This commit is contained in:
+13
-10
@@ -42,6 +42,7 @@ Upstream's final release was **`v26.3.0`** (archived). Our line continues from t
|
||||
| `v26.13.0` | **RuleBuilder maturation** — arbitrary-depth group nesting (#436), inline smart-query authoring in Channel Builder (#437), DB-sourced facet typeahead + relative-date operators + validation (#434/#435/#438), and an artist typeahead covering music-video/song credits with `album_artist` no longer 404ing (#578). **Per-channel On Now/Next transient overlay** (#74/#570) and **per-schedule clock-boundary padding** (#392); in-browser channel preview (#60); Auto-Tune per-source weight steppers + exclude/add-untagged (#440). Library-browse pickers now resolve by search instead of a 100-row window, closing several silent at-cap truncations (#644/#650/#651/#634). Correctness: one watermark resolver for all four attachment points, incl. `MiddleCenter` (#503/#510); QSV HDR tonemaps through OpenCL because `vpp_qsv=tonemap` is a silent no-op (#505); `LibraryFolder` unique index + concurrent-insert tolerance (#491); per-library music-video identity with soft trash (#496); Jellyfin Album/Track music-video projection (#177); metadata-collection dedup (#500); accented facet values via a registered Unicode fold on SQLite (#668); `WorkAheadSlots` atomic slot claim, never a negative count (#536/#539); on-demand guide rebuild on thaw (#68). Process/CI: the H10 review-verdict gate became a sha-bound **required** commit status and was hardened through its false-open chain (#622/#629/#632/#648/#649/#672/#698), the decision corpus split to one YAML-frontmatter record per file (#610/#620), and headless Playwright UI-E2E flows landed (#445/#533). Five dual-provider migrations. |
|
||||
| `v26.14.0` | **Live TV no longer starves on embedded bitmap subtitles** — `-readrate` paces an input off its *furthest-behind* stream, and a PGS/DVD subtitle read through the video's own `-i` is sparse enough to drag the whole process to **0.53x realtime** against the 1.0x a client consumes, draining the buffer until the channel stalls. Fixed with a capability-gated `-readrate_catchup` (ffmpeg 8.0+) on realtime inputs, keeping `-readrate` on the frame-producing path so the `ffmpeg.qsv-extra-hw-frames-floor` bound is untouched; measured 0.533x → 1.067x on QSV and software, with a 240s QSV soak clean of allocation errors (#726). Affects items carrying an embedded bitmap subtitle matching the channel's subtitle mode — 3,182 of 24,646 media versions on prod, and a property of the *item*, not the channel, which is why the stall presented as random. Process/CI: the H10 review-verdict gate's repair sentinel became a fixed point and its write is now fenced on the timeline retarget count, closing a raced-sentinel false-open (#706/#707/#711). **The decisions validator now cross-checks its dependency-free frontmatter parse against PyYAML** and reports both the truncating unquoted `` #`` and the scalar-closing bare apostrophe as errors, so a record whose `rule:` silently halves under PyYAML fails the local gate instead of CI (#674/#688) — the ceiling-calibration claim was also split so the suite pins what the derivation MEANS rather than live-corpus order statistics. Dependencies: CliWrap 3.10.4, JetBrains.ReSharper.GlobalTools 2025.3.5. |
|
||||
| `v26.15.0` | **`main` became PR-only, which is what makes `review-verdict/h10` load-bearing** — branch protection now carries `enable_push: false` *and* `block_admin_merge_override: true`, so a direct `git push origin HEAD:main` is refused at pre-receive for every account including a site admin, and an admin cannot force-merge past a missing or red required context (#743/#749). Gitea only evaluates `status_check_contexts` on the PR merge path, so before this the whole gate was skippable with no forgery needed. The gate was then hardened through the rest of its false-open chain: CI's registry credential scoped so a head-resolved workflow cannot forge the status (#697), a stray expression delimiter **in a comment** that killed the gate outright and is now banned on the release path itself by a `scan` job `build` depends on (#751/#767), a positive verdict inherited only from an allow-listed reviewer (#742), the PR-timeline `HEAD` alias fenced on the `pull_push` count (#803/#664), and both `/statuses/{sha}` reads paged to a validated terminator (#763). App-facing: **FFmpeg profile numeric fields are bounded with a 422 that names the bound and its consequence** instead of being silently rewritten, and readrate pacing is exposed as a profile field (#735 — one dual-provider migration, `Add_FFmpegProfile_ReadRatePacing`). **On Now / Next gets a boxed background and is on by default** (#732). Field-level progressive disclosure lands as a shared `FieldHelp` trigger + portalled panel, adopted on FFmpeg Profiles and documented as `docs/spa-conventions.md` §15 (#734). Auto-Tune proposal rows align on a grid (#721); `AddItemsDialog` resolves by search instead of windowing a whole media-library type, with stale-response and is-mounted guards (#685/#740). Correctness: a paged handler now counts **the same query it pages**, so a filtered page stops reporting the unfiltered total across playouts, rerun collections and six sibling handlers (#690/#758); nullable primitive collections are guarded at the read site rather than mutated on a tracked entity — `SongMetadata.Artists` in both search indexers (#701/#824) and the scheduling `DaysOfMonth`/`MonthsOfYear`/`DaysOfWeek` columns, where a runtime NULL reads as **unrestricted** (#823); SPA full-replace bodies are built as `Complete<T>` so an omitted optional DTO field is a type error (#807); and every `ErsatzTV.Mcp` tool is pinned to its OpenAPI contract, with `graphicsElementIds` and `padToNearestMinute` declared so an MCP channel update stops silently stripping the overlay (#754/#757). Process/CI: one rule for how a guard derives its population and one for the proof it ships with, both enforced (#774/#775) — populations come from the git index, never a filesystem walk (#806/#819), every `MUTATION` grade is executed each suite rather than asserted in prose (#790), and verification code is itself code under test (#796). Workflows declare their own per-job metadata and guard populations derive from it (#786/#789); all six declare `permissions:` (#748); `ci-image.yml` publishes from `main` only and the persisted checkout credential is gone (#744/#746/#835). Hooks report their own execution and the report is measured (#776), and `docs.no-session-narrative` was generalized to every durable artifact and the sites its sweep IDENTIFIED were remediated as judgements rather than by regex — no closure is claimed over a phrasing space (#784/#812). A ruff config is committed and enforced in CI (#780). Dependencies: Scriban.Signed 7.2.6. **Cut at `736649b3b`, not at `main`'s head**: `completeAnnotations.guard.test.ts` (merged in the very next commit) needs a git index the Dockerfile's `web-build` stage does not have, so every image build after `736649b3b` fails — including a tag build (#887). `736649b3b` is the newest commit whose `Build & push image (amd64)` actually ran green. The scheduling-recurrence fix #880 therefore slips to the next release. |
|
||||
| `v26.16.0` | **Channels carrying Jellyfin music videos play again** — the music-video scanner wrote the library-items projection's empty stream list straight through, so every Jellyfin music video was stored with no `MediaStream` rows (1520/1520 on prod) and `SelectVideoStream` threw on tune-in while the guide looked normal; it now reads statistics from per-item playback info under the movie scanner's conditions, writes nothing when the server cannot answer, and `SelectVideoStream` fails with the version, file and remedy instead of a bare `Sequence contains no matching element`. **Existing rows heal only on a deep scan** (`POST /api/v1/libraries/{id}/scan?deep=true`), since a regular scan skips unchanged etags (#924/#928, `scan.mediaserver-statistics-from-server`). **Alternate-schedule and template recurrence arrays: an absent array means unrestricted, an explicit `[]` is rejected with a 422 that names the consequence** — the three recurrence sets are read conjunctively, so `?? []` on an omitted array returned 200 while storing an item that could never apply; absent and null now normalize to the same `All*()` sets the read side substitutes, and an unchanged pre-existing empty set is still accepted so no other item in a whole-list PUT becomes uneditable (#880/#892 — slipped from v26.15.0). **Unknown channel `graphicsElementIds` are rejected with a 422** (and the same bounded id-list validation applies to Deco updates), and the built-in graphics elements are identified by the seeded path, not the filename (#568/#922). The channel preview says why it is not playing and only the preview opts into muted autoplay (#554/#910); a failed write in the Collections screen reports to a surface that outlives the dialog that started it (#830/#878). Correctness: a classifier-unreadable branch-protection rule (a glob rule with an empty `branch_name`) is no longer resolved to a rule that matches nothing, which had let the merge hook auto-grant against a rule Gitea may be applying (#858/#859/#897); an empty PR-timeline page before the 20-page cap is no longer read as exhaustion, which a PR author could use to hide an ABA force-push behind 50 comments — narrowed, not closed (#870/#896); a head-authored run holds no stored secret, so the PR route reads the registry and status API anonymously (#885/#919); `Complete<T>` annotations are now derived where applied, not only where defined (#820/#883). **Image builds fixed**: the Dockerfile's `web-build` stage built the SPA and then ran a vitest suite with a hand-written `--exclude` list, so `completeAnnotations.guard.test.ts` (needs a git index the stage lacks) failed every `main` and tag image build after `736649b3b`; the stage now builds the SPA and does not test it, the suite runs once unfiltered in the `test` job `build` depends on, and a guard pins that delegation (#887/#899). Process/CI: the verdict gate replaces every unknown state and proves the clauses that claim to (#849/#890), the verdict writer checks the gate will honour what it just posted (#845/#889), a sourced path is code so every hook resolves it from its own tree (#891/#903), a mutation-outcome claim is executed wherever it is written (#881/#914), `--depth` is never passed to a checkout that may be complete (#836/#884), session narrative is swept from hooks, workflows, scripts, tests and code comments (#876/#906), a scripted-engine build API is characterized in-process against a committed script fixture (#563/#915), orchestrated sessions get a mechanics page, standing prompt and pick/build/resume workflows (#907/#908) with the review loop and concurrency cap tightened (#911/#912/#916/#918), and CI claims dated at Gitea 1.25.4 are re-established on 1.27.1 (#869/#893/#905). No dual-provider migrations and no dependency bumps in this release. **The image moves to the .NET 10.0.14 runtime** (prod had 10.0.11), so it carries the October 2026 .NET out-of-band security fixes, including the Brotli decompression overflow (CVE-2026-111497) and the SocketAsyncEventArgs memory corruption (CVE-2026-70197). No code change was needed, because `docker/Dockerfile` follows the floating `aspnet`/`sdk:10.0-noble-amd64` tags. Release scan on the immutable `:26.16.0` (`sha256:6d698162…`), 2026-10-27: ZAP 0 FAIL / 120 PASS / 1 WARN (100001, the SPA catch-all's `302 → /app` HTML, by design); semgrep 2 rule classes, both false positives (a constant-SQL test helper; JSX inside `design-system/` demo HTML, which the app doesn't bundle). **Known advisories carried unchanged from v26.15.0**: `System.Security.Cryptography.Pkcs` 9.0.4 (High, transitive via the unused Oracle/SQL Server BulkExtensions providers) and `SixLabors.ImageSharp` 3.1.12 (Moderate, fix only in 4.x), tracked in #929. |
|
||||
|
||||
**Before cutting a release — sweep `docs/decisions.md` + `docs/decisions/`** (ersatztv#521, supersedes
|
||||
the ersatztv#303 H9 append-only ritual). Supersession/retirement is now a same-PR act (add the new
|
||||
@@ -106,10 +107,10 @@ commit in the range (see the `decisions.md` header) — routine lifecycle metada
|
||||
> tag-protection rule (`name_pattern: v*`, `whitelist_usernames: ["timothy"]`,
|
||||
> `release.tag-protection-v-star`), because a `v*` push builds and publishes `:prod`. A push from
|
||||
> any other account is expected to be refused — configured, not exercised, since only the
|
||||
> `timothy` credential is available here. **The positive half is UNVERIFIED too**: the rule was
|
||||
> applied 2026-09-04 and only its negative half was measured (a non-`v*` tag still pushes), because
|
||||
> pushing a `v*` tag outside a real cut would publish `:prod`. This cut is its first real
|
||||
> exercise. If the tag push is refused, unblock with
|
||||
> `timothy` credential is available here. The positive half is verified: the rule was
|
||||
> applied 2026-09-04, its negative half measured that day (a non-`v*` tag still pushes), and a real
|
||||
> `v*` cut by `timothy` succeeded with the rule in force on 2026-10-27 (v26.16.0). If the tag push
|
||||
> is refused, unblock with
|
||||
> `DELETE /api/v1/repos/timothy/ersatztv/tag_protections/1`, push, then re-`POST` the rule
|
||||
> (prefer a `PATCH` adding the account if the cut has moved to a different operator, so the
|
||||
> protection is never left off) — and record the outcome on `release.tag-protection-v-star`,
|
||||
@@ -1462,10 +1463,10 @@ they are fail-open and `--no-verify` bypasses them. Tag pushes go through a sepa
|
||||
are meant to stay unaffected for the release operator: since ersatztv#885 `tag_protections` carries
|
||||
one rule, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), which is intended to leave
|
||||
the release cut in "Cutting a release" working unchanged while refusing a `v*` tag from the `renovate`
|
||||
bot — a push that would otherwise have published a `:prod` image. Both halves of that are EXPECTED,
|
||||
UNVERIFIED: only the `timothy` credential exists here, so neither a real release cut nor a refused bot
|
||||
push has been exercised. `release.tag-protection-v-star` carries the same caveat and waits on the
|
||||
next cut.
|
||||
bot — a push that would otherwise have published a `:prod` image. The release-cut half is verified
|
||||
(the v26.16.0 cut, 2026-10-27, pushed its `v*` tag as `timothy` with the rule in force); the refused
|
||||
bot push is still UNVERIFIED, because only the `timothy` credential exists here
|
||||
(`release.tag-protection-v-star`).
|
||||
|
||||
**A fourth arm: guard-scope freshness (ersatztv#787).** `scripts/tests/test_ci_dropped_step_guard.py`
|
||||
decides which CI jobs must carry per-step execution markers by DERIVING that scope from
|
||||
@@ -2647,8 +2648,10 @@ running product from outside our C#/review stack.
|
||||
`/api/v1` operation, injecting `X-Api-Key` on every request via a ZAP replacer rule so it reaches the
|
||||
`[RequiresAuthentication]` + `RequireKeyForReads` surface (not just the `/app` shell an unauthenticated
|
||||
spider sees). Then a **semgrep** SAST cross-check (`p/security-audit` + `p/secrets` + `p/csharp`). The
|
||||
container is torn down on exit.
|
||||
- **Where/when.** Runs on the **docker host** (jazz — the Mac has no docker), like
|
||||
container is torn down on exit. **semgrep runs only if it is installed on that host**, and otherwise the
|
||||
script prints a WARN and still exits 0. As of 2026-10-27 bumblebee has no semgrep, so run it separately
|
||||
against a `git archive` of the tagged commit (the Mac has it) and triage both halves.
|
||||
- **Where/when.** Runs on a **docker host** with registry access (bumblebee, as the script's own header names, or jazz), like
|
||||
`migration-smoke.sh`: `scripts/security-scan.sh [IMAGE] [PORT]` (defaults `…:latest` / `8411`). It is a
|
||||
**manual release-gate**, deliberately **not** a per-PR CI job — it needs docker + a booted image, takes
|
||||
several minutes, and is noisy (expect to tune, not take raw). The continuous layer is the per-PR
|
||||
|
||||
@@ -151,7 +151,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `release.prepush-clean-worktree-guard` | A fail-open pre-push hook blocks a push when any file in the branch's diff vs `origin/main` also has uncommitted working-tree or index changes, since a stale-index commit (e.g. `git reset --soft` + `git add` over an edited-but-unstaged fix) can silently push, CI-test, and get reviewed a different tree than the one on disk. Scope is precise to pushed-diff files; escape hatch `ETV_ALLOW_DIRTY_PUSH=1`. | 2026-07-17 | [link](records/release/prepush-clean-worktree-guard.md) |
|
||||
| `release.promotion-floating-prod` | Prod tracks the floating `:prod` image reference; a tag build's immutable `:<version>` image is scanned first, then promotion happens via a separate manual `DeployStack`, with daily auto-update only as a fallback — tag with enough runway before 03:00 to avoid an unscanned promotion. | 2026-07-13 | [link](records/release/promotion-floating-prod.md) |
|
||||
| `release.review-verdict-gate` | A PR may not merge until a `Review-verdict: <MERGEABLE\|APPROVED\|LGTM\|BLOCKED\|NOT-MERGEABLE> @ <head-sha>` comment references the PR's current head sha (short-sha prefix match against the verdict's OWN `@ <sha>` field, marker at COLUMN 0 (no indent, so indented code blocks cannot self-approve), whole-word verdict token, fenced code blocks stripped with markdown fence-length semantics, negative wins over positive on the same head); folds into the H6 merge-consent hook as condition (c). The grammar lives in ONE tested place, `scripts/check-review-verdict.sh` — #629 found three false-opens that survived because it was implemented inline and untested while this record described stricter behaviour than the code had. | 2026-07-12 | [link](records/release/review-verdict-gate.md) |
|
||||
| `release.tag-protection-v-star` | The repository carries one tag-protection rule: `name_pattern: v*`, `whitelist_usernames: ["timothy"]`. This matters because `docker-build.yml` triggers on `push.tags: [v*]` and a `v*` tag push BUILDS AND PUBLISHES the `:prod` image — the tag the `jazz-media` Komodo stack follows. `release.main-direct-push-disabled` closed every route to `main`, and left this one open: a tag push is a separate mechanism that branch protection does not govern, so it was the cheapest remaining single act that reaches production. It buys NOTHING against the admin actor (`timothy` is on the whitelist, and no self-administered control bounds an owner) and everything it buys is against `renovate`, the repo's other write-capable account (`permission: write`, `is_admin: false` from `GET /repos/timothy/ersatztv/collaborators/renovate/permission`, measured in #853 2026-08-30 and re-read 2026-09-04): the H10 verdict gate and the Renovate exemption rule bound what that bot can get MERGED, and nothing bounded what it could TAG. The rule's failure mode is a BROKEN RELEASE CUT, so it is verified in both directions before it is trusted: the negative half is done and recorded below; the POSITIVE half — a real `v*` release-cut tag push by `timothy` still succeeding — is DEFERRED to the operator's next real cut, because pushing a `v*` tag from any working session publishes `:prod` and a release is the operator's act, not a verification step. Treat that as OPEN until a cut lands. There is no committed mirror of this rule: it lives in Gitea, and this record's `mechanics:` carries the exact call and the read-back, the same shape `release.main-direct-push-disabled` uses. | 2026-09-04 | [link](records/release/tag-protection-v-star.md) |
|
||||
| `release.tag-protection-v-star` | The repository carries one tag-protection rule: `name_pattern: v*`, `whitelist_usernames: ["timothy"]`. This matters because `docker-build.yml` triggers on `push.tags: [v*]` and a `v*` tag push BUILDS AND PUBLISHES the `:prod` image — the tag the `jazz-media` Komodo stack follows. `release.main-direct-push-disabled` closed every route to `main`, and left this one open: a tag push is a separate mechanism that branch protection does not govern, so it was the cheapest remaining single act that reaches production. It buys NOTHING against the admin actor (`timothy` is on the whitelist, and no self-administered control bounds an owner) and everything it buys is against `renovate`, the repo's other write-capable account (`permission: write`, `is_admin: false` from `GET /repos/timothy/ersatztv/collaborators/renovate/permission`, measured in #853 2026-08-30 and re-read 2026-09-04): the H10 verdict gate and the Renovate exemption rule bound what that bot can get MERGED, and nothing bounded what it could TAG. The rule's failure mode is a BROKEN RELEASE CUT, so it is verified in both directions before it is trusted: both halves are done and recorded below: the negative half on 2026-09-04, and the POSITIVE half — a real `v*` release-cut tag push by `timothy` succeeding with the rule in force — on the v26.16.0 cut, 2026-10-27. There is no committed mirror of this rule: it lives in Gitea, and this record's `mechanics:` carries the exact call and the read-back, the same shape `release.main-direct-push-disabled` uses. | 2026-09-04 | [link](records/release/tag-protection-v-star.md) |
|
||||
| `release.verdict-status-check` | The H10 review verdict is written as a `review-verdict/h10` Gitea **commit status** on the exact reviewed sha by `scripts/post-review-verdict.sh`, and that context is a REQUIRED status check on `main`. Because a status belongs to one sha, a later commit cannot inherit it, so Gitea's own `merge_when_checks_succeed` refuses to merge a head no one reviewed. The PreToolUse hook additionally refuses to SCHEDULE an auto-merge unless that status is already green on head. A `pull_request_target` workflow auto-passes the two exempt classes (Renovate-authored, docs-only) unless the PR touches a protected path (`.claude/`, `.codex/`, `.gitea/`, `.husky/`, `scripts/`, `docker/ci/`). This extends — does not supersede — `release.review-verdict-gate` (#303 H10), whose comment convention remains the human-readable artifact and the hook's condition (c). | 2026-07-25 | [link](records/release/verdict-status-check.md) |
|
||||
| `release.verdict-vocabulary-shared` | The H10 verdict words live in exactly one place, `scripts/lib/review-verdict-vocabulary.sh`, as two arrays. `scripts/post-review-verdict.sh` (write: word to commit-status state) classifies through `etv_verdict_class`; `scripts/check-review-verdict.sh` (read: comment to classification) builds `POS_RE`/`NEG_RE` from `etv_verdict_alternation`. Neither script may restate a word, and neither may enumerate the vocabulary in a usage banner or an error string. Every word is validated to `[a-z][a-z-]*` before it reaches a regex. Validation is enforced by a DATA dependency, not a control-flow check: `etv_verdict_vocabulary_validate` sets `ETV_VERDICT_VOCABULARY_OK=1` on its final line, and the derived views refuse to hand out words without it. A vocabulary that is missing, unreadable, truncated, exits at top level, is declared as a scalar, or fails validation yields NO words on either side — exit 1 for the writer (nothing posted), exit 2 for the reader (callers fail closed). Do NOT reintroduce a text-comparison parity test alongside this. | 2026-08-26 | [link](records/release/verdict-vocabulary-shared.md) |
|
||||
| `release.verdict-writes-status-before-comment` | `scripts/post-review-verdict.sh` writes the sha-bound `review-verdict/h10` commit status FIRST and the human-readable `Review-verdict:` comment SECOND. Every refusal path still refuses (fail-closed, unchanged) and exits non-zero, and none of them may leave a verdict comment behind. An orphaned comment is therefore PREVENTED rather than tolerated. If the comment write fails after the status was written, that is an error too, but it degrades to an `ask` at the merge gate rather than to an apparent grant. | 2026-08-22 | [link](records/release/verdict-writes-status-before-comment.md) |
|
||||
|
||||
@@ -5,9 +5,9 @@ status: active
|
||||
since: '2026-09-04'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'The repository carries one tag-protection rule: `name_pattern: v*`, `whitelist_usernames: ["timothy"]`. This matters because `docker-build.yml` triggers on `push.tags: [v*]` and a `v*` tag push BUILDS AND PUBLISHES the `:prod` image — the tag the `jazz-media` Komodo stack follows. `release.main-direct-push-disabled` closed every route to `main`, and left this one open: a tag push is a separate mechanism that branch protection does not govern, so it was the cheapest remaining single act that reaches production. It buys NOTHING against the admin actor (`timothy` is on the whitelist, and no self-administered control bounds an owner) and everything it buys is against `renovate`, the repo''s other write-capable account (`permission: write`, `is_admin: false` from `GET /repos/timothy/ersatztv/collaborators/renovate/permission`, measured in #853 2026-08-30 and re-read 2026-09-04): the H10 verdict gate and the Renovate exemption rule bound what that bot can get MERGED, and nothing bounded what it could TAG. The rule''s failure mode is a BROKEN RELEASE CUT, so it is verified in both directions before it is trusted: the negative half is done and recorded below; the POSITIVE half — a real `v*` release-cut tag push by `timothy` still succeeding — is DEFERRED to the operator''s next real cut, because pushing a `v*` tag from any working session publishes `:prod` and a release is the operator''s act, not a verification step. Treat that as OPEN until a cut lands. There is no committed mirror of this rule: it lives in Gitea, and this record''s `mechanics:` carries the exact call and the read-back, the same shape `release.main-direct-push-disabled` uses.'
|
||||
rule: 'The repository carries one tag-protection rule: `name_pattern: v*`, `whitelist_usernames: ["timothy"]`. This matters because `docker-build.yml` triggers on `push.tags: [v*]` and a `v*` tag push BUILDS AND PUBLISHES the `:prod` image — the tag the `jazz-media` Komodo stack follows. `release.main-direct-push-disabled` closed every route to `main`, and left this one open: a tag push is a separate mechanism that branch protection does not govern, so it was the cheapest remaining single act that reaches production. It buys NOTHING against the admin actor (`timothy` is on the whitelist, and no self-administered control bounds an owner) and everything it buys is against `renovate`, the repo''s other write-capable account (`permission: write`, `is_admin: false` from `GET /repos/timothy/ersatztv/collaborators/renovate/permission`, measured in #853 2026-08-30 and re-read 2026-09-04): the H10 verdict gate and the Renovate exemption rule bound what that bot can get MERGED, and nothing bounded what it could TAG. The rule''s failure mode is a BROKEN RELEASE CUT, so it is verified in both directions before it is trusted: both halves are done and recorded below: the negative half on 2026-09-04, and the POSITIVE half — a real `v*` release-cut tag push by `timothy` succeeding with the rule in force — on the v26.16.0 cut, 2026-10-27. There is no committed mirror of this rule: it lives in Gitea, and this record''s `mechanics:` carries the exact call and the read-back, the same shape `release.main-direct-push-disabled` uses.'
|
||||
signals: 'tag_protections empty, v* tag push publishes prod, release cut blocked, renovate can push a tag, tag protection whitelist, release operator whitelist, prod image published by tag, deferred positive verification · paths: `.gitea/workflows/docker-build.yml` · issues: #885, #853, #743, #698, #742'
|
||||
mechanics: 'Applied 2026-09-04 with `POST /api/v1/repos/timothy/ersatztv/tag_protections` body `{"name_pattern": "v*", "whitelist_usernames": ["timothy"]}`, which returned `{"id": 1, "name_pattern": "v*", "whitelist_usernames": ["timothy"], "whitelist_teams": [], "created_at": "2026-09-04T23:18:50+02:00"}`. Read back the same day with `GET .../tag_protections` -> the one rule, fields as echoed. It is reversible with `DELETE .../tag_protections/1`. NOTE THE ROUTE NAME: `/repos/{owner}/{repo}/tags/protection` is NOT it — measured 2026-09-04, that path resolves `protection` as a TAG NAME and answers 404 `{"message": "tag doesn''t exist: protection"}`. Before this the endpoint returned `[]` (measured #743 2026-08-24 and again 2026-09-04). NEGATIVE VERIFICATION available from this session and performed: a NON-`v*` tag (`probe-885-tagpush`) pushed by `timothy` succeeded and was deleted, confirmed gone by `git ls-remote --tags` — so tag pushes still work at all and the rule is not mis-scoped to `*`. NOT VERIFIED, and it cannot be from a working session: that a real `v*` release-cut push still succeeds, and that a push by `renovate` is refused (only the `timothy` credential is available here). The first is the operator''s next cut; the second would require the bot''s credential.'
|
||||
mechanics: 'Applied 2026-09-04 with `POST /api/v1/repos/timothy/ersatztv/tag_protections` body `{"name_pattern": "v*", "whitelist_usernames": ["timothy"]}`, which returned `{"id": 1, "name_pattern": "v*", "whitelist_usernames": ["timothy"], "whitelist_teams": [], "created_at": "2026-09-04T23:18:50+02:00"}`. Read back the same day with `GET .../tag_protections` -> the one rule, fields as echoed. It is reversible with `DELETE .../tag_protections/1`. NOTE THE ROUTE NAME: `/repos/{owner}/{repo}/tags/protection` is NOT it — measured 2026-09-04, that path resolves `protection` as a TAG NAME and answers 404 `{"message": "tag doesn''t exist: protection"}`. Before this the endpoint returned `[]` (measured #743 2026-08-24 and again 2026-09-04). NEGATIVE VERIFICATION available from this session and performed: a NON-`v*` tag (`probe-885-tagpush`) pushed by `timothy` succeeded and was deleted, confirmed gone by `git ls-remote --tags` — so tag pushes still work at all and the rule is not mis-scoped to `*`. POSITIVE VERIFICATION, 2026-10-27: the v26.16.0 release-cut push (`git push origin v26.16.0` as `timothy`, tag on `1f7b763a7`) succeeded and `git ls-remote --tags origin v26.16.0` read it back; `GET .../tag_protections` the same day returned rule `id: 1` unchanged (`updated_at` 2026-09-04T23:18:50+02:00), so the push ran WITH the rule in force, not past a deleted one. STILL NOT VERIFIED: that a push by `renovate` is refused, which needs the bot''s credential (only `timothy`''s is available here).'
|
||||
---
|
||||
|
||||
**What this closes.** `release.main-direct-push-disabled` made every change to `main` go through a PR
|
||||
|
||||
Reference in New Issue
Block a user