diff --git a/.github/workflows/validate-charts.yml b/.github/workflows/validate-charts.yml index 591b777..32d8f8b 100644 --- a/.github/workflows/validate-charts.yml +++ b/.github/workflows/validate-charts.yml @@ -237,6 +237,32 @@ jobs: - name: "Check every rendered HTTPRoute against the rendered Services" run: ci/run-httproute-match-test.sh + bump-level: + name: "Bump level" + runs-on: ubuntu-latest + # The fourth job that ignores the changed-chart matrix, and the only one + # that touches no chart at all: it tests ci/renovate-bump-chart.sh, the + # postUpgradeTasks hook that computes a chart's `version:` after Renovate + # moved an app version (Gitea #128). + # + # Why it is a job rather than a review: since #128 the hook takes the bump + # level from Renovate's update type, so an app minor becomes a chart minor. + # That makes it deciding logic, and its failure mode is silence - a wrong + # level is a VALID version string, so the PR goes green, the chart + # publishes, and the only symptom is a number that understates what it + # ships. Nothing else in this pipeline reads that number. + # + # It needs no Helm, no cluster and no Python: each case is a throwaway + # `git init` under mktemp with one commit plus a dirty Chart.yaml - the + # state the hook really runs in, since it reads the base commit - and the + # assertion is one `version:` line read back with sed. About a second for + # all 49 cases, and it depends on no other job. + steps: + - name: "Checkout Repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: "Check every bump level of the version-bump hook" + run: ci/run-bump-level-test.sh + trivy-config: name: "Trivy config ${{ matrix.chart }}" needs: detect-changed-charts diff --git a/CLAUDE.md b/CLAUDE.md index 9032277..c7ce967 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ Flat layout, one Markdown file per page, page name = file name, links are relati | a Renovate rule added or changed | `Renovate` | | something broke in a way a rule now prevents | `Lessons-Learned` | -**What does not oblige an edit:** a plain app-version bump. Renovate automerges those continuously and never touches the wiki, which is why chart pages carry no version numbers at all and name `Chart.yaml` as the source of truth. An app update that *does* change values or behaviour is not a plain bump, and the table applies. +**What does not oblige an edit:** an app-version bump, at any level. Renovate automerges those continuously and never touches the wiki, which is why chart pages carry no version numbers at all and name `Chart.yaml` as the source of truth. Since Gitea #128 **"plain" is mechanically defined instead of being a judgement**: the chart version now states the size of the jump, because `ci/renovate-bump-chart.sh` takes that size from Renovate's update type (app minor → chart minor). An app update that *does* change values or behaviour is not a plain bump and the table applies — but **nothing obliges anyone to go and find out**. That is the deliberate limit of #128, not an oversight: the reading duty was rejected, so the `sonarr` incident in `Lessons-Learned` stays repeatable and a bigger number is the only warning that is given. Wiki commits follow the same rules as repo commits: no `Co-Authored-By`, no "Generated with Claude Code" footer. @@ -94,11 +94,13 @@ Copy `template-chart/` and replace every `xxx` placeholder — in file names (`t `Chart.yaml` versions follow `version: +up` (e.g. `1.4.0+up1.9.2`), with `appVersion` matching the suffix (dashes in the app version are normalized to dots in the suffix). Any push to `main` touching a chart directory publishes that chart, so the version must be bumped in the same change. -Semver conventions for the chart part: +Semver conventions for the chart part. **The chart jump is the greater of the chart's own classification and the jump of what it ships** (Gitea #128) — a maximum, never a replacement, so a value that becomes required stays a major even under an app patch: -- **Patch**: bug fixes without behavior or values-schema change; plain app-version updates. -- **Minor**: additive, backward-compatible changes (new optional values with unchanged defaults). -- **Major**: runtime behavior changes or breaking values changes (e.g. probes, security hardening, computed limits, values that become required). +- **Patch**: bug fixes without behavior or values-schema change; an app **patch** or digest update. +- **Minor**: additive, backward-compatible changes (new optional values with unchanged defaults); an app **minor** update. +- **Major**: runtime behavior changes or breaking values changes (e.g. probes, security hardening, computed limits, values that become required); an app **major** update. + +The app half of that is mechanical: `ci/renovate-bump-chart.sh` takes the level from **Renovate's update type**, passed in as `{{{updateType}}}`, and not from comparing `appVersion` — `freesailarr` pins every tag in `values.yaml` and its `appVersion` never moves, so a comparison is blind to exactly the case the rule exists for. Non-semver app versions (CalVer, date tags, four-part and suffixed tags — seven charts) stay at **patch** for a minor, because their second position is a month or a flavour and says nothing about the jump; an app **major** still produces a chart major. **It obliges nobody to read the release notes** — that variant was rejected in #128 — so an app minor that changes a default can still reach `main` unremarked; the number stops understating, that is all it does. See the wiki, `Lessons-Learned`. App-version updates go through PRs (so the validate and install-test pipelines run), not through the `change-app-version.yml` dispatch workflow (which commits directly to main and bypasses both). For app **major** jumps, update stepwise: first to the last release of the old major line (own PR, merge, publish), then to the newest major. @@ -117,4 +119,4 @@ The binding style guide for all current and future charts lives in **README.md ( - Resources: requests as values defaults; limits auto-computed by the `_helpers.tpl` helper (`limitFactor` default 3; no CPU limits unless set explicitly). - Replicas per workload (explicit `0` allowed) plus a chart-global `scaleDown` flag that overrides them. - `icon:` mandatory in every `Chart.yaml`; `helm lint --strict` without findings; CI fixtures under `ci//` or a justified exclusion. -- **Chart dependencies are admissible but expensive** (the former "no dependencies" rule fell with #214; `ytdlp-web-player` was the first to use one, `stalwart` the second, `paperless-ngx` the third). A dependency has to be optional and off by default via `condition:`, and upstream's chart is consumed as it comes — where its defaults fall short of these conventions, that is **documented in our `values.yaml`, never patched**. The price to know before adding the next one: a subchart is invisible to everything downstream that watches Helm release versions, so a new upstream version — a security fix included — reaches a cluster only when the outer chart is republished (Renovate's built-in `helmv3` manager plus `ci/renovate-bump-chart.sh` is the whole safety net, and it belongs in a comment at the pinned version); and every helm command exits 1 while `charts/` is unfilled, which is why `ci/helm-dependencies.sh` runs before lint, package, render and install — including the render test that covers **all** charts on **every** PR. Full rules and the rest of the cost: README, "Chart dependencies". +- **Chart dependencies are admissible but expensive** (the former "no dependencies" rule fell with #214; `ytdlp-web-player` was the first to use one, `stalwart` the second, `paperless-ngx` the third). A dependency has to be optional and off by default via `condition:`, and upstream's chart is consumed as it comes — where its defaults fall short of these conventions, that is **documented in our `values.yaml`, never patched**. The price to know before adding the next one: a subchart is invisible to everything downstream that watches Helm release versions, so a new upstream version — a security fix included — reaches a cluster only when the outer chart is republished (Renovate's built-in `helmv3` manager plus `ci/renovate-bump-chart.sh` is the whole safety net, and since #128 that republish carries the **subchart's own level** — an upstream minor is a chart minor, which is the only place that jump becomes visible at all; it belongs in a comment at the pinned version); and every helm command exits 1 while `charts/` is unfilled, which is why `ci/helm-dependencies.sh` runs before lint, package, render and install — including the render test that covers **all** charts on **every** PR. Full rules and the rest of the cost: README, "Chart dependencies". diff --git a/README.md b/README.md index 718f961..79cfaf2 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h Note: the registry **accepts a re-upload of an already published version** (HTTP 201) and replaces the stored artifact, so a republish of an existing version is still silent — see #160. That is also what makes a manual `workflow_dispatch` of any `publish-.yml` a harmless way to prove the shared block works. - `change-app-version.yml` — manually dispatched app-version bump; the chart must be listed in its `chart-name` choice list. - `ci/helm-dependencies.sh ` — not a workflow but the step they all share: fetches a chart's declared dependencies into `charts/`, and returns silently for the charts that declare none (all but three: `ytdlp-web-player`, `stalwart`, `paperless-ngx`). It runs in **seven** places, because every helm command this repo builds on exits 1 while a declared dependency is missing: before lint and package in `validate-charts.yml`, before package in the shared publish block (which is why that block names this script as a prerequisite of its callers — see above), and inside `ci/run-install-test.sh`, `ci/run-quantity-render-test.sh`, `ci/run-selector-match-test.sh`, `ci/run-httproute-match-test.sh` and `ci/run-trivy-config.sh`. See "Chart dependencies" in the style guidelines for what that costs. -- `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix. After the build stage, an **install-test** job (the same changed-charts matrix, minus the charts listed in `ci/excluded-charts.txt`) installs each changed chart into an ephemeral [k3d](https://k3d.io) cluster on the runner and requires every workload to become Ready — see below. Both matrices follow the changed chart directories, so a PR that only changes the harness under `ci/` exercises nothing by itself; the Trivy scan has a fixed-sample fallback for that case, the install-test has none — bump the affected chart's patch version to pull it into the matrix, or run the all-charts install-test after the merge. The **resource-quantity render test**, the **selector-match render test** and the **HTTPRoute render test** are the three exceptions to that rule: all of them run over every chart on every PR — see below. +- `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix. After the build stage, an **install-test** job (the same changed-charts matrix, minus the charts listed in `ci/excluded-charts.txt`) installs each changed chart into an ephemeral [k3d](https://k3d.io) cluster on the runner and requires every workload to become Ready — see below. Both matrices follow the changed chart directories, so a PR that only changes the harness under `ci/` exercises nothing by itself; the Trivy scan has a fixed-sample fallback for that case, the install-test has none — bump the affected chart's patch version to pull it into the matrix, or run the all-charts install-test after the merge. The **resource-quantity render test**, the **selector-match render test** and the **HTTPRoute render test** are the three exceptions to that rule: all of them run over every chart on every PR — see below. A fourth job, the **bump-level test**, ignores the matrix for a different reason: it tests no chart at all, but the `postUpgradeTasks` hook that computes their versions. - `install-test.yml` — reusable workflow (`workflow_call`) holding the install-test **job** itself: the runner-label expression, the pinned `kubectl` with its skew check, `envsubst`, the containerised-job detection, the per-leg k3d cluster and the `freesailarr` VPN credentials. Its one input is a JSON array of chart names; its callers decide *which* charts are tested and whether the job runs at all, never *how*. Two callers: `validate-charts.yml` and `install-test-all-charts.yml`. **It is not copied for the second caller** — a second copy is a second chance to test differently, unnoticed, the same argument that gives `ci/excluded-charts.txt` exactly one parser. **It stays in this repository, and that is a decision, not an oversight** (Gitea #66, decision 4). It is reusable in form but not in substance: it is welded to the `ci/` fixtures, to the `ci/excluded-charts.txt` parser and to this repository's chart matrix. A building block that only one repository can use is not shared, it is merely moved somewhere else — and moving it would buy nothing while putting a tag between us and a file we change often. `publish-helm-chart.yml` was the opposite case: it takes a chart name and a registry owner and knows nothing about this repository, which is exactly why it could go. This note exists so the question is not asked again in half a year. @@ -187,6 +187,18 @@ Measured in CI (run 719, the pilot): **30 charts, 4 assertions held**, whole job The fail path was exercised rather than assumed: a Service renamed in the route, a port the Service does not expose, a port left out entirely, an emptied `parentRefs` with the chart's own guard disabled, an overlay that renders no route, a run with zero assertions, and — for the pairing — both the redirect rule deleted outright and the redirect pointed at a path other than the one stripped; each produced a `FAIL` naming both sides and a non-zero exit. +### Bump-level test (the version-bump hook, every PR) + +`ci/run-bump-level-test.sh` (no arguments) holds every case of `ci/renovate-bump-chart.sh` against the version string it writes. Added with Gitea #128, which is what made it necessary: until then the hook was a calculator with one branch, and with the update type coming from Renovate it became the thing that *says how big a change a cluster is being offered* — while the chart version is the only signal anything downstream sees (see "Chart dependencies": Fleet reads our chart version and nothing inside the package). + +Its failure mode is the silent kind this section's two rules are about: **a wrong level is a valid version string.** The PR goes green, the chart publishes, and the only symptom is a number that understates what it ships. Nothing in the pipeline reads that number, so nothing but a test can. + +A case is not a function call, because the script reads the **base commit** (`git show HEAD:/Chart.yaml`) *and* the working tree: each of the 49 cases gets a throwaway `git init` under `mktemp -d`, one commit carrying the base `Chart.yaml`, and the new one written over it uncommitted — the state Renovate's `postUpgradeTasks` hook actually runs in. Signing is switched off per command, since this workspace signs through an SSH signer and a throwaway repo has no business invoking it. No Helm, no cluster, no `pyyaml`, no `yq`, no Docker: the fixtures are six-line `Chart.yaml` files written with `printf`, and the assertion is one `version:` line read back with `sed`. The job therefore carries no Helm step and takes about a second. + +What the cases cover: each update type, the `freesailarr` shape (an app minor while `appVersion` stands still), all seven non-semver app versions plus the majors that must *not* fall back, the maximum rule in both directions including a stale branch version that must **not** be kept, a missing and an empty and an unknown second argument, and the inputs the script must leave alone — a chart new on the branch, `template-chart`, a `.github/` or `ci/` argument, no argument at all. The full `version:` line is asserted in every case, so the `+up` suffix and its dash-to-dot normalization are covered throughout. + +**What it cannot assert, and does not pretend to:** that Renovate passes the update type at all. The hook runs only when the command is allow-listed on the shared instance, which is outside this repository — which is why a *missing* second argument is itself a case here, and why the change that introduced it carries a dry run instead of a claim (see "The version-bump hook"). + ### Install-test (PR pipeline stage 2) For every chart a PR changes, `ci/run-install-test.sh ` runs against a throwaway k3d cluster: it applies the stub `OnePasswordItem` CRD plus the chart's fixtures, `helm install`s the chart with its CI values, and waits for all Deployments/StatefulSets to roll out (per-chart timeout). Since probes and security hardening are active on all workloads, "Ready" means the app actually answers. On failure the job dumps pod descriptions, events and container logs. @@ -267,9 +279,9 @@ Each chart renders its main image as `:{{ .Chart.AppVersion }}` — reposi 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. +A consequence of pinning every tag in `values.yaml`, and the measurement that forced the change in Gitea #128: `ci/renovate-bump-chart.sh` used to see an unchanged `appVersion` and bump the chart **patch** — for all ten images alike, the digest-pinned one included. `appVersion` here is the chart's own marker rather than a tracked upstream version, so a comparison had nothing to read: prowlarr's `2.5.2 → 2.6.5` was an upstream **minor** that arrived as a chart patch. Since #128 the level comes from Renovate's classification instead (`{{{updateType}}}`, the hook's second argument), so a minor among the ten makes a chart minor and a mixed group takes the highest level in it. The `+up` suffix still follows `appVersion` and therefore still stands still. -**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. +**That open point is now decided (Gitea #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 where nothing reviewed it and nothing documented it. It had already happened: the `sonarr` 4.0.19 bump carried upstream's removal of the hard-coded trusted proxy networks and reached `main` unremarked (see [Lessons learned](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/Lessons-Learned)). What was decided is **the classification and not a reading duty**: the chart jump follows the app jump, so the number stops understating what it ships. 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 stated in the PR even when it is "nothing found"* — was **rejected**, and its price is the part that stays: nobody is obliged to look behind the number, so an app minor that changes a default can still reach `main` unremarked. What the bigger number does buy is visibility where a patch has none — Fleet's `fleet.yaml` in `cluster-config` sees only our chart version (see "Chart dependencies"), so a minor is a number other gates can act on. Whether `cluster-config` actually distinguishes update types is **not** visible from this repository; that is the mechanism which makes the number effective, not a claim that it is in use. 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. @@ -296,13 +308,22 @@ Not managed: `ci/**` (install-test fixtures pin throwaway infrastructure to majo ### The version-bump hook -Renovate only rewrites `appVersion:` or an image tag; it knows nothing about `version: +up`. Left alone, every chart PR would fail the `build-chart` version check. `ci/renovate-bump-chart.sh ` closes that gap and is wired up as a `postUpgradeTasks` command: +Renovate only rewrites `appVersion:` or an image tag; it knows nothing about `version: +up`. Left alone, every chart PR would fail the `build-chart` version check. `ci/renovate-bump-chart.sh [update-type]` closes that gap and is wired up as a `postUpgradeTasks` command. **Since Gitea #128 the chart jump follows the app jump**, which turned the script from a calculator into deciding logic: -- **app major changed** → chart major (`6.0.0` → `7.0.0`), matching "runtime behavior changes" in the semver conventions -- **anything else** (plain app update, sidecar image) → chart patch +- **update type `major`** → chart major (`6.0.0` → `7.0.0`), matching "runtime behavior changes" in the semver conventions +- **update type `minor`** → chart **minor** (`6.0.0` → `6.1.0`) — the part that was missing, and where the incident sat +- **`patch`, `digest`, `pin`, `pinDigest`** → chart patch +- **no or unknown update type** → the pre-#128 behaviour: app major changed → chart major, anything else → chart patch +- **never below what the branch already carries** — the maximum rule below - the `+up` suffix is recomputed from the new `appVersion`, dashes normalized to dots -Everything is derived from the base commit, so the script is idempotent across the multiple updates a single Renovate branch can carry. +**The level comes from Renovate, not from `appVersion`,** and that is the whole point: `freesailarr` keeps a hand-maintained `appVersion` for ten upstreams, so the update that prompted the rule moved an image tag in `values.yaml` while `appVersion` stood still (see "The one grouped chart" above). Verified rather than assumed — Renovate 44.155.7, keycloak pinned one minor back: `--platform=local --dry-run=full` classifies the update (`"updateType": "minor"`) but **never reaches the branch worker**, so it cannot show the hook at all; 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 patch for a minor**, with the reason in the convention rather than in a footnote: 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 the risk, and in a four-part or suffixed version the positions are not semver positions at all. Seven charts on `main` are in that class — `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`) — and an app **major** is not subject to it, so a year rolling over in a date tag is a chart major exactly as it was 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. That is the accepted trade — conservative by default; a chart that wants the minor earns a documented exception rather than a widened pattern. + +**Maximum, not replacement.** The chart jump is the *greater* of the chart's own classification and the app jump, so a value that becomes required stays a chart major under an app patch. Mechanically it is also what makes a grouped branch deterministic: the hook runs once per update and every call recomputes from the same base commit, so without the maximum the last call would win and `freesailarr`'s minor-among-patches group would take whatever level it happened to end on. The script only ever keeps a *higher* version than it computes, which is why repeated runs stay idempotent, and everything else is derived from the base commit for the same reason. + +`ci/run-bump-level-test.sh` holds every one of those cases (49 of them) against the version string the script writes — a wrong level is a valid version string, so the PR would go green and the only symptom would be a number that understates what it publishes. > **One-time setup outside this repo:** `postUpgradeTasks` only runs on self-hosted Renovate **and** only when the command is allow-listed in the *instance* config: > @@ -311,6 +332,8 @@ Everything is derived from the base commit, so the script is idempotent across t > ``` > > 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, and the shape is not visible from here.** Renovate matches the allow-list against the **compiled** command (`allowedCommands.some((p) => regEx(p).test(compiledCmd))`), so the front-anchored form above still matches with an argument appended — measured, see the dry run noted in `renovate.json5`. A pattern anchored at the end on the instance (`…\.sh$`) would stop matching and produce the same silent skip a missing entry does. Only the maintainer can check that at the instance. It is why the script treats a **missing** update type as the pre-#128 behaviour instead of an error: the worst case of a narrower allow-list, an older Renovate or an unpopulated template field is the old classification, not red chart PRs. If the dependency dashboard reports `Failed to look up github-releases package …: no-result` for third-party repos, that is the instance's github.com credentials, not this config. The instance needs a read-only PAT as `GITHUB_COM_TOKEN` for github.com datasource lookups; its own platform token is a Gitea token and is worth nothing there. @@ -371,10 +394,11 @@ These guidelines are **binding for every current and future chart** in this repo ### 2. Versioning - `Chart.yaml` uses `version: +up`; the `+up` suffix matches `appVersion`, with dashes normalized to dots (e.g. `0.0.36-beta` → `+up0.0.36.beta`). `appVersion` is the **exact image tag** of the main app image. -- Semver rules for the chart part: - - **Patch**: bug fixes without behavior or values-schema change; plain app-version updates. - - **Minor**: additive, backward-compatible changes (new optional values with unchanged defaults). - - **Major**: runtime behavior changes or breaking values changes (e.g. resource renames, probes, security hardening, values that become required). +- Semver rules for the chart part. **The chart jump is the greater of the chart's own classification and the jump of what it ships** (Gitea #128) — a maximum, never a replacement, so a value that becomes required is a chart major even under an app patch: + - **Patch**: bug fixes without behavior or values-schema change; an app **patch** or digest update. + - **Minor**: additive, backward-compatible changes (new optional values with unchanged defaults); an app **minor** update. + - **Major**: runtime behavior changes or breaking values changes (e.g. resource renames, probes, security hardening, values that become required); an app **major** update. +- The app half of that is mechanical and belongs to the hook rather than to a reviewer's judgement: `ci/renovate-bump-chart.sh` takes the level from Renovate's update type, with non-semver app versions staying at patch for a minor and a missing update type falling back to the old behaviour. The full rule, the seven affected charts and what it deliberately does **not** oblige anybody to read are in "The version-bump hook" above. - Any push to `main` touching a chart directory publishes that chart, so the version must be bumped in the same change. ### 3. Images @@ -584,7 +608,7 @@ One more trap, and it is the reason `stalwart` gates its subchart on `valkeySubc **What it costs, all of it visible before the next one is added:** -- **The subchart is invisible to everything downstream.** Its version is inside our packaged `.tgz`, so anything that watches Helm release versions — Fleet's `fleet.yaml` in `cluster-config` included — sees only *our* chart version. A new upstream release, security fix included, reaches a cluster **only** when we publish a new version of the outer chart. Nothing reports the gap. Renovate's built-in `helmv3` manager opens the PR (no `customManagers` entry needed — the repository URL and version sit together in one file) and `ci/renovate-bump-chart.sh` bumps the chart patch that ships it; that chain is the entire safety net. State this in a comment at the pinned version, not only in a review. +- **The subchart is invisible to everything downstream.** Its version is inside our packaged `.tgz`, so anything that watches Helm release versions — Fleet's `fleet.yaml` in `cluster-config` included — sees only *our* chart version. A new upstream release, security fix included, reaches a cluster **only** when we publish a new version of the outer chart. Nothing reports the gap. Renovate's built-in `helmv3` manager opens the PR (no `customManagers` entry needed — the repository URL and version sit together in one file) and `ci/renovate-bump-chart.sh` bumps the chart version that ships it — since Gitea #128 at the **subchart's own level**, so an upstream minor is a chart minor even though our `appVersion` did not move. That chain is the entire safety net, and the number it writes is the only place the subchart's jump is visible at all. State this in a comment at the pinned version, not only in a review. - **Every pipeline that touches the chart needs a network fetch first.** `helm lint`, `helm package`, `helm template` and `helm install` all exit 1 while a declared dependency is missing from `charts/`, so `ci/helm-dependencies.sh` runs before each of them (see CI/CD above). One of those call sites is the resource-quantity render test, which runs over **every** chart on **every** PR — a dependency-carrying chart can therefore turn a third-party Helm repository's bad day into a red pipeline on a PR that touched nothing related. - **`charts/*.tgz` and `Chart.lock` are not committed** (`.gitignore` says why): the archive is a third-party binary nobody reviews in a diff, and a committed lock would arrive stale because Renovate rewrites `Chart.yaml` and cannot regenerate it — which is also why the pipelines run `helm dependency update` and not `helm dependency build`. - **The repo's own mechanisms stop at the subchart boundary.** `checksum/config`, the 1Password auto-restart annotation and the `scaleDown` flag reach our templates only. A chart that renders a `OnePasswordItem` for its subchart documents the manual `kubectl rollout restart` that replaces the missing restart-on-change. diff --git a/ci/renovate-bump-chart.sh b/ci/renovate-bump-chart.sh index d29e81d..066338f 100755 --- a/ci/renovate-bump-chart.sh +++ b/ci/renovate-bump-chart.sh @@ -11,23 +11,56 @@ # failing the `build-chart` version-bump check. This script closes that gap and # is wired up as a `postUpgradeTasks` command in renovate.json5. # -# Usage: ci/renovate-bump-chart.sh +# Usage: ci/renovate-bump-chart.sh [update-type] # -# Bump level (README "Versioning"): -# - app major changed -> chart major, minor/patch reset to 0 -# - anything else -> chart patch (plain app updates, sidecar images) +# Bump level (README "Versioning", decided in Gitea #128): +# - update type major -> chart major, minor/patch reset to 0 +# - update type minor -> chart MINOR, patch reset to 0 +# - patch / digest / pin -> chart patch +# - no or unknown update type -> the appVersion comparison below, i.e. the +# behaviour this script had before #128 +# - and never BELOW what the branch already carries (see the maximum rule +# further down) +# +# THE UPDATE TYPE COMES FROM RENOVATE, AND THAT IS THE POINT (Gitea #128). +# Renovate has already classified the jump; passing `{{{updateType}}}` in is +# what makes a chart minor possible at all. Deriving it here from appVersion +# would be blind to exactly the case that prompted the rule: `freesailarr` +# carries a hand-maintained `appVersion: "1.0.0"` for ten upstreams and it never +# moves, so prowlarr's 2.5.2 -> 2.6.5 — an image tag in values.yaml — is a +# minor with an unchanged appVersion. README, "Dependency updates (Renovate)". +# +# WHAT A CHART MINOR BUYS, AND WHAT IT DOES NOT. It makes the number honest: an +# app minor stops arriving as a chart patch. It does NOT oblige anyone to read +# the release notes — that variant was decided against — so an app minor that +# changes a default can still reach `main` unremarked. What the number does is +# make the jump VISIBLE to everything downstream that sees only our chart +# version (Fleet's `fleet.yaml` in `cluster-config` among them), where a patch +# is invisible. See the wiki, Lessons-Learned. +# +# THE SECOND ARGUMENT IS OPTIONAL ON PURPOSE. `postUpgradeTasks` only runs when +# the command is allow-listed in the *instance* config, which is not visible +# from this repository, and a command that does not match the allow-list is +# skipped SILENTLY. So a missing update type — a narrower allow-list pattern, an +# older Renovate, a template field that is not populated — must not be a +# failure: without it the script falls back to what it did before #128 and every +# chart PR still gets its version bump. # # Everything is derived from the base commit (HEAD, which during # postUpgradeTasks still points at the base branch), never from the working # tree's `version:`. That makes repeated runs on the same branch idempotent — # Renovate calls the script once per dependency update, and a branch may carry -# more than one update for the same chart. +# more than one update for the same chart. The one place the working tree IS +# read is the maximum rule below, and only to keep a higher version. # # Runs inside the Renovate container, so: POSIX sh, no bashisms, no jq/yq. +# +# Tested by ci/run-bump-level-test.sh, which is where the cases live. set -eu dir=${1:-} +update_type=${2:-} [ -n "$dir" ] || exit 0 # Strip a trailing slash so "outline/" and "outline" behave the same. @@ -49,6 +82,72 @@ field() { sed -n "s/^$1:[[:space:]]*//p" | head -n 1 | tr -d "\"' " } +# Splits ".." into p_major/p_minor/p_patch. Returns 1 if +# the string is not exactly three numeric parts, which is also how a four-part +# version (3.3.1.0) and a suffixed one (9.1.2-alpine) are rejected: the last +# part then is not numeric. +parse_semver() { + _sv=$1 + case "$_sv" in + *.*.*) ;; + *) return 1 ;; + esac + p_major=${_sv%%.*} + _rest=${_sv#*.} + p_minor=${_rest%%.*} + p_patch=${_rest#*.} + for _part in "$p_major" "$p_minor" "$p_patch"; do + case "$_part" in + '' | *[!0-9]*) return 1 ;; + esac + done + return 0 +} + +# Is strictly greater than ? Six arguments, componentwise. +semver_gt() { + if [ "$1" -ne "$4" ]; then + if [ "$1" -gt "$4" ]; then return 0; else return 1; fi + fi + if [ "$2" -ne "$5" ]; then + if [ "$2" -gt "$5" ]; then return 0; else return 1; fi + fi + if [ "$3" -gt "$6" ]; then return 0; else return 1; fi +} + +# Does this app version's MINOR POSITION carry a statement about jump width? +# Only a plain [v]X.Y.Z does. Everything else keeps the pre-#128 behaviour, i.e. +# a patch, and the reason is in the convention rather than in a footnote: in +# CalVer and in date tags the second position is a MONTH, so following it would +# produce a chart minor every month that says nothing about the risk, and in a +# four-part or suffixed version the positions are not semver positions at all. +# +# Measured on origin/main, this covers seven charts and only those seven: +# rss-bridge 2026-09-30 (no dots at all) +# homeassistant 2026.8.3 (CalVer — caught by the year guard) +# searxng 2026.9.13-3b8009065 (CalVer plus a commit hash) +# minecraft-server 2026.8.2-java25 (CalVer plus a JRE suffix) +# apache-tika 3.3.1.0-full (four parts plus a flavour) +# teamspeak-server 6.0.0-beta13.1 (prerelease line) +# valkey 9.1.2-alpine (flavour suffix) +# +# The last two are the honest edge of this rule: their numeric part IS a semver +# line, so the fallback understates a real minor there. That is the decided +# trade — conservative by default, and a chart that wants the minor gets a +# documented exception rather than a widened pattern. +# +# A `major` update type is NOT subject to this: a year rolling over in a date +# tag arrives as a major, and that was a chart major before #128 too. +app_minor_is_meaningful() { + _av=${1#v} + _av=${_av#V} + parse_semver "$_av" || return 1 + # A four-digit leading component from 2000 on is a calendar year, not a + # semver major. + if [ "$p_major" -ge 2000 ]; then return 1; fi + return 0 +} + base=$(git show "HEAD:$chart" 2>/dev/null) || { echo "renovate-bump: $chart is new on this branch, leaving its version alone" exit 0 @@ -57,6 +156,7 @@ base=$(git show "HEAD:$chart" 2>/dev/null) || { base_version=$(printf '%s\n' "$base" | field version) base_app=$(printf '%s\n' "$base" | field appVersion) new_app=$(field appVersion <"$chart") +tree_version=$(field version <"$chart") if [ -z "$base_version" ] || [ -z "$new_app" ]; then echo "renovate-bump: $chart has no version/appVersion to work with" >&2 @@ -65,27 +165,13 @@ fi # is everything before the +up build suffix. semver=${base_version%%+*} -case "$semver" in - *.*.*) ;; - *) - echo "renovate-bump: cannot parse chart semver '$semver' in $chart" >&2 - exit 1 - ;; -esac - -major=${semver%%.*} -rest=${semver#*.} -minor=${rest%%.*} -patch=${rest#*.} - -for part in "$major" "$minor" "$patch"; do - case "$part" in - '' | *[!0-9]*) - echo "renovate-bump: non-numeric component in chart semver '$semver'" >&2 - exit 1 - ;; - esac -done +parse_semver "$semver" || { + echo "renovate-bump: cannot parse chart semver '$semver' in $chart" >&2 + exit 1 +} +major=$p_major +minor=$p_minor +patch=$p_patch # Leading major of an app version: v1.159.0 -> 1, 3.3.1.0-full -> 3, # 2025-06-03 -> 2025. @@ -93,17 +179,87 @@ app_major() { printf '%s' "$1" | sed -e 's/^[vV]//' -e 's/[^0-9].*$//' } -if [ "$new_app" != "$base_app" ] && [ "$(app_major "$new_app")" != "$(app_major "$base_app")" ]; then - reason="app major $base_app -> $new_app" - new_semver="$((major + 1)).0.0" -else - reason="patch-level change" - new_semver="$major.$minor.$((patch + 1))" +# The pre-#128 level: app major changed -> major, anything else -> patch. Also +# the fallback whenever no usable update type arrives. +app_version_level() { + if [ "$new_app" != "$base_app" ] && [ "$(app_major "$new_app")" != "$(app_major "$base_app")" ]; then + echo major + else + echo patch + fi +} + +case "$update_type" in + major) + level=major + reason="update type major" + ;; + minor) + if app_minor_is_meaningful "$new_app"; then + level=minor + reason="update type minor" + else + level=$(app_version_level) + reason="update type minor, but appVersion '$new_app' is not a plain semver line -> $level" + fi + ;; + patch | digest | pin | pinDigest) + level=patch + reason="update type $update_type" + ;; + '') + level=$(app_version_level) + reason="no update type given, derived from appVersion -> $level" + ;; + *) + level=$(app_version_level) + reason="unknown update type '$update_type', derived from appVersion -> $level" + ;; +esac + +case "$level" in + major) + new_major=$((major + 1)) + new_minor=0 + new_patch=0 + ;; + minor) + new_major=$major + new_minor=$((minor + 1)) + new_patch=0 + ;; + *) + new_major=$major + new_minor=$minor + new_patch=$((patch + 1)) + ;; +esac + +# THE MAXIMUM RULE (Gitea #128): the chart level is the GREATER of the chart's +# own classification and the app jump — never a replacement. Two cases, and the +# second is the mechanical one: +# +# - a chart-own bump already in the working tree stands. A value that becomes +# required is a chart major even when the app jump is a patch, and the chart +# version must not stop reporting the chart's own breaks. +# - a branch carrying SEVERAL updates. Renovate calls this script once per +# update and every call recomputes from the same base commit, so without +# this the last call would win: `freesailarr`'s grouped PR has arrived with +# three minors among five patches, and the chart level would then depend on +# the order the hook happened to run in. +# +# It only ever keeps a HIGHER version, so repeated runs stay idempotent. +if parse_semver "${tree_version%%+*}" && + semver_gt "$p_major" "$p_minor" "$p_patch" "$new_major" "$new_minor" "$new_patch"; then + reason="$reason, kept the higher $p_major.$p_minor.$p_patch already on the branch" + new_major=$p_major + new_minor=$p_minor + new_patch=$p_patch fi # Dashes in the app version become dots in the +up suffix. suffix=$(printf '%s' "$new_app" | tr '-' '.') -new_version="$new_semver+up$suffix" +new_version="$new_major.$new_minor.$new_patch+up$suffix" sed -i.bak "s|^version:.*|version: $new_version|" "$chart" rm -f "$chart.bak" diff --git a/ci/run-bump-level-test.sh b/ci/run-bump-level-test.sh new file mode 100755 index 0000000..05783f1 --- /dev/null +++ b/ci/run-bump-level-test.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# Bump-level test: every case of ci/renovate-bump-chart.sh, held against the +# version string it writes into a throwaway chart. +# +# Usage: ci/run-bump-level-test.sh +# +# WHY THIS TEST EXISTS (Gitea #128) +# +# Until #128 the hook was a calculator with one branch: app major -> chart +# major, everything else -> chart patch. With the update type coming from +# Renovate it became DECIDING LOGIC — it is now the thing that says how big a +# change the cluster is being offered, and the chart version is the only signal +# anything downstream sees (README, "Chart dependencies": Fleet's `fleet.yaml` +# in `cluster-config` sees our chart version and nothing inside the package). +# +# And its failure mode is silence. A wrong level is a valid version string: the +# PR goes green, the chart publishes, and the only symptom is a number that +# understates what it ships. Nothing in the pipeline reads it, so nothing but a +# test can. +# +# HOW A CASE IS BUILT, AND WHY A REAL GIT REPO +# +# The script reads the BASE commit (`git show HEAD:/Chart.yaml`) and the +# working tree, so a case is not a function call — it is a repository with a +# commit and a dirty file. Each case therefore gets a throwaway `git init` in a +# unique temp directory, one commit carrying the base Chart.yaml, and the new +# Chart.yaml written over it uncommitted. That is exactly the state Renovate's +# `postUpgradeTasks` hook runs in. +# +# Signing is switched off per command (`-c commit.gpgsign=false`): this +# workspace signs every commit through an SSH signer, and a throwaway repo has +# no business invoking it. The temp root comes from `mktemp -d`, because /tmp is +# shared with whatever else runs on this machine. +# +# The runners have no pyyaml, no yq and no Docker — the same constraint +# ci/run-selector-match-test.sh and ci/run-quantity-render-test.sh work under. +# None is needed: the fixtures are six-line Chart.yaml files written by printf, +# and the assertion is one `version:` line read back with sed. Helm is not +# needed either, which is why this job carries no Helm step. +# +# WHAT IS ASSERTED, AND WHAT IS NOT +# +# Asserted: the full `version:` line (so the `+up` suffix and its dash-to-dot +# normalization are covered by every case), the exit code, and for the cases +# that must leave the chart alone, that the line is untouched. +# +# NOT asserted, and not assertable from here: that Renovate passes the update +# type at all. The hook only runs when the command is allow-listed in the +# *instance* config, which lives outside this repository — and a pattern that +# does not match is skipped silently. That is why a missing second argument is +# itself a case here (it must fall back, not fail), and why the PR that +# introduced this carries a dry-run instead of a claim. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="$ROOT/ci/renovate-bump-chart.sh" + +[ -x "$SCRIPT" ] || { + echo "FAIL: $SCRIPT is not executable" >&2 + exit 1 +} + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/bump-level-test.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +GIT_OPTS=(-c user.name=ci -c user.email=ci@example.invalid -c commit.gpgsign=false -c tag.gpgsign=false) + +passed=0 +failed=0 + +chart_yaml() { # + printf 'apiVersion: v2\nname: %s\ndescription: test fixture\ntype: application\nversion: %s\nappVersion: "%s"\nicon: https://example.com/icon.png\n' \ + "$1" "$2" "$3" +} + +# Builds a repository whose HEAD carries for /Chart.yaml +# (skipped when empty, which is the "new chart on this branch" case) and whose +# working tree carries . Prints the repository path. +scenario() { # + local dir=$1 base=$2 tree=$3 repo + repo="$(mktemp -d "$TMP/case.XXXXXX")" + git -C "$repo" init -q + printf 'root commit so HEAD exists\n' >"$repo/README.md" + git -C "$repo" add README.md + git -C "$repo" "${GIT_OPTS[@]}" commit -qm "root" + mkdir -p "$repo/$dir" + if [ -n "$base" ]; then + printf '%s' "$base" >"$repo/$dir/Chart.yaml" + git -C "$repo" add "$dir/Chart.yaml" + git -C "$repo" "${GIT_OPTS[@]}" commit -qm "base chart" + fi + [ -z "$tree" ] || printf '%s' "$tree" >"$repo/$dir/Chart.yaml" + printf '%s' "$repo" +} + +version_of() { sed -n 's/^version:[[:space:]]*//p' "$1" | head -n 1; } + +report() { # + if [ "$2" = "$3" ] && [ "$4" = "$5" ]; then + passed=$((passed + 1)) + printf ' ok %s\n' "$1" + else + failed=$((failed + 1)) + printf ' FAIL %s\n expected version %-24s rc %s\n got version %-24s rc %s\n output: %s\n' \ + "$1" "$2" "$4" "$3" "$5" "$6" + fi +} + +# The ordinary case: one chart, a base version, an app version that may or may +# not have moved, and an update type. UPDATE_TYPE of "-" calls the script with +# ONE argument (the allow-list / older-Renovate case), "" passes an empty +# second argument (a template field that resolved to nothing). +bump() { # [expected-rc] + local name=$1 base_version=$2 base_app=$3 tree_version=$4 tree_app=$5 utype=$6 expected=$7 rc_expected=${8:-0} + local repo out rc got + repo="$(scenario demo "$(chart_yaml demo "$base_version" "$base_app")" "$(chart_yaml demo "$tree_version" "$tree_app")")" + rc=0 + if [ "$utype" = "-" ]; then + out="$(cd "$repo" && "$SCRIPT" demo 2>&1)" || rc=$? + else + out="$(cd "$repo" && "$SCRIPT" demo "$utype" 2>&1)" || rc=$? + fi + got="$(version_of "$repo/demo/Chart.yaml")" + report "$name" "$expected" "$got" "$rc_expected" "$rc" "$out" +} + +echo "== update type decides the level (plain semver appVersion) ==" +bump "major -> chart major" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 2.0.0 major 2.0.0+up2.0.0 +bump "minor -> chart minor" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 minor 1.3.0+up1.5.0 +bump "patch -> chart patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.1 patch 1.2.4+up1.4.1 +bump "digest -> chart patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.0 digest 1.2.4+up1.4.0 +bump "pin -> chart patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.0 pin 1.2.4+up1.4.0 +bump "pinDigest -> chart patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.0 pinDigest 1.2.4+up1.4.0 +bump "minor resets the patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 minor 1.3.0+up1.5.0 +bump "major resets minor and patch" 4.5.6+up1.4.0 1.4.0 4.5.6+up1.4.0 2.0.0 major 5.0.0+up2.0.0 + +echo "== the case that prompted the rule: appVersion never moves (freesailarr) ==" +# prowlarr 2.5.2 -> 2.6.5 is an image tag in values.yaml; freesailarr's +# appVersion is a hand-maintained marker and stays at 1.0.0. An appVersion +# comparison sees nothing and would bump the patch. +bump "sidecar/bundle minor, appVersion unchanged" 3.1.1+up1.0.0 1.0.0 3.1.1+up1.0.0 1.0.0 minor 3.2.0+up1.0.0 +bump "sidecar/bundle patch, appVersion unchanged" 3.1.1+up1.0.0 1.0.0 3.1.1+up1.0.0 1.0.0 patch 3.1.2+up1.0.0 + +echo "== non-semver app versions stay at patch (all seven on origin/main) ==" +bump "rss-bridge date tag, month roll" 2.1.1+up2026.09.30 2026-09-30 2.1.1+up2026.09.30 2026-10-05 minor 2.1.2+up2026.10.05 +bump "homeassistant CalVer" 6.2.0+up2026.8.3 2026.8.3 6.2.0+up2026.8.3 2026.9.0 minor 6.2.1+up2026.9.0 +bump "searxng CalVer plus hash" 1.1.0+up2026.9.13.3b8009065 2026.9.13-3b8009065 1.1.0+up2026.9.13.3b8009065 2026.10.1-aa12bb34c minor 1.1.1+up2026.10.1.aa12bb34c +bump "minecraft-server CalVer plus JRE" 6.0.1+up2026.8.2.java25 2026.8.2-java25 6.0.1+up2026.8.2.java25 2026.9.1-java25 minor 6.0.2+up2026.9.1.java25 +bump "apache-tika four parts" 10.0.2+up3.3.1.0.full 3.3.1.0-full 10.0.2+up3.3.1.0.full 3.4.0.0-full minor 10.0.3+up3.4.0.0.full +bump "teamspeak prerelease line" 5.1.3+up6.0.0.beta13.1 6.0.0-beta13.1 5.1.3+up6.0.0.beta13.1 6.0.0-beta14.1 minor 5.1.4+up6.0.0.beta14.1 +bump "valkey flavour suffix" 1.0.0+up9.1.2.alpine 9.1.2-alpine 1.0.0+up9.1.2.alpine 9.2.0-alpine minor 1.0.1+up9.2.0.alpine +# A major is NOT subject to the fallback - a year rolling over was a chart major +# before #128 as well. +bump "date tag, year roll, major" 2.1.1+up2026.09.30 2026-09-30 2.1.1+up2026.09.30 2027-01-05 major 3.0.0+up2027.01.05 +bump "four parts, app major, major" 10.0.2+up3.3.1.0.full 3.3.1.0-full 10.0.2+up3.3.1.0.full 4.0.0.0-full major 11.0.0+up4.0.0.0.full +# The year guard is a guard, not a blanket: a v-prefixed plain line still counts. +bump "v-prefixed plain semver, minor" 7.2.0+upv1.159.0 v1.159.0 7.2.0+upv1.159.0 v1.160.0 minor 7.3.0+upv1.160.0 + +echo "== maximum, not replacement ==" +# A chart-own bump already in the working tree stands, whatever the app jump is. +bump "chart major already on branch, app patch" 1.2.3+up1.4.0 1.4.0 2.0.0+up1.4.0 1.4.1 patch 2.0.0+up1.4.1 +bump "chart minor already on branch, app patch" 1.2.3+up1.4.0 1.4.0 1.3.0+up1.4.0 1.4.1 patch 1.3.0+up1.4.1 +# A grouped branch: the hook runs once per update from the same base, so the +# minor must survive a patch call that follows it. +bump "minor on branch, later patch call" 3.1.1+up1.0.0 1.0.0 3.2.0+up1.0.0 1.0.0 patch 3.2.0+up1.0.0 +bump "patch on branch, later minor call" 3.1.1+up1.0.0 1.0.0 3.1.2+up1.0.0 1.0.0 minor 3.2.0+up1.0.0 +bump "repeated minor call is idempotent" 3.1.1+up1.0.0 1.0.0 3.2.0+up1.0.0 1.0.0 minor 3.2.0+up1.0.0 +bump "repeated major call is idempotent" 1.2.3+up1.4.0 1.4.0 2.0.0+up2.0.0 2.0.0 major 2.0.0+up2.0.0 +bump "major on branch wins over a later minor" 1.2.3+up1.4.0 1.4.0 2.0.0+up1.4.0 1.5.0 minor 2.0.0+up1.5.0 +# Stale branch after a rebase: the base moved ahead of what the branch carries, +# so the recomputed version wins and nothing is kept. +bump "stale branch version is not kept" 3.3.0+up1.0.0 1.0.0 3.1.2+up1.0.0 1.0.0 patch 3.3.1+up1.0.0 + +echo "== missing and unknown update type fall back to the pre-#128 behaviour ==" +bump "no second argument, app major" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 2.0.0 - 2.0.0+up2.0.0 +bump "no second argument, app minor" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 - 1.2.4+up1.5.0 +bump "no second argument, app patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.1 - 1.2.4+up1.4.1 +bump "no second argument, unchanged" 3.1.1+up1.0.0 1.0.0 3.1.1+up1.0.0 1.0.0 - 3.1.2+up1.0.0 +bump "empty second argument, app major" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 2.0.0 "" 2.0.0+up2.0.0 +bump "empty second argument, app minor" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 "" 1.2.4+up1.5.0 +bump "unknown type, app minor" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 replacement 1.2.4+up1.5.0 +bump "unknown type, app major" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 2.0.0 lockFileMaintenance 2.0.0+up2.0.0 +bump "unknown type, maximum still holds" 1.2.3+up1.4.0 1.4.0 2.0.0+up1.4.0 1.4.1 nonsense 2.0.0+up1.4.1 + +echo "== charts the script must leave alone, and the hard failures ==" +bump "unparsable chart semver fails loudly" 1.2+up1.4.0 1.4.0 1.2+up1.4.0 1.5.0 minor 1.2+up1.4.0 1 +bump "missing appVersion fails loudly" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 "" minor 1.2.3+up1.4.0 1 + +new_chart_repo="$(scenario demo "" "$(chart_yaml demo 0.1.0+up1.0.0 1.0.0)")" +rc=0 +out="$(cd "$new_chart_repo" && "$SCRIPT" demo minor 2>&1)" || rc=$? +report "a chart new on the branch keeps its version" \ + "0.1.0+up1.0.0" "$(version_of "$new_chart_repo/demo/Chart.yaml")" 0 "$rc" "$out" +case "$out" in + *"new on this branch"*) passed=$((passed + 1)); printf ' ok and says so\n' ;; + *) failed=$((failed + 1)); printf ' FAIL new chart: no explanation in the output: %s\n' "$out" ;; +esac + +tpl_repo="$(scenario template-chart "$(chart_yaml template-chart 4.2.0+upxxx xxx)" "$(chart_yaml template-chart 4.2.0+upxxx xxx)")" +rc=0 +out="$(cd "$tpl_repo" && "$SCRIPT" template-chart minor 2>&1)" || rc=$? +report "template-chart is never touched" \ + "4.2.0+upxxx" "$(version_of "$tpl_repo/template-chart/Chart.yaml")" 0 "$rc" "$out" + +# Renovate calls the hook for every update it makes, chart or not. +other_repo="$(scenario demo "$(chart_yaml demo 1.2.3+up1.4.0 1.4.0)" "$(chart_yaml demo 1.2.3+up1.4.0 1.4.0)")" +for arg in ".github/workflows" "ci/gateway-api-crds.sh" "." "" "not-a-chart"; do + rc=0 + out="$(cd "$other_repo" && "$SCRIPT" "$arg" major 2>&1)" || rc=$? + report "non-chart argument '$arg' is a no-op" \ + "1.2.3+up1.4.0" "$(version_of "$other_repo/demo/Chart.yaml")" 0 "$rc" "$out" +done + +rc=0 +out="$(cd "$other_repo" && "$SCRIPT" 2>&1)" || rc=$? +report "no arguments at all is a no-op" \ + "1.2.3+up1.4.0" "$(version_of "$other_repo/demo/Chart.yaml")" 0 "$rc" "$out" + +rc=0 +out="$(cd "$other_repo" && "$SCRIPT" demo/ patch 2>&1)" || rc=$? +report "a trailing slash is accepted" \ + "1.2.4+up1.4.0" "$(version_of "$other_repo/demo/Chart.yaml")" 0 "$rc" "$out" + +echo +if [ "$failed" -gt 0 ]; then + printf 'bump-level test: %d passed, %d FAILED\n' "$passed" "$failed" + exit 1 +fi +printf 'bump-level test: %d cases passed\n' "$passed" diff --git a/renovate.json5 b/renovate.json5 index 9313743..20b6357 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -88,8 +88,55 @@ // line can only come from this hook. Nothing has to be configured for the // move - but the allow-list lives on that instance, not in this repo, so it // is also not visible from here if it is ever removed. + // + // THE SECOND ARGUMENT IS THE UPDATE TYPE (Gitea #128), and it is what lets + // the chart version follow the app version: major -> major, minor -> MINOR, + // patch/digest -> patch. It is passed rather than derived because Renovate + // has already classified the jump and the script cannot: `freesailarr` keeps + // a hand-maintained `appVersion: "1.0.0"` for ten upstreams, so prowlarr's + // 2.5.2 -> 2.6.5 moved an image tag in values.yaml while appVersion stood + // still - an appVersion comparison is blind to exactly the case the rule was + // written for (README, "The version-bump hook"). + // + // VERIFIED, NOT ASSUMED, and it took two runs because one of them cannot + // answer the question. Renovate 44.155.7, keycloak pinned one minor back: + // + // 1. --platform=local --dry-run=full reads THIS file and classifies the + // update ("updateType": "minor", newValue 26.8.1, alongside a separate + // "patch" bucket for 26.7.5). What it does NOT do is reach the branch + // worker at all - there is not one "Processing branch" line in the log - + // so the hook never runs under the local platform and that run can + // prove the lookup and the config, never the command. + // 2. Renovate's own post-upgrade executor + // (dist/workers/repository/update/branch/execute-post-upgrade-commands) + // driven against a throwaway checkout with allowedCommands set to the + // pattern above: + // DEBUG: Executed post-upgrade task + // "cmd": "ci/renovate-bump-chart.sh keycloak minor" + // "stdout": "renovate-bump: keycloak/Chart.yaml 1.3.0+up26.7.0 -> + // 1.4.0+up26.8.1 (update type minor)" + // So the field arrives populated, the front-anchored allow-list accepts + // the command WITH the argument, and the result is a chart minor. + // + // `updateType` is in Renovate's template allowedFields (util/template: + // "One of digest, pin, rollback, patch, minor, major, replacement, + // pinDigest") and the command is compiled per UPDATE here, which is what + // executionMode: "update" below means. + // + // THE RISK THIS ADDS, and it cannot be settled from inside this repo: the + // allow-list is matched against the COMPILED command + // (`allowedCommands.some((p) => regEx(p).test(compiledCmd))`), so the + // front-anchored pattern above still matches with an argument appended. A + // pattern anchored at the END on the instance (`...\.sh$`) would stop + // matching and the task would be skipped with every chart PR red on the + // version check - the same silent failure a missing entry produces. Which + // form the instance uses is not visible from here. The script therefore + // treats a MISSING second argument as the pre-#128 behaviour instead of an + // error, so the worst case is the old classification rather than an outage. postUpgradeTasks: { - commands: ["ci/renovate-bump-chart.sh {{{packageFileDir}}}"], + commands: [ + "ci/renovate-bump-chart.sh {{{packageFileDir}}} {{{updateType}}}", + ], fileFilters: ["**/Chart.yaml"], // "update" runs the script once per dependency update with the package // file's directory, which is what the script needs to find the chart. The @@ -169,9 +216,12 @@ "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. 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.", + "reviewed and the check is green, and ci/renovate-bump-chart.sh bumps the chart version", + "that publishes it - since #128 at the SUBCHART'S level, so an oauth2-proxy minor is a", + "chart minor even though our appVersion did not move. That is the point of taking the", + "level from Renovate rather than from appVersion: the one number anything downstream", + "sees now reports the size of what is inside the package. A major goes the same way -", + "it is the review, not a separate policy, that gives it a look.", ], matchManagers: ["helmv3"], addLabels: ["subchart"], @@ -262,6 +312,12 @@ "publish, one install-test run for the whole stack. Nothing is left to serialize, so", "nothing is left to rebase.", "", + "WHAT LEVEL A MIXED GROUP GETS, since #128 made that a question: the hook still runs once", + "per update and still recomputes from the same base, so the group takes the HIGHEST level", + "in it - three minors among five patches is a chart minor, whatever order the updates are", + "processed in. That is the maximum rule in ci/renovate-bump-chart.sh, and this branch is", + "the reason it is not optional: without it the last call would win.", + "", "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",