#!/bin/sh # Recompute a chart's `version:` after Renovate changed something inside its # directory. # # Renovate rewrites `appVersion:` in Chart.yaml (main image) or an image tag in # values.yaml (sidecar/init image). It has no idea about this repo's scheme # # version: +up # # and it never touches `version:` at all — which would leave every Renovate PR # 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 [update-type] # # 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. 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. dir=${dir%/} # Renovate also calls this for .github/workflows and other non-chart updates. case "$dir" in "" | "." | .github* | ci*) exit 0 ;; esac chart="$dir/Chart.yaml" [ -f "$chart" ] || exit 0 # The scaffold is never published and its version carries no meaning. [ "$dir" != "template-chart" ] || exit 0 # Reads a top-level scalar field, unquoted and untrimmed of surrounding spaces. 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 } 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 exit 1 fi # is everything before the +up build suffix. semver=${base_version%%+*} 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. app_major() { printf '%s' "$1" | sed -e 's/^[vV]//' -e 's/[^0-9].*$//' } # 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_major.$new_minor.$new_patch+up$suffix" sed -i.bak "s|^version:.*|version: $new_version|" "$chart" rm -f "$chart.bak" echo "renovate-bump: $chart $base_version -> $new_version ($reason)"