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/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 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: [