Table of Contents
- Renovate
- Where it runs
- What it keeps current
- Why the mapping lives in renovate.json5
- A capture group is a claim about how the value is compared
- The upgrade procedure the automerge does not know about
- The one grouped chart
- The one digest pin
- The version-bump hook
- Constrained dependencies
- PR policy
- GITHUB_COM_TOKEN
- Adding a chart to Renovate
Renovate
Where it runs
As the shared jlab-renovate-bot instance on the Gitea side, which also serves the other repositories there. It reads our renovate.json5 and its postUpgradeTasks hook is allow-listed in the instance config.
Do not add a Renovate workflow to this repository. It is the obvious thing to reach for — Gitea has no app model, so a scheduled job in the repo looks like the only way to run the bot — and it is wrong here: a second runner against the same repo produces duplicate PRs and two schedulers rewriting each other's branches. This paragraph exists so the next reader who finds no workflow knows why instead of building one.
The jlab-renovate GitHub App that used to do this is switched off along with the rest of GitHub Actions.
There must be exactly one config file. renovate.json5 in the repo root — JSON5 for the inline reasoning, and it requires Renovate ≥ 41 for managerFilePatterns/customType. Do not add a second renovate.json or .github/renovate.json.
What it keeps current
| What | How |
|---|---|
Chart app versions (appVersion: in Chart.yaml) |
one customManagers entry per chart, hard-coding the image repository |
Sidecar and init images in values.yaml (nginx, guacd, busybox, alpine) |
built-in helm-values manager |
All images of freesailarr, its main one included |
built-in helm-values manager — the chart pins every tag in values.yaml and has no app-version block. Exception: mediathekarr is pinned by digest and has its own customManagers block. All ten arrive in one grouped PR, see The one grouped chart |
The oauth2-proxy subchart of ytdlp-web-player |
built-in helmv3 manager — repository URL and version sit together in Chart.yaml, so no custom manager is needed |
| GitHub Actions | built-in github-actions manager |
Tool versions pinned as workflow env vars (TRIVY_VERSION, KUBECTL_VERSION) |
a # renovate: datasource=… depName=… comment above the line, through a customManagers entry for .github/workflows/*.yml |
Tool versions pinned in a ci/ shell script (GATEWAY_API_VERSION in ci/gateway-api-crds.sh) |
the same comment convention, through a second customManagers entry for ci/*.sh — the assignment there is VAR=value rather than the workflow's VAR: value, which is why it is a separate pattern |
Not managed: the fixture images under ci/<chart>/ (they 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 Renovate will not follow — bump it by hand when the chart moves a major.
The blanket "ci/** is not managed" was true until the Gateway-API pilot (Gitea #114) put a pinned tool version in ci/gateway-api-crds.sh. The distinction that replaced it is the one that was always meant: a fixture's image is pinned deliberately and stays unmanaged, while a tool fetched at a pinned version is tracked wherever it lives — in a workflow or in a ci/ script. The second customManagers entry is deliberately a separate block rather than a widened managerFilePatterns on the first: that one is dry-run verified and covers every workflow, and broadening it would put the verification back in question for a second file shape (see A capture group is a claim — a valid rule is not a matching rule).
Why the mapping lives in renovate.json5
Each chart renders its main image as <repo>:{{ .Chart.AppVersion }} — repository in the template, version in Chart.yaml. Renovate cannot join two files, so the mapping is one block per chart in the config.
It is deliberately not a # renovate: comment inside each Chart.yaml: that would touch all chart directories and force a version bump plus a publish per chart for a pure comment change.
A capture group is a claim about how the value is compared
The rule every versioningTemplate override shares, learned from searxng and worth reading before writing the next one.
Renovate's regex versioning parses major, minor, patch, build and revision with Number.parseInt, so a non-numeric commit hash captured as build becomes whatever digits lead it: 9fea41204 is 9, 777ba8fa4 is 777, and a hash starting with a letter is NaN. Measured on renovate 41.173.1 and 44.48.0, that makes Renovate offer an image pushed earlier the same day as an update to a newer one — a downgrade dressed as a routine date bump, which is what a review waves through and automerge then carries to main.
Parts of a tag that carry no ordering belong outside every capture group. The anchored pattern still requires them to be there; they simply take no part in the comparison.
prerelease is not the way out either: it sorts correctly but marks every tag isStable === false, which hands the whole chart to ignoreUnstable.
Charts whose tags carry a flavour or date suffix override versioning in their block, with the reasoning next to each: apache-tika, minecraft-server, rss-bridge, searxng, teamspeak-server.
Floating tags belong outside the pattern. ytdlp-web-player anchors its regex on the v prefix precisely so latest and stable — both of which move — can never be selected.
And a registry that publishes a tag per commit needs a version constraint, not only an anchor. garage is the sharp case, measured on 2026-10-03: of the 340 tags on docker.io/dxflrs/garage the newest 22 are 40-character commit SHAs, pushed almost daily, while the current release v2.4.1 dates from 2026-09-08. A block without a constraint therefore offers a hex string as the next "update", and an appVersion of 40 hex characters pins a commit nobody chose — and republishes the chart for it. The same block also has to keep out v2.0.0-rc1, v2.0.0-beta1 and four v1.99.x-internal builds, and note that ordering does not help there: v1.99.3-internal sorts above the whole v1.3.x line. The shape that does all of it at once is a regex versioning anchored to ^v<major>.<minor>.<patch>$ — a candidate that does not match is not a version at all, so SHA tags (no v, no dots) and every suffixed tag (the terminating anchor) are skipped in one rule. stalwart uses the identical shape against its three moving names and its -alpine twins.
The counter-case, and it has to be measured just as carefully: sometimes the constraint is not needed. keycloak looks like the same trap from the outside — quay.io/keycloak/keycloak carries release tags (26.8.0), rebuild tags with a numeric suffix (26.8.0-0, 26.7.3-1, 26.7.2-2, where the same app version is rebuilt for a base-image or CVE fix), floating minor tags (26.8, 26.7) and the floating names latest and nightly, the latter rebuilt daily. Four shapes, three of them unwanted. And plain docker versioning already excludes all three, so that block carries no versioningTemplate: docker versioning splits a tag at the first dash and only ever offers candidates with the same suffix as the current value — ours has none, so the -N rebuilds are invisible to it — while the floating minors fail on the missing third part and latest/nightly parse as no version at all.
Measured rather than assumed, with Renovate 44.132.5 (--platform=local --dry-run=lookup, real registry) against the committed config: 26.8.0 → no updates and warnings: []; 26.7.0 → exactly one, 26.8.0 as a minor; 26.7.4 → exactly two, 26.7.5 as a patch and 26.8.0 as a minor. No run offered a rebuild tag, a floating minor, latest or nightly.
Why that deserves a paragraph instead of an absent one: an unnecessary constraint is not free. It is a line that does nothing, that reads as load-bearing to the next person, and that has to stay correct as upstream's tagging changes — and the two fixtures above are exactly the guard the nginx and qbittorrent rules taught us to run, so skipping it was a decision with evidence rather than an omission. The general rule both cases share: check the tag list, then measure what the default versioning already does with it, and only add a constraint for what is left.
The upgrade procedure the automerge does not know about
keycloak supports a rolling update only within a minor line. A minor jump (26.8 → 26.9) requires every node shut down before any new node starts, and nothing in the cluster reports that two minor versions met in one Infinispan cluster.
That collides with the PR policy below: automerge: true is global here, majors included, with a three-day age gate for patch and minor. A minor app-version PR therefore merges itself after three days, publishes the chart, and — at two replicas — is rolled out as a rolling update, which is the one procedure upstream excludes for that step.
The proposal to switch automerge off for minor and major on that one image was made and decided against (Gitea #83). The global policy applies to keycloak like everything else, and the risk is accepted knowingly rather than overlooked. Two reasons that is defensible: main requires a review, so an automerged PR is still a PR somebody waved through; and a packageRules exception would have been the second mechanism — next to the review — that only works while somebody remembers it is there.
So the procedure is documented instead of prevented, which puts an obligation on whoever reviews app-version PRs for this chart:
- Watch for a changed minor in a keycloak app-version PR. The chart version's
+upsuffix makes it visible in the diff. - Decide it with
kc.sh update-compatibility metadata --file=…(old version, old configuration) and thencheck --file=…(new version, new configuration), passing every option on both calls. Automate on the exit code:0possible,1error,2invalid option,3/4not possible — shut down first. - If it says shut down:
scaleDown: true, back up database and configuration, bump,scaleDown: false.
The chart's NOTES.txt carries the same sequence, so it reaches whoever runs the release and not only whoever reads this page. One thing is deliberately left open there and here: upstream's 26.6 release notes call patch-level rolling updates supported and default-on, while the 26.8.0 update-compatibility page says a rolling update is possible "when the Keycloak version is the same" and uses a patch pair as its own incompatible example. Until that is resolved, run the check for patch updates too.
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". Every other chart gets one PR per image, which for a one-image chart is already the same thing — so this is a rule about a ten-image chart, not a change of policy. It is also the only groupName in renovate.json5, and it reverses what the comment above the app-version managers says about the helm-values manager proposing "one update per image". That sentence described what Renovate does; it was never an argument for what this chart wants.
Measured on 2026-10-12, which is why the rule exists. Eight of the 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 edits freesailarr/values.yaml and, through the version-bump hook, freesailarr/Chart.yaml — so the API reported mergeable: false for all eight: 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 repository (a real Wireguard tunnel, ten containers in one pod).
A second symptom came with it, and it 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, and those branches were cut before the Gateway-API rounds landed — so merging one unrebased would have moved the chart version backwards by two minors and republished it under a version the registry already had. A rebase repairs that, which is exactly 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. The mutual blocking is the durable argument; the backwards version depends on that race.
Grouped, there is one branch, one version: computed once from one base, one publish, one install-test run.
Four decisions, each deliberate:
- Matched by file name, 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 thatvalues.yaml, whatever its manager or datasource. AmatchPackageNameslist 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 and no warning. A valid rule is not a matching rule. mediathekarris in the group, although it comes from its owncustomManagersblock rather than fromhelm-values. Its digest PR edits the same two files and blocks the others identically, so keeping it outside would preserve a smaller copy of the problem. Its own block is untouched: a group decides which branch an update lands on, not how it is found.digestis inmatchUpdateTypesprecisely for it.gluetunand thebusyboxinit image are in too, against the instinct to hold them apart because gluetun is the tunnel every other container 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, just not eight times over.- Majors stay out, via
matchUpdateTypes: ["patch", "minor", "digest"]. Versioning requires an app major to be taken stepwise in its own PR andseparateMultipleMajorproduces that sequence; a group throwing a major in with seven patches would undo both, and a major is the update where a review most needs one image at a time.
It takes two packageRules, and that is not cosmetic. Renovate refuses the two settings in one rule:
packageRules[n]: packageRules cannot combine both matchUpdateTypes and separateMinorPatch
a config-validation error, not a warning — found by running the dry-run rather than trusting the draft. It is consistent: separateMinorPatch is what decides the update-type split, so one rule may not both perform the split and select on its outcome. 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 (--platform=local --dry-run=full, real registries, against a checkout holding those eight updates):
| branch | contents | |
|---|---|---|
| group rule alone | 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 (patch) | |
| both rules | renovate/freesailarr-stack |
all eight |
Note the shape: separateMinorPatch prefixes the branch name rather than suffixing it, which makes the second branch easy to miss when scanning a list. The global separateMinorPatch: true keeps its reason for every other chart — "one red install-test must not hold up unrelated updates" — and that reason simply does not hold inside a single chart, where the updates share a pod, a chart version and a publish.
The mediathekarr membership was checked the way its own pin demands, because with a current digest a working rule and a broken one produce the same empty result: with the real digest the run yields no mediathekarr update at all and proves nothing, while with the digest set to sha256:0000… the digest update appears on the group branch beside the other eight instead of on renovate/docker.io-pcjones-mediathekarr-beta.
What none of this shows. Renovate runs as the shared instance outside this repository, so the available evidence is a config that validates and a lookup that groups as intended locally — not the behaviour of the next real run, and not the instance's Renovate version.
The one digest pin
freesailarr's mediathekarr image is the only one pinned by digest, because its tags carry no version at all: latest is a 2024 build and the upstream README points at the moving beta. The built-in helm-values manager reads repository/tag and skips it (skipReason: invalid-value), so the two managers cannot produce competing PRs.
A digest rule needs the same verification as a versioning rule, for a sharper reason: while the pin is current, a matching rule and a broken one produce the identical empty result. It was verified with two dry-run fixtures — the real digest yields no update, a deliberately stale one yields exactly one digest update back to the pinned digest.
The pattern is anchored on the three keys in their values.yaml order (repository, tag, digest). Reordering or renaming them fails nothing — it silently stops matching, and the image then never gets an update again.
The version-bump hook
Renovate only rewrites appVersion: or an image tag; it knows nothing about version: <chart-semver>+up<app-version>. Left alone, every chart PR would fail the version check. ci/renovate-bump-chart.sh <chart-dir> [update-type] closes that gap as a postUpgradeTasks command, and since the #128 decision the chart jump follows the app jump:
| Update type | Chart jump |
|---|---|
major |
chart major (6.0.0 → 7.0.0) |
minor |
chart minor (6.0.0 → 6.1.0) — the level that used to be missing |
patch, digest, pin, pinDigest |
chart patch |
| none, or one this script does not know | the pre-#128 fallback: app major changed → major, otherwise patch |
- the
+upsuffix is recomputed from the newappVersion, dashes normalized to dots - the level is never lowered below what the branch already carries (the maximum rule, below)
The level comes from Renovate, not from comparing appVersion, and that is the decision's load-bearing half: freesailarr keeps a hand-maintained appVersion for ten upstreams, so prowlarr's 2.5.2 → 2.6.5 — the update that prompted all of this — moved an image tag in values.yaml while appVersion stood still. A comparison is blind to exactly the case the rule exists for.
Verified rather than assumed (Renovate 44.155.7, keycloak pinned one minor back). Two runs, because the obvious one cannot answer the question: --platform=local --dry-run=full classifies the update ("updateType": "minor") but never reaches the branch worker — not one Processing branch line — so the hook never runs under the local platform. Driving Renovate's own post-upgrade executor against a throwaway checkout does, and logs cmd: "ci/renovate-bump-chart.sh keycloak minor" with the script answering 1.3.0+up26.7.0 -> 1.4.0+up26.8.1 (update type minor).
Non-semver app versions stay at a patch for a minor. Seven charts: rss-bridge (2026-09-30), homeassistant (2026.8.3), searxng (2026.9.13-3b8009065), minecraft-server (2026.8.2-java25), apache-tika (3.3.1.0-full), teamspeak-server (6.0.0-beta13.1), valkey (9.1.2-alpine). In CalVer and date tags the second position is a month, so following it would mint a chart minor every month that says nothing about risk; in a four-part or suffixed version the positions are not semver positions at all. An app major is not subject to the fallback, so a year rolling over is a chart major as before. The honest edge: for 6.0.0-beta13.1 and 9.1.2-alpine the numeric part is a semver line, so the fallback understates a real minor there — the accepted trade, with a documented per-chart exception as the way out rather than a widened pattern.
Maximum, not replacement. The chart jump is the greater of the chart's own classification and the app jump. Mechanically that is also what makes a grouped branch deterministic: the hook runs once per update and recomputes from the same base commit every time, so without it the last call would win and freesailarr's minor-among-patches group would take whatever level it happened to end on.
ci/run-bump-level-test.sh holds all of this — 49 cases, each a throwaway git init with a committed base Chart.yaml and a dirty one over it, because a wrong level is a valid version string and nothing else in the pipeline reads that number. See CI/CD.
postUpgradeTasksonly runs when the command is allow-listed in the instance config (allowedCommands: ["^ci/renovate-bump-chart\\.sh"]). Without that entry Renovate skips the task without an error and every chart PR stays red on the version check.The second argument depends on that pattern's shape. Renovate matches the allow-list against the compiled command, so the front-anchored form above still matches with an argument appended (measured). A pattern anchored at the end (
…\.sh$) would stop matching and produce the same silent skip a missing entry does — and the allow-list is not readable from this repository, so only a look at the instance settles it. That is why a missing update type is a fallback and not an error: the worst case is the old classification, not red chart PRs.
On the Gitea instance that entry exists and was verified rather than hoped for: the first run on the migrated repo produced a searxng PR whose diff carries version: 1.0.4+up… → 1.0.5+up… next to the appVersion change. Nothing but this hook writes version:, so that line can only come from it. The allow-list is not visible from this repo, though — if it is ever dropped, the symptom here is red chart PRs with no explanation.
The open point recorded here is now decided (#128), and the decision is deliberately smaller than the problem. The gap was real: the automatic patch was also a classification, and the convention exempts a plain app-version bump from the wiki obligation — so an image update that quietly changed a default was the one case nothing reviewed and nothing documented. It had already happened: sonarr's 4.0.19 bump carried upstream's removal of the hard-coded trusted proxy networks and reached main unremarked — see The security default that a patch bump switched off. What was decided is the classification, not a reading duty. The variant that would have caught that incident at its origin — a chart minor or major obliges reading the upstream release notes, with the result in the PR even when it is "nothing found" — was rejected, and its price stays on the table: nobody is obliged to look behind the number, so an app minor that changes a default can still arrive unremarked. What the bigger number buys is visibility where a patch has none, since anything watching Helm release versions (Fleet's fleet.yaml in cluster-config included) sees only our chart version. Whether cluster-config actually distinguishes update types is not visible from here — that is the mechanism which would make the number act, not a claim that it does.
Constrained dependencies
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 normally. The constraint tolerates a variant suffix (1.30.4-alpine) on purpose: docker versioning only 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 — with no error and no warning.
linuxserver/qbittorrent 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. 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 guard was run: from currentValue 4.6.4 the lookup still offers 5.2.3 as a major, so the filter does not silently empty the update list. The same fixture shows the cost of leaving it alone — without the rule the identical lookup offers 20.04.1 and never mentions the real 5.2.3, since Renovate fills the major bucket only once.
PR policy
The config is aligned with the one in cluster-config, because the same jlab-renovate-bot instance serves both and two repositories behaving differently under one bot is a source of surprises rather than of safety.
- Automerge is global —
automerge: true,automergeType: "pr",platformAutomerge: true, for every update type, majors included. What keeps that from being unattended merging is the branch protection, not the absence of automerge:mainrequires a review and requiresvalidate-chartsto pass.platformAutomergearms the PR on the forge, and Gitea merges it once both are satisfied — an armed PR nobody reviews just sits there. This is exactly the condition the olderplatformAutomerge: falsecomment named as the point at whichtruebecomes the better value; it arrived, so the value changed.cluster-configrecords the same reasoning, having retired its dashboard-approval step for it. minimumReleaseAge: "3 days"forpatch,minoranddigestupdates — Renovate does not open the PR before then. A merge here is never just a merge: a chart publish follows it straight away, so a release that is yanked hours after it appeared must not reach the registry.cluster-configspends the same three days on the dependencies where a merge is expensive (authentik, rancher); here that is all of them. 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, then one for v3 — see Versioning.separateMinorPatch: truesplits minor and patch into separate PRs as well, so one red install-test does not hold up unrelated updates. It has exactly one exception,freesailarr, where it is overridden tofalse— see The one grouped chart. - No PR caps (
prConcurrentLimit: 100,prHourlyLimit: 0). The cost is known and accepted 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 instead of fanning out (see CI/CD). The alternative was updates trickling in four per hour, and nothing merges without a review anyway. config:best-practicesis inextends, as incluster-config. Two of its members matter here:helpers:pinGitHubActionDigestsmoves the workflow files fromactions/checkout@v7to SHA pins, anddocker:pinDigestsis switched off again forcustom.regexandhelm-values. AnappVersion:is a tag by construction — the templates render<repo>:{{ .Chart.AppVersion }}and the chart version's+upsuffix is built from the same string — so a digest has nowhere to go. Sidecar digests invalues.yamlcould be pinned; that is its own decision, since each digest change would be a chart patch plus a publish.- Labels: every Renovate PR carries
renovate; app-version PRs additionallyapp-version, subchart PRssubchart.
GITHUB_COM_TOKEN
The instance needs a read-only PAT as GITHUB_COM_TOKEN for github.com datasource lookups. Every github-releases/github-tags lookup and the whole built-in github-actions manager resolve against github.com; the platform token is a Gitea one and is worth nothing there, and unauthenticated lookups hit the rate limit inside a single pass.
The symptom is a Failed to look up github-releases package …: no-result line on the dependency dashboard, not an error — so it is easy to read as a broken config here rather than as missing credentials there.
Adding a chart to Renovate
A new chart needs its own customManagers block, or its app version is never updated. Check the image's tag list first: if it carries a flavour, date or prerelease suffix, add a versioningTemplate with a comment saying why. See Creating a new chart.
helm-charts wiki
How we work
How it is built
How charts are written
Charts
- apache-tika
- bentopdf
- bulwark
- calibre-web-automated
- convertx
- cyberchef
- freesailarr
- garage
- gotenberg
- homeassistant
- keycloak
- matrix-synapse
- minecraft-router
- minecraft-server
- outline
- paperless-ngx
- patchmon
- rallly
- rss-bridge
- samba
- satisfactory
- searxng
- stalwart
- sure
- teamspeak-server
- template-chart
- urlaubsverwaltung
- valkey
- voucher-vault
- ytdlp-web-player
- zipline