From f03883e971889df1bab094a666d4b4495e69eea0 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 14 Aug 2026 17:01:01 +0200 Subject: [PATCH 1/2] IONOS(ci): derive publish coordinate from ref and version (HDNEXT-2144) Add .github/scripts/derive-coordinate.sh, which maps a git ref plus the NC_VERSION that was actually built to the tuple saying where the build belongs: lane, train, Nextcloud major, downstream ref, chart path and chart version. Nothing consumes it yet; wiring it into the trigger and into the helm pipeline are later steps. Two properties it exists to hold: - The major comes from the artifact, never from the branch name. Where a branch name declares a major, that declaration becomes an assertion that fails the build on disagreement. This is the guard that was missing on 2026-07-29, when an NC32 build successfully triggered the v30 lane and only an unrelated apk pin conflict stopped it. - Every guard fails closed. An unrecognised ref, an empty or malformed NC_VERSION, or a declared major that disagrees with the artifact stops the pipeline. There is no default lane to fall back to. Both branch naming eras are accepted indefinitely: legacy rc/web-3.5, whose trailing number is a release counter and not a major, and the new rc/web-v- shape. The unsuffixed ionos-dev branch keeps working too, since retiring it comes later and failing it closed here would break the live dev build before that step arrives. Extracting this into a script rather than leaving it as inline pipeline shell is what makes it testable: a table of 48 cases covers every lane type, both naming eras, the 2026-07-29 regression and each fail-closed path, and runs offline with no pipeline and no registry. There was no shell test harness in this repo before, so the table brings its own. Signed-off-by: Misha M.-Kupriyanov --- .github/scripts/derive-coordinate.sh | 224 ++++++++++ .../scripts/tests/derive-coordinate.test.sh | 402 ++++++++++++++++++ 2 files changed, 626 insertions(+) create mode 100755 .github/scripts/derive-coordinate.sh create mode 100755 .github/scripts/tests/derive-coordinate.test.sh diff --git a/.github/scripts/derive-coordinate.sh b/.github/scripts/derive-coordinate.sh new file mode 100755 index 0000000000000..0d9fb6f826c9f --- /dev/null +++ b/.github/scripts/derive-coordinate.sh @@ -0,0 +1,224 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: 2026 STRATO AG +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Derive the publish coordinate for one build. +# +# Given a git ref and the NC_VERSION that was actually built, emit the tuple that +# says where this build belongs: its lane, its train, its Nextcloud major, the +# downstream branch to trigger, and the chart path and version to publish under. +# Emit nothing and exit non-zero if the inputs do not describe exactly one place. +# +# Usage: +# derive-coordinate.sh [pipeline-iid] +# +# Output is KEY=value lines on stdout, suitable for appending to $GITHUB_OUTPUT, +# $GITHUB_ENV or a GitLab dotenv artifact. Reasons for refusing go to stderr. +# +# LANE_TYPE dev | train | trunk | merge-request +# LANE the lane, empty for trunk and merge-request +# TRAIN the release train, empty unless LANE_TYPE=train +# NC_MAJOR Nextcloud major, parsed from NC_VERSION +# DOWNSTREAM_REF branch to trigger in the image and helm repos +# PUBLISHES_CHART true | false +# CHART_NAME always hidrive-next +# CHART_PATH OCI repository path, empty when PUBLISHES_CHART=false +# CHART_VERSION 1.., only when publishing and an IID is known +# +# Whether a lane *packages* a chart without pushing it is policy that keys off +# LANE_TYPE, and belongs to the helm pipeline rather than here. +# +# Two properties this script exists to hold: +# +# The major comes from the artifact, never from the branch name. Where a branch +# name *declares* a major, that declaration is an assertion checked against the +# artifact and the build fails on disagreement. This is why legacy rc/web-3.5 +# needs no rename: it builds 30.x, parses to v30, and routes to the v30 lane. +# +# Every guard fails closed. An unrecognised ref, an empty or malformed +# NC_VERSION, or a declared major that disagrees with the artifact stops the +# pipeline. There is no default lane to fall back to. +# +# The pipeline IID is optional because CI_PIPELINE_IID does not exist until the +# downstream pipeline starts: nc-server calls this without one and posts the lane, +# train and major downstream as trigger variables, and the chart version is +# composed where the IID is known. This script is the authority for the +# derivation itself, not a file shared between the two repos. +# +# Ref vocabulary note: `main` is the trunk as it exists in the image and helm +# repos. nc-server has no `main` branch — its refs are the ionos-dev* and rc/* +# lanes below — so the trunk arm is reached only when the derivation runs +# downstream. + +set -e +set -u +set -o pipefail + +# The chart name may never vary. Its common labels emit helm.sh/chart, which is +# included in the spec.selector.matchLabels of Deployments, and Deployment +# selectors are immutable in Kubernetes — renaming the chart makes helm upgrade +# fail and forces delete-and-recreate of every Deployment. The train identifier +# goes in the path instead. +readonly CHART_NAME="hidrive-next" + +# The nextcloud-dev/helm namespace is shared — the chart pulls its imaginary +# dependency from the same path it pushes to, and the namespace also hosts the +# nextcloud-workspace charts — so path segments are product-qualified. +readonly CHART_PATH_PREFIX="helm/${CHART_NAME}" + +die() { + echo "derive-coordinate: $1" >&2 + exit 1 +} + +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + die "usage: derive-coordinate.sh [pipeline-iid]" +fi + +REF="$1" +NC_VERSION="$2" +PIPELINE_IID="${3:-}" + +[ -n "$REF" ] || die "ref is empty; refusing to guess a lane" + +# Accept both github.ref_name (bare) and github.ref (fully qualified). +REF="${REF#refs/heads/}" + +# --- The major comes from the artifact ------------------------------------- + +# jq -r on a missing .ncVersion key prints the string "null", which is the shape +# a broken version.json actually reaches the trigger as. Require at least +# .: a real NC_VERSION always carries dots, so a bare number is +# more likely a truncated read than a version. +if [ -z "$NC_VERSION" ]; then + die "NC_VERSION is empty; refusing to guess a major" +fi + +if ! [[ "$NC_VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)*$ ]]; then + die "NC_VERSION '${NC_VERSION}' is not a dotted numeric version; refusing to guess a major" +fi + +NC_MAJOR="${NC_VERSION%%.*}" + +if [ -n "$PIPELINE_IID" ] && ! [[ "$PIPELINE_IID" =~ ^[0-9]+$ ]]; then + die "pipeline IID '${PIPELINE_IID}' is not numeric" +fi + +# --- The ref says which lane, and may declare a major ---------------------- + +# DECLARED_MAJOR stays empty when the ref shape carries no major. Those refs need +# no assertion and, under the derived scheme, no rename: */dev/* user branches +# and legacy rc/web- trains both fall here. A version-shaped substring +# elsewhere in a branch name is not a declaration. +DECLARED_MAJOR="" +LANE_TYPE="" +LANE="" +TRAIN="" + +if [ "$REF" = "main" ]; then + # The integration trunk. Not a lane — the vocabulary has exactly two lane + # types — but it publishes a chart line of its own so the trunk stays + # continuously exercised rather than being packaged only once a change + # reaches a lane. + LANE_TYPE="trunk" + +elif [[ "$REF" =~ ^ionos-dev-v([0-9]+)$ ]]; then + LANE_TYPE="dev" + DECLARED_MAJOR="${BASH_REMATCH[1]}" + +elif [ "$REF" = "ionos-dev" ]; then + # The unsuffixed dev branch predates the per-major lanes and still builds. It + # declares no major, so it takes one from the artifact like any user dev + # branch. Retiring it is a later step; failing it closed here would break the + # live dev build before that step arrives. + LANE_TYPE="dev" + +elif [[ "$REF" =~ ^(feature|renovate)/.+$ ]]; then + # Checked but never published: these lint, template and package only. + # Matched before the */dev/* shape below, which would otherwise swallow + # feature/dev/... and renovate/dev/... into a dev lane and trigger downstream. + LANE_TYPE="merge-request" + +elif [[ "$REF" =~ ^[^/]+/dev/.+$ ]]; then + # A user dev branch. Carries no major and no longer needs to. + LANE_TYPE="dev" + +elif [[ "$REF" =~ ^rc/(web-v([0-9]+)-[0-9]+)$ ]]; then + LANE_TYPE="train" + TRAIN="${BASH_REMATCH[1]}" + DECLARED_MAJOR="${BASH_REMATCH[2]}" + +elif [[ "$REF" =~ ^rc/(web-[0-9]+(\.[0-9]+)?)$ ]]; then + # Legacy naming era — rc/web-3.5, rc/web-5. The trailing number is a release + # counter, not a major, so this shape declares nothing. Both eras are accepted + # indefinitely; this is not a transition window. + LANE_TYPE="train" + TRAIN="${BASH_REMATCH[1]}" + +else + # No default lane. The retired stable axis (ionos-stable*, ionos-dev with no + # major) and anything else unrecognised stop here. + die "unrecognised ref '${REF}'; refusing to fall back to a default lane" +fi + +# --- The declaration is an assertion, not a source of truth ---------------- + +if [ -n "$DECLARED_MAJOR" ] && [ "$DECLARED_MAJOR" != "$NC_MAJOR" ]; then + die "ref '${REF}' declares major ${DECLARED_MAJOR} but NC_VERSION '${NC_VERSION}' is major ${NC_MAJOR}" +fi + +# --- Compose the rest ------------------------------------------------------ + +PUBLISHES_CHART="false" +CHART_PATH="" +DOWNSTREAM_REF="" + +case "$LANE_TYPE" in + dev) + # Dev installs from the working tree with helm upgrade --install, so it has a + # downstream branch — chart source, values and image tag all match the major + # under test — but no chart path. It publishes nothing, by design. + LANE="dev-v${NC_MAJOR}" + DOWNSTREAM_REF="$LANE" + ;; + train) + LANE="rc/${TRAIN}" + DOWNSTREAM_REF="$LANE" + PUBLISHES_CHART="true" + # Uniqueness of a published chart comes from this path, never from the + # version: two trains on the same major emit the same version. + CHART_PATH="${CHART_PATH_PREFIX}-${TRAIN}" + ;; + trunk) + DOWNSTREAM_REF="main" + PUBLISHES_CHART="true" + CHART_PATH="${CHART_PATH_PREFIX}-main" + ;; + merge-request) + # Nothing to trigger: a merge request is checked, never delivered. + ;; + *) + # Unreachable unless a lane type is added above without a composition arm. + # Fail closed rather than emit a half-built coordinate. + die "no coordinate composition for lane type '${LANE_TYPE}'" + ;; +esac + +echo "LANE_TYPE=${LANE_TYPE}" +echo "LANE=${LANE}" +echo "TRAIN=${TRAIN}" +echo "NC_MAJOR=${NC_MAJOR}" +echo "DOWNSTREAM_REF=${DOWNSTREAM_REF}" +echo "PUBLISHES_CHART=${PUBLISHES_CHART}" +echo "CHART_NAME=${CHART_NAME}" +echo "CHART_PATH=${CHART_PATH}" + +# Only a lane that publishes gets a version, and only once the IID is known. A +# version without a path would not identify one place to publish to. +# +# 1.. sorts above the legacy 1.7.* line for every major from 30 up, +# because semver compares the minor numerically. That is relied on deliberately. +if [ "$PUBLISHES_CHART" = "true" ] && [ -n "$PIPELINE_IID" ]; then + echo "CHART_VERSION=1.${NC_MAJOR}.${PIPELINE_IID}" +fi diff --git a/.github/scripts/tests/derive-coordinate.test.sh b/.github/scripts/tests/derive-coordinate.test.sh new file mode 100755 index 0000000000000..24cb1726ae5a4 --- /dev/null +++ b/.github/scripts/tests/derive-coordinate.test.sh @@ -0,0 +1,402 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: 2026 STRATO AG +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Table-driven tests for derive-coordinate.sh. +# +# Runs entirely offline — no pipeline, no registry, no network, no git. +# Usage: .github/scripts/tests/derive-coordinate.test.sh + +# Deliberately no `set -e`: every helper below inspects a non-zero exit status +# from the script under test, which `set -e` would turn into an abort. +set -u +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DERIVE="${SCRIPT_DIR}/../derive-coordinate.sh" + +PASSED=0 +FAILED=0 + +# run_derive — invoke the script under test, passing the +# IID only when one was given, since its absence is itself a case under test. +# Sets DERIVE_STDOUT, DERIVE_STDERR and DERIVE_STATUS. +run_derive() { + local ref="$1" version="$2" iid="$3" + local stderr_file + stderr_file="$(mktemp)" + + if [ -n "$iid" ]; then + DERIVE_STDOUT=$("$DERIVE" "$ref" "$version" "$iid" 2>"$stderr_file") + else + DERIVE_STDOUT=$("$DERIVE" "$ref" "$version" 2>"$stderr_file") + fi + DERIVE_STATUS=$? + DERIVE_STDERR="$(cat "$stderr_file")" + rm -f "$stderr_file" +} + +# assert_ok +# +# Asserts the script exits 0 and that every expected KEY=value appears verbatim +# as its own output line. Only the listed keys are checked, so a case states the +# part of the coordinate it cares about. +assert_ok() { + local name="$1" ref="$2" version="$3" iid="$4" expectations="$5" + local output + + run_derive "$ref" "$version" "$iid" + output="$DERIVE_STDOUT" + + if [ "$DERIVE_STATUS" -ne 0 ]; then + printf 'FAIL %s\n expected exit 0, got %s\n stderr: %s\n' \ + "$name" "$DERIVE_STATUS" "$DERIVE_STDERR" + FAILED=$((FAILED + 1)) + return + fi + + local missing="" + local expectation + while IFS= read -r expectation; do + [ -z "$expectation" ] && continue + if ! grep -Fxq -- "$expectation" <<<"$output"; then + missing="${missing} ${expectation}" + fi + done <<<"${expectations//;/$'\n'}" + + if [ -n "$missing" ]; then + printf 'FAIL %s\n missing:%s\n actual output:\n %s\n' \ + "$name" "$missing" "${output//$'\n'/$'\n' }" + FAILED=$((FAILED + 1)) + return + fi + + printf 'ok %s\n' "$name" + PASSED=$((PASSED + 1)) +} + +# assert_fails +# +# Asserts the script exits non-zero, says why on stderr, and emits no coordinate. +# The expected reason must be specific enough to identify *which* guard fired: a +# substring shared by two guards' messages lets either one satisfy the case, so +# deleting one guard would leave the table green. +assert_fails() { + local name="$1" ref="$2" version="$3" iid="$4" expected_reason="$5" + + run_derive "$ref" "$version" "$iid" + + if [ "$DERIVE_STATUS" -eq 0 ]; then + printf 'FAIL %s\n expected non-zero exit, got 0\n' "$name" + FAILED=$((FAILED + 1)) + return + fi + + if ! grep -Fq -- "$expected_reason" <<<"$DERIVE_STDERR"; then + printf 'FAIL %s\n expected stderr to mention: %s\n actual stderr: %s\n' \ + "$name" "$expected_reason" "$DERIVE_STDERR" + FAILED=$((FAILED + 1)) + return + fi + + if [ -n "$DERIVE_STDOUT" ]; then + printf 'FAIL %s\n a refused build must emit no coordinate, got: %s\n' \ + "$name" "$DERIVE_STDOUT" + FAILED=$((FAILED + 1)) + return + fi + + printf 'ok %s\n' "$name" + PASSED=$((PASSED + 1)) +} + +# assert_absent +# +# Asserts the script exits 0 but emits no line for at all. +assert_absent() { + local name="$1" ref="$2" version="$3" iid="$4" key="$5" + + run_derive "$ref" "$version" "$iid" + + if [ "$DERIVE_STATUS" -ne 0 ]; then + printf 'FAIL %s\n expected exit 0, got %s\n' "$name" "$DERIVE_STATUS" + FAILED=$((FAILED + 1)) + return + fi + + if grep -q "^${key}=" <<<"$DERIVE_STDOUT"; then + printf 'FAIL %s\n expected no %s line, got: %s\n' \ + "$name" "$key" "$(grep "^${key}=" <<<"$DERIVE_STDOUT")" + FAILED=$((FAILED + 1)) + return + fi + + printf 'ok %s\n' "$name" + PASSED=$((PASSED + 1)) +} + +echo "=== Lane type: dev lane (branch declares a major) ===" + +assert_ok 'ionos-dev-v33 is a dev lane routing to dev-v33' \ + 'ionos-dev-v33' '33.0.6.2' '' \ + 'LANE_TYPE=dev;LANE=dev-v33;TRAIN=;NC_MAJOR=33;DOWNSTREAM_REF=dev-v33' + +assert_ok 'a dev lane publishes no chart' \ + 'ionos-dev-v33' '33.0.6.2' '' \ + 'PUBLISHES_CHART=false;CHART_PATH=' + +assert_ok 'ionos-dev-v30 routes to its own major, not a default' \ + 'ionos-dev-v30' '30.0.7.2' '' \ + 'LANE=dev-v30;NC_MAJOR=30;DOWNSTREAM_REF=dev-v30' + +assert_ok 'a fully-qualified refs/heads/ ref is accepted' \ + 'refs/heads/ionos-dev-v33' '33.0.6.2' '' \ + 'LANE_TYPE=dev;LANE=dev-v33' + +echo "=== Lane type: dev lane (branch declares no major) ===" + +# A */dev/* user branch carries no major, and under the derived scheme it no +# longer needs to — the major comes from the artifact. +assert_ok 'a user dev branch takes its major from the artifact' \ + 'kh/dev/add-simplenavigation-v33' '33.0.6.2' '' \ + 'LANE_TYPE=dev;LANE=dev-v33;NC_MAJOR=33;DOWNSTREAM_REF=dev-v33' + +assert_ok 'a user dev branch on a different major routes elsewhere' \ + 'mk/dev/some-fix' '31.0.6.2.1062' '' \ + 'LANE_TYPE=dev;LANE=dev-v31;NC_MAJOR=31;DOWNSTREAM_REF=dev-v31' + +# The branch name mentioning a version is not a declaration — only the +# ionos-dev-v form declares one. This case would trip an over-eager +# parser into asserting 33 against an artifact built as 31. +assert_ok 'a version-shaped substring in a user branch name is not a declaration' \ + 'kh/dev/prepare-v33-theming' '31.0.6.2' '' \ + 'LANE=dev-v31;NC_MAJOR=31' + +# The unsuffixed dev branch still builds and is retired only later, so failing it +# closed here would break the live dev build before that step arrives. +assert_ok 'unsuffixed ionos-dev is a dev lane taking its major from the artifact' \ + 'ionos-dev' '33.0.6.2' '' \ + 'LANE_TYPE=dev;LANE=dev-v33;NC_MAJOR=33;DOWNSTREAM_REF=dev-v33' + +echo "=== Lane type: train lane, new naming era ===" + +assert_ok 'rc/web-v33-1 is a train lane with its own chart line' \ + 'rc/web-v33-1' '33.0.6.2' '990' \ + 'LANE_TYPE=train;LANE=rc/web-v33-1;TRAIN=web-v33-1;NC_MAJOR=33;DOWNSTREAM_REF=rc/web-v33-1;PUBLISHES_CHART=true;CHART_PATH=helm/hidrive-next-web-v33-1;CHART_VERSION=1.33.990' + +assert_ok 'rc/web-v31-1 publishes into the v31 line' \ + 'rc/web-v31-1' '31.0.6.2.1062' '42' \ + 'TRAIN=web-v31-1;NC_MAJOR=31;CHART_PATH=helm/hidrive-next-web-v31-1;CHART_VERSION=1.31.42' + +echo "=== Lane type: train lane, legacy naming era ===" + +# rc/web-3.5 is the live v30 lane. The trailing number is a release counter, not +# a major — so the legacy shape declares no major, parses 30 from the artifact, +# routes to the v30 lane, and needs no rename. Indefinitely, not for a window. +assert_ok 'legacy rc/web-3.5 derives v30 from the artifact' \ + 'rc/web-3.5' '30.0.7.2' '88' \ + 'LANE_TYPE=train;LANE=rc/web-3.5;TRAIN=web-3.5;NC_MAJOR=30;DOWNSTREAM_REF=rc/web-3.5;CHART_PATH=helm/hidrive-next-web-3.5;CHART_VERSION=1.30.88' + +assert_ok 'legacy rc/web-5 is a train lane, its 5 is not a major' \ + 'rc/web-5' '32.0.6.1.1062' '7' \ + 'LANE_TYPE=train;TRAIN=web-5;NC_MAJOR=32;CHART_PATH=helm/hidrive-next-web-5;CHART_VERSION=1.32.7' + +echo "=== Lane type: trunk ===" + +# main is the integration trunk with a line of its own. It is not a lane — +# the vocabulary has exactly two lane types — so LANE and TRAIN stay empty. +assert_ok 'main publishes the trunk chart line' \ + 'main' '33.0.6.2' '512' \ + 'LANE_TYPE=trunk;LANE=;TRAIN=;NC_MAJOR=33;DOWNSTREAM_REF=main;PUBLISHES_CHART=true;CHART_PATH=helm/hidrive-next-main;CHART_VERSION=1.33.512' + +echo "=== Lane type: merge-request branch ===" + +assert_ok 'a feature branch packages but never publishes' \ + 'feature/HDNEXT-2144-derive-coordinate' '33.0.6.2' '4' \ + 'LANE_TYPE=merge-request;LANE=;TRAIN=;NC_MAJOR=33;DOWNSTREAM_REF=;PUBLISHES_CHART=false;CHART_PATH=' + +assert_ok 'a renovate branch packages but never publishes' \ + 'renovate/npm-vue-3.x' '33.0.6.2' '4' \ + 'LANE_TYPE=merge-request;PUBLISHES_CHART=false;CHART_PATH=' + +# The */dev/* shape would otherwise swallow these and trigger downstream. +assert_ok 'feature/dev/... is a merge request, not a dev lane' \ + 'feature/dev/some-experiment' '33.0.6.2' '' \ + 'LANE_TYPE=merge-request;LANE=;DOWNSTREAM_REF=' + +assert_ok 'renovate/dev/... is a merge request, not a dev lane' \ + 'renovate/dev/bump-something' '33.0.6.2' '' \ + 'LANE_TYPE=merge-request;LANE=;DOWNSTREAM_REF=' + +echo "=== The major assertion ===" + +# The 2026-07-29 regression: nc-server rc/web-5 built NC 32.0.6.1.1062 and +# triggered the v30 lane. Only an unrelated apk pin conflict stopped it. Where a +# branch declares a major, disagreeing with the artifact must fail the build. +assert_fails 'a train branch declaring v33 that built 32.x fails the build' \ + 'rc/web-v33-1' '32.0.6.1.1062' '990' \ + 'declares major 33' + +assert_fails 'a dev branch declaring v33 that built 30.x fails the build' \ + 'ionos-dev-v33' '30.0.7.2' '' \ + 'declares major 33' + +assert_fails 'the assertion is symmetric — declaring v30 while building 33.x also fails' \ + 'ionos-dev-v30' '33.0.6.2' '' \ + 'declares major 30' + +echo "=== Fail closed ===" + +assert_fails 'an unrecognised ref stops the pipeline' \ + 'some/random/branch' '33.0.6.2' '' \ + 'unrecognised ref' + +assert_fails 'a bare topic branch is not silently treated as a merge request' \ + 'wip-experiment' '33.0.6.2' '' \ + 'unrecognised ref' + +# The stable axis is retired: no ionos-stable-v* branch has ever existed, and +# BUILD_TYPE is re-derived from the lane. These refs must not resolve to a lane. +assert_fails 'the retired stable axis is not a lane' \ + 'ionos-stable-v30' '30.0.7.2' '' \ + 'unrecognised ref' + +# ionos-dev-v32.0.6 was deleted from origin to keep one dev-lane shape, so the +# longer form is no longer a lane. Only ionos-dev-v declares a major. +assert_fails 'a dotted ionos-dev-v. ref is not a lane' \ + 'ionos-dev-v32.0.6' '32.0.6.1.1062' '' \ + 'unrecognised ref' + +assert_fails 'the unsuffixed stable branch is not a lane either' \ + 'ionos-stable' '30.0.7.2' '' \ + 'unrecognised ref' + +# nc-server's fork-sync branch is not a delivery lane; the trunk that publishes a +# chart line is `main` in the image and helm repos. +assert_fails 'master is not a lane' \ + 'master' '33.0.6.2' '' \ + 'unrecognised ref' + +assert_fails 'an empty NC_VERSION stops the pipeline' \ + 'ionos-dev-v33' '' '' \ + 'NC_VERSION is empty' + +# jq -r on a missing .ncVersion key prints the string "null", so this is the +# shape a broken version.json actually reaches the trigger as. +assert_fails 'the literal string null is rejected' \ + 'ionos-dev-v33' 'null' '' \ + 'is not a dotted numeric version' + +assert_fails 'a non-numeric NC_VERSION is rejected' \ + 'ionos-dev-v33' 'abc' '' \ + 'is not a dotted numeric version' + +assert_fails 'a v-prefixed NC_VERSION is rejected' \ + 'ionos-dev-v33' 'v33.0.6.2' '' \ + 'is not a dotted numeric version' + +assert_fails 'a single-component NC_VERSION is rejected' \ + 'ionos-dev-v33' '33' '' \ + 'is not a dotted numeric version' + +assert_fails 'a trailing-dot NC_VERSION is rejected' \ + 'ionos-dev-v33' '33.' '' \ + 'is not a dotted numeric version' + +assert_fails 'a leading-dot NC_VERSION is rejected' \ + 'ionos-dev-v33' '.33.0' '' \ + 'is not a dotted numeric version' + +assert_fails 'a non-numeric component is rejected' \ + 'ionos-dev-v33' '33.0.x.2' '' \ + 'is not a dotted numeric version' + +assert_fails 'an empty ref stops the pipeline' \ + '' '33.0.6.2' '' \ + 'ref is empty' + +# Called with too few or too many arguments, rather than with bad values. Checked +# directly because the helpers above always pass a well-formed argument count. +assert_usage_error() { + local name="$1" + shift + local stderr status + stderr=$("$DERIVE" "$@" 2>&1 >/dev/null) + status=$? + + if [ $status -eq 0 ]; then + printf 'FAIL %s\n expected non-zero exit, got 0\n' "$name" + FAILED=$((FAILED + 1)) + return + fi + if ! grep -Fq -- 'usage' <<<"$stderr"; then + printf 'FAIL %s\n expected a usage error, got: %s\n' "$name" "$stderr" + FAILED=$((FAILED + 1)) + return + fi + printf 'ok %s\n' "$name" + PASSED=$((PASSED + 1)) +} + +assert_usage_error 'no arguments at all is a usage error' +assert_usage_error 'a missing NC_VERSION argument is a usage error' 'ionos-dev-v33' +assert_usage_error 'a fourth argument is a usage error' 'ionos-dev-v33' '33.0.6.2' '1' 'extra' + +assert_fails 'a non-numeric pipeline IID is rejected' \ + 'rc/web-v33-1' '33.0.6.2' 'abc' \ + 'IID' + +echo "=== Chart version and path composition ===" + +assert_ok 'the chart version is 1..' \ + 'rc/web-v32-1' '32.0.6.1.1062' '1234' \ + 'CHART_VERSION=1.32.1234' + +# nc-server cannot compose the full version: CI_PIPELINE_IID does not exist until +# the downstream pipeline starts, so it posts the major downstream instead. +assert_absent 'no chart version is invented when no IID is known yet' \ + 'rc/web-v32-1' '32.0.6.1.1062' '' \ + 'CHART_VERSION' + +# A version with no path would not identify one place to publish to. +assert_absent 'a dev lane gets no chart version even when an IID is known' \ + 'ionos-dev-v33' '33.0.6.2' '7' \ + 'CHART_VERSION' + +assert_absent 'a merge-request branch gets no chart version even when an IID is known' \ + 'feature/HDNEXT-2144-derive-coordinate' '33.0.6.2' '7' \ + 'CHART_VERSION' + +# Uniqueness comes from the path, never from the version. Nothing downstream may +# rely on a chart version alone identifying a chart. +assert_ok 'two trains on one major share a version — first train' \ + 'rc/web-v31-1' '31.0.6.2' '77' \ + 'CHART_VERSION=1.31.77;CHART_PATH=helm/hidrive-next-web-v31-1' + +assert_ok 'two trains on one major share a version — second train, different path' \ + 'rc/web-v31-2' '31.0.6.2' '77' \ + 'CHART_VERSION=1.31.77;CHART_PATH=helm/hidrive-next-web-v31-2' + +# The chart name never varies: helm.sh/chart is emitted from it into immutable +# Deployment selectors, so the train goes in the path and the name stays put. +assert_ok 'the chart name is always hidrive-next' \ + 'rc/web-v33-1' '33.0.6.2' '990' \ + 'CHART_NAME=hidrive-next' + +assert_ok 'the legacy train keeps the same chart name' \ + 'rc/web-3.5' '30.0.7.2' '990' \ + 'CHART_NAME=hidrive-next' + +# 1.30.* through 1.33.* all sort above the legacy 1.7.* line, because semver +# compares the minor numerically. The scheme relies on this deliberately. +assert_ok 'a v30 chart version sorts above the legacy 1.7.x line' \ + 'rc/web-3.5' '30.0.7.2' '1' \ + 'CHART_VERSION=1.30.1' + +echo +if [ $FAILED -ne 0 ]; then + printf '%s passed, %s FAILED\n' "$PASSED" "$FAILED" + exit 1 +fi +printf 'all %s cases passed\n' "$PASSED" From 11ce392f7fb57056521c23193f32f1b431d3795c Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 14 Aug 2026 17:01:01 +0200 Subject: [PATCH 2/2] IONOS(ci): run shellcheck and script tests on pull requests (HDNEXT-2144) Run the derive-coordinate table and shellcheck on pull requests that touch .github/scripts, so the coordinate derivation stays verified without triggering a pipeline or touching a registry. shellcheck is scoped to the two files known to be clean rather than to .github/scripts/**, so that pre-existing findings in detect-app-cache.sh do not block merges. The list should widen as those are cleaned up. There was no shellcheck in this repo before this. Signed-off-by: Misha M.-Kupriyanov --- .github/workflows/test-ci-scripts.yml | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/test-ci-scripts.yml diff --git a/.github/workflows/test-ci-scripts.yml b/.github/workflows/test-ci-scripts.yml new file mode 100644 index 0000000000000..4cfeb69c5959e --- /dev/null +++ b/.github/workflows/test-ci-scripts.yml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: 2026 STRATO AG +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Tests the extracted CI shell scripts under .github/scripts/. +# +# The coordinate derivation these tests cover decides where a build publishes and +# refuses builds whose branch and artifact disagree about the Nextcloud major. +# Running it here means both naming eras and every fail-closed case are verified +# without triggering a pipeline or touching a registry. + +name: Test CI scripts + +on: + pull_request: + paths: + - '.github/scripts/**' + - '.github/workflows/test-ci-scripts.yml' + +permissions: + contents: read + +concurrency: + group: test-ci-scripts-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + + name: shellcheck and unit tests + + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + # shellcheck is preinstalled on the ubuntu-latest runner image. Scoped to + # the scripts that are known clean rather than to .github/scripts/**, so + # that pre-existing findings in older scripts do not block merges. Widen + # the list as those are cleaned up. + - name: Shellcheck + run: | + shellcheck \ + .github/scripts/derive-coordinate.sh \ + .github/scripts/tests/derive-coordinate.test.sh + + # Invoked through `bash` rather than relying on the executable bit, matching + # how hidrive-next-build.yml calls detect-app-cache.sh. + - name: Derive coordinate — unit tests + run: bash .github/scripts/tests/derive-coordinate.test.sh