feat(ci): the chart bump follows the app bump
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Build ${{ matrix.chart }} (pull_request) Skipped
Validate Helm Charts / Install-test (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 18s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Bump level (pull_request) Successful in 7s
Validate Helm Charts / Trivy config cyberchef (pull_request) Successful in 20s
Validate Helm Charts / Trivy config matrix-synapse (pull_request) Successful in 22s
Validate Helm Charts / Trivy config samba (pull_request) Successful in 21s
SonarQube Analysis / SonarQube Analysis (push) Successful in 48s

The version-bump hook classified every app update that was not a major
as a chart patch, so an app minor arrived as a patch - the mechanism
behind the sonarr incident, where a security default was switched off
inside a bump the conventions told the reviewer not to read (Gitea #128).

ci/renovate-bump-chart.sh now takes the level from Renovate's update
type, passed in as a second argument: major -> major, minor -> MINOR,
patch/digest/pin -> patch. The level comes from Renovate rather than
from comparing appVersion because an appVersion comparison is blind to
the case that prompted the rule - freesailarr keeps a hand-maintained
appVersion for ten upstreams, and prowlarr's 2.5.2 -> 2.6.5 moved an
image tag in values.yaml while appVersion stood still.

Three properties come with it:

- 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 major under an app patch. Mechanically it is also
  what makes a grouped branch deterministic: the hook runs once per
  update from the same base commit, so without it the last call would
  win.
- Non-semver app versions stay at patch for a minor (seven charts). In
  CalVer and date tags the second position is a month; in a four-part
  or suffixed version the positions are not semver positions. An app
  major is not subject to it.
- A missing or unknown update type falls back to the pre-#128
  behaviour. postUpgradeTasks only runs where the command is
  allow-listed on the shared instance, which is not visible from here
  and fails silently, so the worst case is the old classification
  rather than every chart PR red on the version check.

ci/run-bump-level-test.sh holds 49 cases against the version string the
script writes, wired up as the bump-level job: 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.

The convention texts follow in CLAUDE.md and README, including the
limit the decision deliberately keeps: the reading duty was rejected,
so the incident stays repeatable and the honest number is the whole of
the remedy.

Fixes #128
This commit was merged in pull request #130.
This commit is contained in:
jklein committed 2026-10-13 18:47:44 +00:00
1 parent 978e6fbaa8
commit 59d2614384
6 files changed
+548 -55

No files matched your search

+189 -33
View File
@@ -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 <chart-directory>
# Usage: ci/renovate-bump-chart.sh <chart-directory> [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 "<major>.<minor>.<patch>" 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 <a.b.c> strictly greater than <d.e.f>? 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
# <chart-semver> 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"