diff --git a/CLAUDE.md b/CLAUDE.md index 2a0be08..aaee606 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,8 +54,8 @@ Everything runs on the Gitea instance JLab-Git: issues, pull requests, CI and Re - **`tea`, not `gh`.** Work happens in a **Coder workspace, and it has no `op`** — that is the normal case here, not a broken environment, so do not wait for the 1Password CLI or propose installing it. Call `tea` directly; it carries the workspace's stored login and opens PRs and issues under the **bot account**, which is accepted. Say so in the report when something is authored that way. (`op plugin run -- tea`, the older instruction, applies only where `op` actually exists.) - **Remotes are named `github` and `JLab-Git`** — there is no `origin`. `JLab-Git/main` is the leading copy; say which remote you mean, because `git push` without one is ambiguous here. - **Branch protection with a signature requirement is active**, and signatures verify on Gitea. A commit that is not signed does not reach `main` — check before opening a PR, do not discover it at merge time. -- **No merge queue.** Gitea has none, and its place is taken by **auto-merge on green checks**: the PR is armed once and merges itself when the required checks pass. Rule 6 above is unchanged — the user merges, and an agent never merges its own PR or arms auto-merge on it. -- **A required check per chart is not possible.** Gitea runs a whole matrix as one job under one status context, so the install-test reports once for all charts. Relevant whenever branch protection is touched. +- **No merge queue.** Gitea has none, and its place is taken by **auto-merge on green checks**: the PR is armed once and merges itself when the required check passes **and the required review exists** — both are active on `main`, so an armed PR without a review sits there instead of merging. Rule 6 above is unchanged — the user merges, and an agent never merges its own PR or arms auto-merge on it. +- **A required check per chart is not possible.** Gitea runs a whole matrix as one job under one status context, so the install-test reports once for all charts. What is required on `main` is `validate-charts`, as that single context; per chart is the part that cannot be had. Relevant whenever branch protection is touched. - **Issue numbers changed with the move, and `#n` in this repo's files means the GitHub tracker.** The issues open at the time are Gitea #5–#21 and #24; the mapping is a comment on Gitea #14. Closed ones were not migrated and exist only on GitHub. Beware that Gitea silently resolves a bare `#n` against its own counter, so an old reference can render as a link to something unrelated. When you write a *new* reference, use the Gitea number. - **Renovate runs outside this repo**, as the shared `jlab-renovate-bot` instance; it reads our `renovate.json5` and its `postUpgradeTasks` hook is allow-listed there. **Do not add a Renovate workflow here** — a second runner means duplicate PRs and two schedulers rewriting each other's branches. README, "Renovate on Gitea", has the evidence. diff --git a/README.md b/README.md index 411834c..c8d5337 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Everything below was measured on this instance, not taken from documentation, an **Runner labels mean different machines than on GitHub.** `ubuntu-latest` here has **no Docker socket**; `ubuntu-docker` has one. Exactly one job cares — the install-test, because k3d is a Docker client — and its `runs-on` is therefore an expression rather than a fixed label, so the same file still works on a GitHub runner where `ubuntu-docker` does not exist. Every other job (lint/package, the quantity render, the Trivy scan) needs no socket and stays on `ubuntu-latest`. -**A matrix runs concurrently inside ONE job, under ONE status context.** This is the single most consequential difference and it has two halves. Operationally: all legs share one Docker daemon, which is what makes a per-leg resource name mandatory (see the cluster name below). For branch protection: **a required check per chart is not possible**, because there is only one context for the whole matrix — and its name carries the *unexpanded* matrix expression, e.g. `Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request)`. Required-check names have to be read off a real run; they cannot be copied from GitHub. +**A matrix runs concurrently inside ONE job, under ONE status context.** This is the single most consequential difference and it has two halves. Operationally: all legs share one Docker daemon, which is what makes a per-leg resource name mandatory (see the cluster name below). For branch protection: **a required check per chart is not possible**, because there is only one context for the whole matrix — and its name carries the *unexpanded* matrix expression, e.g. `Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request)`. Required-check names have to be read off a real run; they cannot be copied from GitHub. What *is* required on `main` today is **`validate-charts`**, as the single context it reports under, alongside a **review**: per chart is the part that is impossible, not required checks as such. Both are what makes Renovate's `platformAutomerge: true` safe — see "PR policy". **Expected runtimes**, so that a later deviation is visible as one: @@ -159,7 +159,7 @@ What Renovate keeps current: Each chart renders its main image as `:{{ .Chart.AppVersion }}` — repository in the template, version in `Chart.yaml`. Renovate cannot join two files, so the mapping lives in `renovate.json5`, one block per chart. 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. Charts whose tags carry a flavour or date suffix (`apache-tika`, `minecraft-server`, `rss-bridge`, `searxng`, `teamspeak-server`) override `versioning` in their block; the reasoning sits next to each one. -One rule those overrides share, learned from `searxng` and worth knowing before writing the next one: **a capture group is a claim about how the value is compared.** Renovate's regex versioning parses `major`, `minor`, `patch`, `build` and `revision` with `Number.parseInt` (`modules/versioning/regex/index.js`, `_parse`), 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, unattended under the patch/minor automerge rule. Parts of a tag that carry no ordering belong **outside** every 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`. `freesailarr` has no **app-version** block on purpose — see the images exception above; a block would either duplicate the `helm-values` updates or rewrite an `appVersion` that selects no image. It does carry one `customManagers` block, for `mediathekarr`: that image is pinned by digest because its tags carry no version (`latest` is a 2024 build, `beta` moves), and the built-in `helm-values` manager reads only `repository`/`tag` and skips it (`skipReason: invalid-value`), so the two cannot collide. Digest rules need the same guard as versioning rules and for a sharper reason: while the pin is current, a matching rule and a broken one produce the identical empty result. 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. +One rule those overrides share, learned from `searxng` and worth knowing before writing the next one: **a capture group is a claim about how the value is compared.** Renovate's regex versioning parses `major`, `minor`, `patch`, `build` and `revision` with `Number.parseInt` (`modules/versioning/regex/index.js`, `_parse`), 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 wearing the clothes of a routine date bump, which is exactly what a review waves through and automerge then carries to `main`. Parts of a tag that carry no ordering belong **outside** every 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`. `freesailarr` has no **app-version** block on purpose — see the images exception above; a block would either duplicate the `helm-values` updates or rewrite an `appVersion` that selects no image. It does carry one `customManagers` block, for `mediathekarr`: that image is pinned by digest because its tags carry no version (`latest` is a 2024 build, `beta` moves), and the built-in `helm-values` manager reads only `repository`/`tag` and skips it (`skipReason: invalid-value`), so the two cannot collide. Digest rules need the same guard as versioning rules and for a sharper reason: while the pin is current, a matching rule and a broken one produce the identical empty result. 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. 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. @@ -202,9 +202,13 @@ One item is easy to miss and is named in "The version-bump hook" above: the inst ### PR policy -- **Automerge** for `patch`, `minor` and `digest` updates, after a `minimumReleaseAge` of 3 days and only once the PR pipeline is green — install-test included, which is why that job no longer runs with `continue-on-error`. Minor is in scope because the sidecar images barely move in patch steps (`alpine 3.22` → `3.24`, `nginx 1.30.4` → `1.31.4` are both minor), so a patch-only rule would practically never fire. Renovate merges itself (`platformAutomerge: false`) rather than handing the PR to the forge's own auto-merge, which on **both** GitHub and Gitea would merge immediately as long as `main` has no required status checks. That is a statement about the branch protection, not about the platform: once required checks exist on `main`, `platformAutomerge: true` becomes the better setting on either side. Majors are merged by hand. -- **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. -- `prConcurrentLimit: 10` / `prHourlyLimit: 4` keep the k3d install-tests from all starting at once. +The config is aligned with the one in `cluster-config`, because the same `jlab-renovate-bot` instance serves both and two repos that behave differently under one bot is a source of surprises rather than of safety. + +- **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. +- **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. ## Creating a new chart diff --git a/renovate.json5 b/renovate.json5 index 45a41f5..a752be2 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -16,15 +16,45 @@ // Commit subjects in this repo are plain sentences, not conventional // commits. Renovate's own merged PRs already look like that. ":semanticCommitsDisabled", + // Same preset as cluster-config, so both repos behave identically on the + // shared jlab-renovate-bot instance. Two of the things it pulls in are + // load-bearing here: `helpers:pinGitHubActionDigests` (wanted — the + // workflow files move from `actions/checkout@v7` to SHA pins) and + // `docker:pinDigests`, which is turned off again for the two managers that + // cannot carry a digest — see the pinDigests rule below. The + // npm-flavoured members (`:pinDevDependencies`, + // `security:minimumReleaseAgeNpm`, `:maintainLockFilesWeekly`) are inert + // in a repo without a package manager. + "config:best-practices", ], timezone: "Europe/Berlin", - labels: ["dependencies"], + labels: ["renovate"], + dependencyDashboard: true, - // 21 charts plus the workflows: without a cap the first run after a quiet - // week opens a wall of PRs, and every chart PR spins up a k3d install-test. - prConcurrentLimit: 10, - prHourlyLimit: 4, + // No caps, as in cluster-config. The first run after a quiet week can open + // many PRs at once and every chart PR starts a k3d install-test; on Gitea a + // whole matrix runs inside one job on one runner, so those queue instead of + // fanning out. That queue is the accepted price for not having updates + // trickle in four per hour — nothing merges without a review anyway. + prConcurrentLimit: 100, + prHourlyLimit: 0, + + // Minor and patch arrive as separate PRs rather than one combined branch. + // With 23 charts a shared branch means one red install-test blocks unrelated + // updates, and the separation is what lets a patch be reviewed in seconds. + separateMinorPatch: true, + + // Every update automerges once the PR is mergeable, majors included, and + // `platformAutomerge` hands that decision to the forge instead of letting + // Renovate poll: Gitea arms the PR and merges it when the required check + // (`validate-charts`) passes AND the required review exists. Both are active + // on `main`, which is why this is not unattended merging — the review is the + // gate, and without it the armed PR simply sits there. Same reasoning as + // cluster-config, where the dashboard approval was retired for it. + automerge: true, + automergeType: "pr", + platformAutomerge: true, // template-chart is the scaffold. Its image is the literal placeholder // ghcr.io/ORG/IMAGE and its appVersion is "xxx" — nothing to update, and the @@ -80,26 +110,43 @@ }, { description: [ - "Patch, minor and digest updates merge themselves once the PR pipeline is green.", - "Minor is included because the sidecar images barely move in patch steps —", - "alpine 3.22 -> 3.24 and nginx 1.30.4 -> 1.31.4 are both minor, so a patch-only", - "rule would practically never fire. Majors stay manual.", + "Routine updates wait three days before Renovate even opens the PR. Automerge is", + "global now (top of this file), so this rule no longer decides WHAT merges by itself —", + "it decides how fresh a release may be when it does. A merge here is never just a", + "merge: every chart change publishes a chart straight away, and a release that is", + "yanked hours after it appeared should not reach the registry at all.", + "cluster-config spends the same three days on the dependencies where a merge is", + "expensive (authentik, rancher); here that is every one of them.", + "", + "Majors deliberately carry no age gate: they are not the updates that slip through", + "unnoticed, they are the ones held at the review, and separateMultipleMajor makes", + "them arrive one line at a time anyway.", ], matchUpdateTypes: ["patch", "minor", "digest"], - automerge: true, - automergeType: "pr", - // Renovate waits for the checks itself instead of handing the PR to the - // forge's own auto-merge. Both of them — GitHub's, and the auto-merge - // that takes the merge queue's place on Gitea, which has none — merge - // immediately when the branch has no required status checks configured, - // and validate-charts is not (yet) a required check on either side. This - // is a statement about the branch protection, not about the platform: - // once required checks exist on `main`, true is the better value. - platformAutomerge: false, - // A release that is hours old should not walk into main unattended; a - // chart publish follows the merge straight away. minimumReleaseAge: "3 days", }, + { + description: [ + "`config:best-practices` brings `docker:pinDigests`, and two of this repo's managers", + "have nowhere to put a digest. custom.regex reads `appVersion:` out of a Chart.yaml,", + "and that value is a TAG by construction: the templates render", + "`:{{ .Chart.AppVersion }}`, and the chart version's `+up` suffix is", + "built from the same string — an `@sha256:...` fits into neither. cluster-config makes", + "the identical opt-out for its fleet manager, for the identical reason.", + "", + "helm-values is opted out too, but as a decision rather than a necessity: sidecar", + "images there COULD be pinned by digest, and every digest change would then be a chart", + "patch plus a publish. Whether that is worth it is its own question, not part of this", + "alignment.", + "", + "Unaffected: the freesailarr/mediathekarr block below, which extracts a digest that is", + "written in values.yaml on purpose (pinDigests only ADDS digests where none exists),", + "and the GitHub Actions pinning from `helpers:pinGitHubActionDigests` — a different", + "datasource, and wanted.", + ], + matchManagers: ["custom.regex", "helm-values"], + pinDigests: false, + }, { description: [ "Chart app versions. Kept out of the automerge grouping decisions above only by label,", @@ -121,10 +168,10 @@ "This rule adds only a label. It exists because the update matters more here than", "its size suggests: a subchart is invisible to everything downstream that watches", "Helm release versions, so this PR is the ONLY way a new proxy version - a security", - "fix included - ever reaches a cluster. Under the automerge rule above, patch and", - "minor updates do that on their own once the pipeline is green, and", - "ci/renovate-bump-chart.sh bumps the chart patch that publishes it (appVersion is", - "unchanged, so it takes the patch branch). A major arrives as a PR to merge by hand.", + "fix included - ever reaches a cluster. Automerge carries that PR to main once it is", + "reviewed and the check is green, and ci/renovate-bump-chart.sh bumps the chart patch", + "that publishes it (appVersion is unchanged, so it takes the patch branch). A major", + "goes the same way - it is the review, not a separate policy, that gives it a look.", ], matchManagers: ["helmv3"], addLabels: ["subchart"], @@ -230,7 +277,8 @@ "points at the moving 'beta'. The built-in helm-values manager reads repository/tag and", "would have nothing to compare, so this manager captures all three keys and lets Renovate", "propose digest updates for the beta tag (currentDigest + currentValue = a digest-only", - "lookup; automerged under the 'digest' update type in the rule above).", + "lookup; it arrives as a 'digest' update, which the three-day age gate above covers and", + "automerge then carries once the PR is reviewed and green).", "", "The pattern is anchored on the three keys in their values.yaml order: repository, tag,", "digest. Reordering or renaming them does not fail anything — it silently stops matching,", @@ -407,8 +455,9 @@ // isGreaterThan("2026.8.22-777ba8fa4", current) -> false, equals -> true // 777ba8fa4 was pushed at 03:06 and 9fea41204 at 05:38 the SAME day, so // the build group makes Renovate offer the older image as an update - - // a downgrade, unattended, because date updates land in the patch/minor - // automerge rule. With NaN the pair is neither equal nor ordered at all. + // a downgrade, and one that automerge would carry to main as soon as the + // PR is waved through as the routine date bump it looks like. With NaN + // the pair is neither equal nor ordered at all. // // Uncaptured, the hash still has to be THERE (the pattern is anchored), // it just takes no part in the comparison: two builds of one day are