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