From d36112aaf66630d0e773c94ae6200b04a7d23578 Mon Sep 17 00:00:00 2001 From: Justin Klein Date: Mon, 12 Oct 2026 22:00:42 +0000 Subject: [PATCH] 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: [