From 7003f3674c75cf9c54f47998dab22e934b5c4703 Mon Sep 17 00:00:00 2001 From: Justin Klein Date: Mon, 12 Oct 2026 22:00:23 +0000 Subject: [PATCH 1/2] feat(freesailarr)!: eight image updates and a trustedNetworks value for the *arr apps Collapses the eight open Renovate PRs for this stack into one change, and answers what two of them turned out to carry. The eight tags: qbittorrent 5.2.3 -> 5.2.4, bazarr 1.6.0 -> 1.6.2, sonarr 4.0.19 -> 4.0.20, flaresolverr v3.5.0 -> v3.5.2, recyclarr 8.7.1 -> 8.7.3, prowlarr 2.5.2 -> 2.6.5, radarr 6.3.0 -> 6.4.4, seerr v3.4.1 -> v3.5.0. gluetun, the busybox init image and the digest-pinned mediathekarr have no update pending and are untouched. Major rather than patch, because prowlarr 2.6.3 and radarr 6.4.3 are not plain bumps: they removed the hard-coded trusted proxy networks (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7, fe80::/10, read in Startup.cs at the previously pinned tags) and replaced them with a TrustedNetworks setting whose own default is string.Empty - ForwardedHeadersConfigurator returns before registering a single KnownNetwork while it is empty. Behind an in-cluster ingress controller that means the forwarded headers are ignored: the client address the app reports is the controller's pod IP, the scheme reads as http behind TLS termination, and under "Disabled for Local Addresses" a proxied request stops counting as local. So a .trustedNetworks value is added for prowlarr, radarr AND sonarr, rendered as __SERVER__TRUSTEDNETWORKS and only when non-empty - an empty variable cannot be told apart from an unset one by the app, but looks handled to a reader of the manifest. The env form was verified per app rather than inferred: all three bind ServerOptions to :Server next to a plain AddEnvironmentVariables(). A helper suppresses the chart's entry when the app's own env block already carries the variable, for the reason the UrlBase helper states - two identically named variables in one container are not a defined state. sonarr is included because it is already in the empty state: 4.0.19, the version this chart shipped before, has no hard-coded networks either. It arrived in what the convention classes as a plain app-version bump, so nothing reviewed or documented it. Repairing two and knowingly leaving the third would be the same silent pattern. The default is empty, and this therefore does NOT restore the behaviour from before the upgrade. That is deliberate and follows upstream's reason - trusting X-Forwarded-For from any private network is a spoofing vector - and it is made visible instead of reinstated: NOTES.txt warns in exactly the combination that costs something, the app published through an Ingress or an HTTPRoute and the value empty, and stays quiet otherwise. AllowedHosts, added by the same upstream releases, is deliberately not exposed. Its default is string.Empty as well and the app then accepts any host, so there is nothing to repair; the trap is documented instead, because if it is ever set the app appends only localhost, 127.0.0.1, [::1] and its own hostname - neither the pod IP nor the Service DNS name - and the kubelet probes the pod by IP, which an incomplete list answers with HTTP 400. At default values the rendered manifest changes in exactly eight lines, the image tags; the new value renders nothing until it is set. --- freesailarr/Chart.yaml | 2 +- freesailarr/templates/NOTES.txt | 39 +++++++++++++ freesailarr/templates/_helpers.tpl | 40 +++++++++++++ freesailarr/templates/freesailarr.sfs.yaml | 20 +++++++ freesailarr/values.schema.json | 16 +++++ freesailarr/values.yaml | 68 +++++++++++++++++++--- 6 files changed, 176 insertions(+), 9 deletions(-) diff --git a/freesailarr/Chart.yaml b/freesailarr/Chart.yaml index e6c05b8..cb8e8e8 100644 --- a/freesailarr/Chart.yaml +++ b/freesailarr/Chart.yaml @@ -8,7 +8,7 @@ description: A Helm chart to deploy a Wireguard-tunneled media automation stack type: application -version: 3.3.0+up1.0.0 +version: 4.0.0+up1.0.0 # The chart's own version marker, freely chosen and tied to no upstream. The # stack bundles ten independently versioned projects (nine of them on by # default), so there is no single "app version" to track here. diff --git a/freesailarr/templates/NOTES.txt b/freesailarr/templates/NOTES.txt index bbf4908..f66c27f 100644 --- a/freesailarr/templates/NOTES.txt +++ b/freesailarr/templates/NOTES.txt @@ -52,6 +52,45 @@ SET gluetun.vpn.firewallOutboundSubnets — IT IS EMPTY RIGHT NOW fail, in a way that looks like an application bug. Set it to the cluster pod and service CIDRs, e.g. "10.42.0.0/16,10.43.0.0/16" on k3s. {{- end }} +{{- /* The servarr apps that are published through a routing object AND have no + trusted proxy network set - the one combination in which the empty + default actually costs something. Gated on freesailarr.published plus + membership in freesailarr.ingressApps, the same pair the ACCESS section + below uses, so it holds in Gateway mode too: reading ingress.enabled + directly would silence this warning in exactly the mode whose routes + still publish the apps. */}} +{{- $noTrust := list }} +{{- if $published }} +{{- range $a := $ingressApps }} +{{- if and (has $a.name (list "prowlarr" "radarr" "sonarr")) (not (index $.Values $a.name).trustedNetworks) }} +{{- $noTrust = append $noTrust $a.name }} +{{- end }} +{{- end }} +{{- end }} +{{- if $noTrust }} + +FORWARDED HEADERS ARE NOT TRUSTED — {{ join ", " $noTrust }} + These apps are published through a routing object and their trustedNetworks + value is empty, which is the chart's default. They therefore trust no proxy: + the client address each one logs and displays is your ingress controller's + pod IP rather than the real client, and each sees the scheme as http even + where TLS is terminated in front of it. If an app's authentication is set to + "Disabled for Local Addresses", a proxied request no longer counts as local + and is asked to authenticate. + + This is not a regression introduced by your values. Upstream removed the + hard-coded private networks (prowlarr 2.6.3, radarr 6.4.3; sonarr before + 4.0.19) because trusting X-Forwarded-For from any private network is a + spoofing vector, and this chart follows that default rather than reinstating + it behind your back. + + To honour the headers, set the network your ingress controller's pods sit in + — the chart cannot know it, as it is a property of the cluster: + + prowlarr.trustedNetworks: "10.42.0.0/16" # e.g. the k3s pod CIDR + + Leaving it empty is a valid choice; it is only not a silent one. +{{- end }} ACCESS {{- if and $published $ingressApps }} diff --git a/freesailarr/templates/_helpers.tpl b/freesailarr/templates/_helpers.tpl index 8f64e8e..e99b063 100644 --- a/freesailarr/templates/_helpers.tpl +++ b/freesailarr/templates/_helpers.tpl @@ -406,6 +406,46 @@ Input: dict "root" $ "app" <"radarr"|"sonarr"|"prowlarr">. {{- $path -}} {{- end -}} +{{/* +The value for a servarr app's __SERVER__TRUSTEDNETWORKS variable, or the +empty string when the chart must not set it. Same env schema as the UrlBase +variable above (__CONFIGNAMESPACE__CONFIGITEM): verified for all three +apps at the tags this chart pins, where Bootstrap.cs binds +services.Configure(config.GetSection(":Server")) next to a +plain .AddEnvironmentVariables(). + +The variable is emitted ONLY when the value is non-empty. An empty +TRUSTEDNETWORKS would be indistinguishable from an unset one for the app - +upstream reads string.Empty either way - but not for a reader of the manifest, +to whom it would look like a setting that had been taken care of. + +Empty - i.e. the chart sets nothing - in two cases: + + - the app's trustedNetworks value is empty, which is the DEFAULT. The apps + then trust no proxy at all; see the value's comment in values.yaml for what + that costs and why it is not a preserved behaviour; + - the app's own env block already carries the variable, for the same reason + the UrlBase helper above stays out of that case: the env blocks are + additive, and two identically named variables in one container are not a + defined state to build on. + +Input: dict "root" $ "app" <"radarr"|"sonarr"|"prowlarr">. +*/}} +{{- define "freesailarr.trustedNetworks" -}} +{{- $root := .root -}} +{{- $app := .app -}} +{{- $var := printf "%s__SERVER__TRUSTEDNETWORKS" (upper $app) -}} +{{- $value := (index $root.Values $app).trustedNetworks | default "" -}} +{{- if $value -}} +{{- range $entry := (index $root.Values $app).env -}} +{{- if eq (toString $entry.name) $var -}} +{{- $value = "" -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- $value -}} +{{- end -}} + {{/* Comma-separated FIREWALL_INPUT_PORTS for gluetun: the ports of the enabled app containers (ingress traffic and the kubelet probes both arrive on the pod IP) diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index fb9a5a5..e32ebf5 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -279,6 +279,14 @@ spec: - name: PROWLARR__SERVER__URLBASE value: {{ . | quote }} {{- end }} + {{- /* Proxy networks whose forwarded headers prowlarr honours. + Emitted only when set - empty is the default and means no + proxy is trusted. See the freesailarr.trustedNetworks + helper and the value's comment in values.yaml. */}} + {{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "prowlarr")) }} + - name: PROWLARR__SERVER__TRUSTEDNETWORKS + value: {{ . | quote }} + {{- end }} {{- include "freesailarr.env" (dict "root" $ "env" .Values.prowlarr.env) | nindent 12 }} ports: - containerPort: 9696 @@ -348,6 +356,11 @@ spec: - name: RADARR__SERVER__URLBASE value: {{ . | quote }} {{- end }} + {{- /* See the prowlarr TRUSTEDNETWORKS comment above. */}} + {{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "radarr")) }} + - name: RADARR__SERVER__TRUSTEDNETWORKS + value: {{ . | quote }} + {{- end }} {{- include "freesailarr.env" (dict "root" $ "env" .Values.radarr.env) | nindent 12 }} ports: - containerPort: 7878 @@ -424,6 +437,13 @@ spec: - name: SONARR__SERVER__URLBASE value: {{ . | quote }} {{- end }} + {{- /* See the prowlarr TRUSTEDNETWORKS comment above. Sonarr did + not lose the trust with this chart version - 4.0.19 was + already in the empty state. */}} + {{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "sonarr")) }} + - name: SONARR__SERVER__TRUSTEDNETWORKS + value: {{ . | quote }} + {{- end }} {{- include "freesailarr.env" (dict "root" $ "env" .Values.sonarr.env) | nindent 12 }} ports: - containerPort: 8989 diff --git a/freesailarr/values.schema.json b/freesailarr/values.schema.json index 374a021..57f454d 100644 --- a/freesailarr/values.schema.json +++ b/freesailarr/values.schema.json @@ -329,6 +329,9 @@ "env": { "$ref": "#/definitions/env" }, + "trustedNetworks": { + "$ref": "#/definitions/trustedNetworks" + }, "livenessProbe": { "$ref": "#/definitions/probe" }, @@ -395,6 +398,9 @@ "env": { "$ref": "#/definitions/env" }, + "trustedNetworks": { + "$ref": "#/definitions/trustedNetworks" + }, "livenessProbe": { "$ref": "#/definitions/probe" }, @@ -464,6 +470,9 @@ "env": { "$ref": "#/definitions/env" }, + "trustedNetworks": { + "$ref": "#/definitions/trustedNetworks" + }, "livenessProbe": { "$ref": "#/definitions/probe" }, @@ -690,6 +699,13 @@ } } }, + "trustedNetworks": { + "description": "Comma-separated CIDRs whose X-Forwarded-For/-Proto/-Host headers this servarr app honours, rendered as __SERVER__TRUSTEDNETWORKS and only when non-empty. Empty is the default and means no proxy is trusted - the app then reports the ingress controller's pod IP as the client and sees the scheme as http behind TLS termination. Kept null-safe deliberately: an erased value is the same permissive-to-nobody state as the default, so it must not fail the render.", + "type": [ + "string", + "null" + ] + }, "appStorage": { "description": "Size of this app's config volumeClaimTemplate. Not null-safe on purpose: an erased size yields an invalid claim template and fails loudly.", "type": "object", diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index a604b2c..7d2a26b 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -423,7 +423,7 @@ qbittorrent: enabled: true image: repository: docker.io/linuxserver/qbittorrent - tag: 5.2.3 + tag: 5.2.4 # Every app gets its own Ingress object (named ---ingress) # with its own TLS secret tls----ingress, so the apps can # be exposed independently and under unrelated hosts. @@ -628,7 +628,43 @@ prowlarr: enabled: true image: repository: docker.io/linuxserver/prowlarr - tag: 2.5.2 + tag: 2.6.5 + # Networks whose X-Forwarded-For / -Proto / -Host headers prowlarr honours, + # as comma-separated CIDRs (e.g. "10.42.0.0/16"). Rendered as + # PROWLARR__SERVER__TRUSTEDNETWORKS, and only when this value is non-empty. + # + # EMPTY IS THE DEFAULT, AND WITH IT PROWLARR TRUSTS NO PROXY AT ALL. That is + # a change in behaviour and not a preserved one. Up to 2.5.2 the app + # hard-coded 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 and + # fe80::/10 as trusted proxy networks in Startup.cs; 2.6.3 removed all five + # and replaced them with this setting, whose own upstream default is + # string.Empty - ForwardedHeadersConfigurator returns without registering a + # single KnownNetwork while it is empty. Upstream's reason for the change is + # that trusting X-Forwarded-For from every private network is a spoofing + # vector, and this chart follows that intent rather than papering over it. + # + # What it costs while this stays empty and the app is published through an + # Ingress or an HTTPRoute: the client address prowlarr logs and shows is the + # ingress controller's pod IP rather than the real client, and the app sees + # the scheme as http even behind TLS termination. If its authentication is + # set to "Disabled for Local Addresses", a proxied request no longer counts + # as local and is asked to authenticate. + # + # The chart cannot fill this in for you. The network to trust is the one your + # ingress controller's pods sit in - the cluster pod CIDR, or that single + # controller pod's IP - which is a property of the cluster and not of the + # release. Set it yourself if you want the forwarded headers honoured. + # + # AllowedHosts, the other key 2.6.3 added, is deliberately NOT exposed as a + # value here. Its upstream default is string.Empty as well, and while it is + # empty the app accepts requests for any host ("Allowed Hosts is not + # configured, accepting requests for any host"), so there is nothing to + # repair. One trap to know before anyone sets it through the env block + # anyway: the app only ever appends localhost, 127.0.0.1, [::1] and its own + # hostname to that list, so neither the pod IP nor the in-cluster Service DNS + # name is covered - and the kubelet addresses the probes to the pod IP, so an + # incomplete list answers them with HTTP 400. + trustedNetworks: "" # See the qbittorrent ingress comment. ingress: enabled: true @@ -687,7 +723,7 @@ flaresolverr: enabled: true image: repository: ghcr.io/flaresolverr/flaresolverr - tag: v3.5.0 + tag: v3.5.2 # No ingress and no config volume: flaresolverr is stateless and only # consumed by prowlarr over localhost. env: [] @@ -742,7 +778,13 @@ radarr: enabled: true image: repository: docker.io/linuxserver/radarr - tag: 6.3.0 + tag: 6.4.4 + # The same setting, the same empty default and the same consequence as + # prowlarr's - read the trustedNetworks comment there for the whole picture. + # Radarr dropped the five hard-coded private networks in 6.4.3 (6.3.0, the + # version this chart shipped before, still had them) and reads this as + # RADARR__SERVER__TRUSTEDNETWORKS. + trustedNetworks: "" # See the qbittorrent ingress comment. ingress: enabled: true @@ -800,7 +842,7 @@ recyclarr: enabled: true image: repository: ghcr.io/recyclarr/recyclarr - tag: 8.7.1 + tag: 8.7.3 # No ingress: recyclarr has no web UI, it syncs TRaSH guides to radarr and # sonarr on a schedule (daemon mode). storage: @@ -853,7 +895,17 @@ sonarr: enabled: true image: repository: docker.io/linuxserver/sonarr - tag: 4.0.19 + tag: 4.0.20 + # The same setting as prowlarr's - read the trustedNetworks comment there. + # Reads as SONARR__SERVER__TRUSTEDNETWORKS. + # + # One difference worth stating plainly: sonarr did NOT lose the forwarded + # header trust with this chart version. 4.0.19, the version this chart + # shipped before, already had no hard-coded networks and the same + # empty-default TrustedNetworks, so the value is being supplied here for a + # state that already existed rather than for a new one. It had arrived + # unnoticed in what the convention classes as a plain app-version bump. + trustedNetworks: "" # See the qbittorrent ingress comment. ingress: enabled: true @@ -911,7 +963,7 @@ seerr: enabled: true image: repository: ghcr.io/seerr-team/seerr - tag: v3.4.1 + tag: v3.5.0 # See the qbittorrent ingress comment. ingress: enabled: true @@ -968,7 +1020,7 @@ bazarr: enabled: true image: repository: docker.io/linuxserver/bazarr - tag: 1.6.0 + tag: 1.6.2 # See the qbittorrent ingress comment. ingress: enabled: true -- 2.54.0 From d36112aaf66630d0e773c94ae6200b04a7d23578 Mon Sep 17 00:00:00 2001 From: Justin Klein Date: Mon, 12 Oct 2026 22:00:42 +0000 Subject: [PATCH 2/2] chore(renovate): group the freesailarr stack into one PR per cycle The first groupName in this config, and a deliberate reversal for one chart of the stance the file records above its app-version managers: that the helm-values manager finds freesailarr's ten images and proposes one update per image. That described what Renovate does, not what the chart wants. For every other chart here one PR per image is already one PR per chart. What it fixes, measured against the Gitea API on 2026-10-12: eight of the ten images had an open PR at the same moment (#102, #103, #104, #105, #106, #107, #108, #111). Each edits freesailarr/values.yaml and, through the postUpgradeTasks hook, freesailarr/Chart.yaml - so the API reported mergeable: false for all eight, since they rewrite the same version: line. One can merge; the other seven then need a rebase each, and every rebase restarts the most expensive install-test in this repo. The second symptom is sharper: all eight carried version: 3.1.1+up1.0.0 -> 3.1.2+up1.0.0 while main already stood at 3.3.0+up1.0.0, because the hook derives everything from the base commit and those branches predated the Gateway-API rounds. A rebase repairs it, which is the point - nothing fails if the rebase does not happen first. Two rules rather than one, and not for tidiness: renovate rejects the combination outright with "packageRules cannot combine both matchUpdateTypes and separateMinorPatch", a config-validation error. That is consistent, since separateMinorPatch decides the split a rule would then be selecting on. Without the second rule the group still arrives as two PRs - verified by dry-run against a checkout holding those eight updates, they split onto renovate/freesailarr-stack (the three minors) and renovate/patch-freesailarr-stack (the five patches); note that the setting prefixes the branch rather than suffixing it. With both rules, one branch carries all eight. Decisions, with their reasons in the rules themselves: matched by file name so that no second list of image names has to be kept current (an image added later would silently never join the group); mediathekarr included, since its digest PR blocks the others identically - checked with a stale digest, because a current pin makes a working and a broken rule look the same; gluetun and busybox included, as neither's role has anything to do with the blocking; and majors kept out via matchUpdateTypes, so the stepwise app-major procedure and separateMultipleMajor keep working. What it cannot show from here: Renovate runs as the shared instance outside this repository, so the evidence available is a config that validates and a local lookup that groups as intended - not the next real run. --- README.md | 23 +++++++++- renovate.json5 | 116 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 50184b7..df2a839 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ What Renovate keeps current: | --- | --- | | Chart app versions (`appVersion:` in `Chart.yaml`) | One `customManagers` entry per chart, hard-coding the image repository | | Sidecar/init images in `values.yaml` (nginx, guacd, busybox, alpine) | Built-in `helm-values` manager | -| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block. One exception: `mediathekarr` is pinned by **digest** (its tags carry no version) and has its own `customManagers` block | +| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block. One exception: `mediathekarr` is pinned by **digest** (its tags carry no version) and has its own `customManagers` block. All ten arrive **grouped into one PR**, see "The one grouped chart" below | | GitHub Actions | Built-in `github-actions` manager | | Tool versions pinned as workflow env vars (`TRIVY_VERSION`) | `# renovate: datasource=… depName=…` comment above the line | @@ -265,10 +265,29 @@ One rule those overrides share, learned from `searxng` and worth knowing before A consequence of pinning every tag in `values.yaml`: `ci/renovate-bump-chart.sh` sees an unchanged `appVersion` and bumps the chart **patch**. That holds for all ten images alike, the digest-pinned one included — none of them is special — and it is the intended outcome, since `appVersion` here is the chart's own marker rather than a tracked upstream version. +**An open point this leaves, recorded rather than solved.** That automatic patch is also a classification, and the convention exempts a "plain app-version bump" from the wiki obligation — so an image update that quietly changes a *default* is the one case where nothing reviews it and nothing documents it. It has already happened once: the `sonarr` 4.0.19 bump carried upstream's removal of the hard-coded trusted proxy networks, and it reached `main` unremarked (see [Lessons learned](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/Lessons-Learned)). Whether the convention should change is a separate decision and deliberately not proposed here. + Two dependencies are constrained beyond their versioning. **nginx** (the Synapse delegation sidecar) is limited to **even minors** via `allowedVersions`. nginx numbers its release branches by minor — even is stable, odd is mainline — and the chart belongs on stable. Patch updates inside the stable branch still flow through normally. The constraint tolerates a variant suffix (`1.30.4-alpine`) on purpose: docker versioning only ever offers candidates carrying the current tag's suffix, so a suffix-blind constraint would silently freeze nginx the day the sidecar moves to a slimmer base image — a switch to `1.30.4-alpine` that #84 has commissioned a check on — with no error and no warning. **linuxserver/qbittorrent** (freesailarr) excludes two tags by name, `14.3.9` and `20.04.1`. They are leftovers from earlier linuxserver tagging schemes, and docker versioning sorts both above every real release, so Renovate offers them as major updates — PRs #152 and #153 were exactly that. They are named rather than cut off with an upper bound like `<10`, because a bound would eventually reject a genuine major, while two finished artifacts cannot grow and therefore cannot hide a future release. The same guard as for nginx applies and was run: a lookup from `currentValue 4.6.4` still offers `5.2.3` as a **major**, so the filter does not silently empty the update list. That fixture also shows the cost of leaving it alone — without the rule the very same lookup offers `20.04.1` and never mentions the real `5.2.3`, since Renovate fills the major bucket only once. +### The one grouped chart + +`freesailarr` is the only chart whose updates are **grouped**: all ten of its images arrive as one PR per cycle (`groupName: "freesailarr stack"`), where every other chart in this repo gets one PR per image. For the others that is already the same thing — one chart, one image — so this is a rule about a ten-image chart rather than a change of policy. It is also the only `groupName` in `renovate.json5`, and it reverses what the comment above the app-version managers describes: the `helm-values` manager finding those ten images and proposing "one update per image". That sentence described what Renovate does, not what the chart wants. + +**What it fixes, measured on 2026-10-12.** Eight of the ten images had an open Renovate PR simultaneously (#102 bazarr, #103 qbittorrent, #104 sonarr, #105 flaresolverr, #106 recyclarr, #107 prowlarr, #108 radarr, #111 seerr). Each edits `freesailarr/values.yaml` and — through the `postUpgradeTasks` hook — `freesailarr/Chart.yaml`, so the API reported `mergeable: false` for all eight: they rewrite the same `version:` line. One could merge; the other seven then needed a rebase each, and every rebase restarts the most expensive install-test here (a real Wireguard tunnel, ten containers). A sharper second symptom came with it: all eight carried `version: 3.1.1+up1.0.0 → 3.1.2+up1.0.0` while `main` already stood at `3.3.0+up1.0.0`, because the hook derives everything from the **base commit** and those branches predated the Gateway-API rounds. A rebase repairs that, which is precisely the problem — nothing *fails* if the rebase does not happen first, so the repair depends on the bot reaching the branch before a human reaches the merge button. Grouped, there is one branch, one `version:` computed from one base, one publish and one install-test run. + +**Four decisions worth keeping** (the reasoning sits next to the rules in `renovate.json5`): + +- **Matched by file name, not by a list of images.** What causes the collision is "touches `freesailarr/Chart.yaml`", true of every dependency Renovate finds in that `values.yaml` regardless of manager or datasource. A `matchPackageNames` list would be a second place to keep the chart's image set current, and its failure mode is silence — an image added later never joins the group, with no error (#158: a valid rule is not a matching rule). +- **`mediathekarr` is in the group** although it comes from its own `customManagers` block: its digest PR edits the same two files and blocks the others identically. `digest` is in `matchUpdateTypes` for exactly that, and its own block is untouched — a group decides which *branch* an update lands on, not how it is found. +- **`gluetun` and the `busybox` init image are in too.** Neither "gluetun is the tunnel" nor "busybox is only an init container" has anything to do with the blocking, and a grouped PR hides nothing: the review still sees every changed line, just not eight times over. +- **Majors stay out**, via `matchUpdateTypes: ["patch", "minor", "digest"]`. An app major must be taken stepwise in its own PR, and `separateMultipleMajor` splits a multi-major jump to produce that sequence; a group throwing a major in with seven patches would undo both. + +**It takes two `packageRules`, and that is not cosmetic.** Renovate refuses `matchUpdateTypes` and `separateMinorPatch` in one rule — `packageRules cannot combine both matchUpdateTypes and separateMinorPatch`, a config-validation error — which is consistent, since `separateMinorPatch` is what *decides* the split a rule would then be selecting on. So one rule groups the non-major update types and a second sets `separateMinorPatch: false` for the same file. Without that second rule the group still arrives as **two** PRs: verified by dry-run (`--platform=local --dry-run=full`, real registries) against a checkout holding those eight updates, they landed on `renovate/freesailarr-stack` (the three minors) and `renovate/patch-freesailarr-stack` (the five patches) — note that `separateMinorPatch` **prefixes** the branch rather than suffixing it, which makes the second name easy to miss in a branch list. With both rules, one branch carries all eight. The global `separateMinorPatch: true` keeps its reason for every other chart; "one red install-test must not block unrelated updates" simply does not apply within a single chart, where the updates share a pod, a chart version and a publish. + +What this cannot show from inside the repo: Renovate runs as the shared instance outside it, so a valid config and a correct local lookup are the evidence available — not the behaviour of the next real run. + Not managed: `ci/**` (install-test fixtures pin throwaway infrastructure to major lines on purpose) and `template-chart/**` (placeholder image). A consequence worth knowing: `ci/matrix-synapse/fixtures.yaml` pins a Synapse image that Renovate will not follow — bump it by hand when the chart moves a major. ### The version-bump hook @@ -308,7 +327,7 @@ The config is aligned with the one in `cluster-config`, because the same `jlab-r - **Automerge is global** (`automerge: true`, `automergeType: "pr"`, `platformAutomerge: true`) — every update type, **majors included**. The safeguard is not the absence of automerge, it is the branch protection: `main` requires a **review** and requires `validate-charts` to pass. `platformAutomerge` arms the PR on the forge, and Gitea merges it when *both* conditions are met, so an armed PR without a review simply sits there. This is the setting the older `platformAutomerge: false` comment in `renovate.json5` named as the better one "once required checks exist on `main`" — they now do. Same reasoning as `cluster-config`, which retired its dashboard-approval step for it. - **`minimumReleaseAge: "3 days"`** for `patch`, `minor` and `digest` updates. Renovate does not even open the PR before then. A merge here is never just a merge — a chart publish follows it straight away — so a release that gets yanked hours after it appeared must not reach the registry. Majors carry no age gate: they are held at the review, not by a delay. -- **Majors** arrive one line at a time (`separateMultipleMajor`): a v1 → v3 jump produces a PR for the latest v2 first and then one for v3 — the stepwise procedure the versioning section requires. `separateMinorPatch: true` likewise splits minor and patch into separate PRs, so one red install-test does not block unrelated updates. +- **Majors** arrive one line at a time (`separateMultipleMajor`): a v1 → v3 jump produces a PR for the latest v2 first and then one for v3 — the stepwise procedure the versioning section requires. `separateMinorPatch: true` likewise splits minor and patch into separate PRs, so one red install-test does not block unrelated updates. **It has one exception**, `freesailarr`, where it is overridden to `false` — see "The one grouped chart" below for why that separation is wrong inside a single chart. - **No PR caps** (`prConcurrentLimit: 100`, `prHourlyLimit: 0`), as in `cluster-config`. The known cost, written down rather than discovered: the first run after a quiet week can open many PRs at once, and every chart PR starts a k3d install-test — which on a Gitea runner queues inside one job rather than fanning out (see "Where this repo lives"). That queue is the accepted trade for not having updates trickle in four per hour. - **`config:best-practices`** is in `extends`. Two of its members matter here: `helpers:pinGitHubActionDigests` moves the workflow files from `actions/checkout@v7` to SHA pins (wanted), and `docker:pinDigests` is switched off again for `custom.regex` and `helm-values` — an `appVersion:` is a tag by construction (`:{{ .Chart.AppVersion }}`, and the `+up` suffix is built from the same string), so a digest has nowhere to go. Sidecar digests in `values.yaml` *could* be pinned; that is a separate decision, since each digest change would be a chart patch plus a publish. - The manually dispatched `change-app-version.yml` still exists for one-off bumps, but the normal path for app updates is now a Renovate PR. diff --git a/renovate.json5 b/renovate.json5 index 4aea481..9313743 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -229,6 +229,122 @@ // the major bucket once. allowedVersions: "!/^(14\\.3\\.9|20\\.04\\.1)$/", }, + { + description: [ + "The freesailarr stack arrives as ONE grouped PR per cycle instead of one PR per image.", + "This is the only groupName in this file and a deliberate reversal of the stance written", + "above the app-version managers, which says the built-in helm-values manager finds the ten", + "images in that chart's values.yaml and 'proposes one update per image'. That sentence", + "described what Renovate does; it was never an argument that one PR per image is what this", + "chart wants. For every other chart in this repo it still is — one chart, one image, one", + "PR is already the grouping — and that is why this rule is scoped to one file rather than", + "turning into a repo-wide policy.", + "", + "WHAT CHANGED, measured on 2026-10-12 against the Gitea API: eight of this chart's ten", + "images had an open Renovate PR at the same moment — #102 bazarr, #103 qbittorrent, #104", + "sonarr, #105 flaresolverr, #106 recyclarr, #107 prowlarr, #108 radarr, #111 seerr. Each", + "one edits freesailarr/values.yaml and, through the postUpgradeTasks hook above, also", + "freesailarr/Chart.yaml. The API reported `mergeable: false` for all eight, because the", + "`version:` line they rewrite is the same line in all eight. One of them can merge; the", + "other seven then need a rebase round each, and every rebase restarts the most expensive", + "install-test in this repo — a real Wireguard tunnel and ten containers in one pod.", + "", + "The second symptom is the sharper one. All eight carried", + "`version: 3.1.1+up1.0.0 -> 3.1.2+up1.0.0` while main already stood at 3.3.0+up1.0.0:", + "the hook derives everything from the BASE COMMIT (see its comment above), and these", + "branches were cut before the Gateway-API rounds landed. Merging one unrebased would have", + "moved the chart version backwards by two minors and republished the chart under a version", + "the registry already has. A rebase repairs it — the point is that nothing FAILS when the", + "rebase does not happen first, so the repair depends on the bot reaching the branch before", + "a human reaches the merge button.", + "", + "WHY GROUPING FIXES BOTH: one branch, one `version:` bump computed once from one base, one", + "publish, one install-test run for the whole stack. Nothing is left to serialize, so", + "nothing is left to rebase.", + "", + "MATCHED BY FILE, NOT BY A LIST OF IMAGE NAMES. What creates the collision is 'touches", + "freesailarr/Chart.yaml', and that is true of every dependency Renovate finds in that", + "chart's values.yaml, whatever its manager, datasource or role in the pod. A", + "matchPackageNames list would be a second place to keep the chart's image set current, and", + "its failure mode is silence: an image added later simply never joins the group, with no", + "error and no warning (#158 — a valid rule is not a matching rule). matchFileNames needs", + "no maintenance to stay complete.", + "", + "Consequences of that choice, each one intended and not a side effect:", + " - mediathekarr IS in the group, even though it comes from its own customManagers block", + " further down rather than from helm-values. Its digest PR edits the same two files and", + " blocks the other seven exactly as they block each other, so leaving it beside the", + " group would keep a smaller copy of the problem alive. Its own block is untouched; a", + " group decides which BRANCH an update lands on, not how it is found. `digest` is in", + " matchUpdateTypes precisely so this one is covered. Verified the way that block's own", + " comment demands, because while the pin is current a working rule and a broken one look", + " identical: with the real digest the run produces no mediathekarr update at all, so it", + " proves nothing; with the digest set to sha256:0000... the digest update appears ON THE", + " GROUP BRANCH next to the other eight, not on renovate/docker.io-pcjones-mediathekarr-", + " beta as it would without this rule.", + " - gluetun and the busybox init image are in the group too, against the instinct to hold", + " them apart because gluetun is the tunnel every other container's network namespace", + " depends on and busybox is 'only' an init container. Neither property has anything to", + " do with the blocking, and a grouped PR hides nothing: the review still sees every", + " changed line, it just no longer sees them eight times. (The busybox pinned in", + " ci// is a different thing and stays unmanaged — see the ci/** rule at the top.)", + "", + "MAJORS STAY OUT, which is what matchUpdateTypes is doing here. CLAUDE.md and README", + "require an app major to be taken stepwise, in its own PR, and separateMultipleMajor above", + "splits a multi-major jump into one PR per major line to produce exactly that sequence. A", + "group that threw a major in with seven patches would undo both, and a major is the update", + "where the review most needs one image at a time. Majors therefore keep arriving", + "individually and keep their deliberately missing age gate.", + "", + "A groupName ALONE DOES NOT DO THE JOB, which is why the rule directly below exists and", + "is not a stylistic split. The global `separateMinorPatch: true` at the top of this file", + "keeps splitting minor from patch INSIDE the group. Measured (renovate 44.x,", + "--platform=local --dry-run=full, real registries, 2026-10-12, against a checkout at the", + "eight pending updates): with the group rule and nothing else, the eight land on TWO", + "branches —", + " renovate/freesailarr-stack prowlarr 2.6.5, radarr 6.4.4, seerr v3.5.0 (minor)", + " renovate/patch-freesailarr-stack bazarr, qbittorrent, sonarr, flaresolverr, recyclarr", + "— and note the shape: separateMinorPatch prefixes the branch, it does not suffix it, so", + "the second name is easy to miss when scanning a branch list. Two PRs editing the same", + "`version:` line is the original problem at a quarter of the size, so the override is", + "required for the result this rule is for. With it, the same run puts all eight on one", + "branch.", + "", + "And the override CANNOT live in this rule: renovate rejects the combination outright with", + " packageRules[n]: packageRules cannot combine both matchUpdateTypes and", + " separateMinorPatch", + "which is a config-validation error, not a warning — found by running the dry-run instead", + "of trusting the draft. It is consistent, too: separateMinorPatch is what DECIDES the", + "updateType split, so a rule may not both perform the split and select on its outcome.", + "Hence two rules over the same matchFileNames, one selecting updateTypes to group and one", + "setting the split. Keep them together and keep them in this order.", + "", + "WHAT THIS RULE CANNOT DO: it takes effect on the next run of the shared instance, and", + "that instance is outside this repository. What is verifiable from here is that the config", + "validates and that the lookup groups as intended; that the bot then behaves that way is", + "not provable from this side (see README, 'Renovate on Gitea').", + ], + matchFileNames: ["freesailarr/values.yaml"], + matchUpdateTypes: ["patch", "minor", "digest"], + groupName: "freesailarr stack", + groupSlug: "freesailarr-stack", + }, + { + description: [ + "The other half of the freesailarr grouping directly above — read that block first, the", + "whole argument is there. Separate only because Renovate refuses matchUpdateTypes and", + "separateMinorPatch in one rule ('packageRules cannot combine both matchUpdateTypes and", + "separateMinorPatch', a validation error). Without this, the group still arrives as two", + "PRs, one of them on the easily missed renovate/patch-freesailarr-stack.", + "", + "It deliberately carries NO matchUpdateTypes, so it also applies to a major — which costs", + "nothing: separateMinorPatch only ever decides whether minor and patch share a branch, and", + "a major is in neither bucket. Majors stay single PRs because the rule above does not", + "group them, not because of anything here.", + ], + matchFileNames: ["freesailarr/values.yaml"], + separateMinorPatch: false, + }, ], customManagers: [ -- 2.54.0