From cfae6e990a2399e1faebb4235282e7225d126f60 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 14:41:18 +0200 Subject: [PATCH] Versions-Arithmetik kennt eine Decke --- deploy/lib/release.sh | 64 ++++++++++++++++++++--- tests/Feature/ReleaseComparisonTest.php | 69 +++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 8 deletions(-) diff --git a/deploy/lib/release.sh b/deploy/lib/release.sh index ef53480..4cd9117 100644 --- a/deploy/lib/release.sh +++ b/deploy/lib/release.sh @@ -51,7 +51,12 @@ release_version_gt() { [[ "$1" != "$2" ]] && [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" == "$1" ]] } -# release_newest_tag — the highest v* tag by version order, or nothing. +# release_newest_tag [CEILING] — the highest v* tag by version order, or nothing. +# +# Mit CEILING: der hoechste Tag, der NICHT darueber liegt. Das ist die ganze +# Mechanik des Festnagelns — der Agent fragt nach dem neuesten Tag, den er +# nehmen DARF, und `behind`, der Knopf und das Wartungsfenster fallen alle aus +# dieser einen Antwort. # # Deliberately not `git tag … | head -1`. head exits after the first line, git # gets SIGPIPE, and under `set -o pipefail` that non-zero status leaves the @@ -59,9 +64,13 @@ release_version_gt() { # and the same trap install-agent.sh already carries a warning about. A read # loop over a process substitution is not a pipeline and cannot do it. release_newest_tag() { - local tag + local ceiling="${1-}" tag + while read -r tag; do [[ -n "$tag" ]] || continue + if [[ -n "$ceiling" ]] && release_version_gt "${tag#v}" "${ceiling#v}"; then + continue + fi printf '%s' "$tag" return 0 done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null) @@ -69,31 +78,70 @@ release_newest_tag() { return 0 } -# release_tags_ahead VERSION — how many v* tags are higher than VERSION. +# release_tags_ahead VERSION [CEILING] — how many v* tags are higher than +# VERSION, counting only up to CEILING. # # Lives here rather than inline in the agent because it has to be testable. The # inline version killed the update agent on every tick: the loop body was # `release_version_gt … && echo`, so the LAST iteration — almost always a tag # that is not newer — left the loop with a non-zero status, `pipefail` carried # it out of the pipeline, the command substitution inherited it, and `set -e` -# ended the agent before it could write a status. The console then showed a -# check that never finished and a last-checked time frozen at the deployment. +# ended the agent before it could write a status. # # `if` rather than `&&` is the whole fix: an if whose condition is false still -# returns 0. Anything added here must keep that property. +# returns 0. Anything added here must keep that property — auch die +# Decken-Pruefung unten ist deshalb ein verschachteltes `if` und kein `&&`. release_tags_ahead() { - local current="$1" tag count=0 + local current="$1" ceiling="${2-}" tag count=0 while read -r tag; do [[ -n "$tag" ]] || continue if release_version_gt "${tag#v}" "$current"; then - count=$((count + 1)) + if [[ -z "$ceiling" ]] || ! release_version_gt "${tag#v}" "${ceiling#v}"; then + count=$((count + 1)) + fi fi done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null) printf '%s' "$count" } +# release_tag_exists TAG — true when TAG is a real tag in this checkout. +# +# Form und Existenz sind zwei Fragen. Eine Decke, die bloss AUSSIEHT wie eine +# Version, ist keine: der Release-Prozess loescht einen falschen Tag und +# ueberspringt die Nummer, eine Decke kann also ohne Zutun dessen, der sie +# gesetzt hat, ins Leere zeigen. +release_tag_exists() { + [[ -n "${1-}" ]] || return 1 + git rev-parse -q --verify "refs/tags/${1}^{commit}" >/dev/null 2>&1 +} + +# release_tags_from VERSION [LIMIT] — die Tags, auf die festgenagelt werden +# darf: VERSION selbst und alles darueber, neueste zuerst, einer je Zeile. +# +# VERSION ist EINGESCHLOSSEN, und das ist der haeufigste Fall: „hier +# einfrieren, nichts Neues nehmen" ist das, was neun von zehn Servern wollen, +# waehrend der zehnte die neue Fassung bekommt. +# +# LIMIT, weil die Statusdatei sonst mit der Tag-Historie mitwaechst. +release_tags_from() { + local current="$1" limit="${2:-20}" tag count=0 out='' + + while read -r tag; do + [[ -n "$tag" ]] || continue + if [[ "${tag#v}" == "$current" ]] || release_version_gt "${tag#v}" "$current"; then + out+="${tag}"$'\n' + count=$((count + 1)) + if (( count >= limit )); then + break + fi + fi + done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null) + + printf '%s' "$out" +} + release_remember() { # release_remember MODE SOURCE mkdir -p "$(dirname "$MODE_FILE")" printf '%s' "$1" > "$MODE_FILE" diff --git a/tests/Feature/ReleaseComparisonTest.php b/tests/Feature/ReleaseComparisonTest.php index 1634ddd..4042b1a 100644 --- a/tests/Feature/ReleaseComparisonTest.php +++ b/tests/Feature/ReleaseComparisonTest.php @@ -143,3 +143,72 @@ it('answers the pairwise comparison the same way, and exits cleanly either way', ->and(trim($process->getOutput()))->toBe($expected, "{$a} > {$b}"); } }); + +it('stops at the ceiling instead of taking the newest tag', function () { + // Der Kern des Festnagelns: v1.8.1 existiert, darf aber nicht genommen + // werden, weil die Decke auf v1.8.0 steht. + $p = runRelease('release_newest_tag v1.8.0', ['v1.7.3', 'v1.8.0', 'v1.8.1']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe('v1.8.0'); +}); + +it('takes the newest tag when no ceiling is given', function () { + // Das bisherige Verhalten muss unveraendert bleiben — ohne Decke aendert + // sich nichts an einem Server, der nie festgenagelt wurde. + $p = runRelease('release_newest_tag', ['v1.7.3', 'v1.8.0', 'v1.8.1']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe('v1.8.1'); +}); + +it('answers nothing when every tag is above the ceiling', function () { + $p = runRelease('release_newest_tag v1.0.0', ['v1.7.3', 'v1.8.1']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe(''); +}); + +it('counts only the tags up to the ceiling as ahead', function () { + // Ausgeliefert 1.7.3, Decke v1.8.0, vorhanden bis v1.8.2: genau EINE + // Aktualisierung ist verfuegbar, nicht drei. + $p = runRelease('release_tags_ahead 1.7.3 v1.8.0', ['v1.7.3', 'v1.8.0', 'v1.8.1', 'v1.8.2']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe('1'); +}); + +it('counts nothing ahead when the ceiling equals what is deployed', function () { + // Der haeufigste Fall: "hier einfrieren". Muss sich wie "aktuell" + // rechnen, sonst bietet die Konsole etwas an, das nie installiert wird. + $p = runRelease('release_tags_ahead 1.8.0 v1.8.0', ['v1.8.0', 'v1.8.1', 'v1.8.2']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe('0'); +}); + +it('knows whether a tag really exists', function () { + // Form und Existenz sind zwei Fragen. Der Release-Prozess loescht einen + // falschen Tag und ueberspringt die Nummer — eine Decke kann also ohne + // Zutun ihres Setzers ins Leere zeigen. + $p = runRelease('release_tag_exists v1.8.0 && echo ja; release_tag_exists v9.9.9 || echo nein', ['v1.8.0']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe("ja\nnein"); +}); + +it('offers the deployed version itself as a ceiling, newest first', function () { + // Die ausgelieferte Version steht MIT in der Liste: "einfrieren, nichts + // Neues nehmen" ist das, was neun von zehn Servern brauchen. + $p = runRelease('release_tags_from 1.8.0', ['v1.7.3', 'v1.8.0', 'v1.8.1']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe("v1.8.1\nv1.8.0"); +}); + +it('caps the offered list', function () { + $p = runRelease('release_tags_from 1.0.0 2', ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v1.3.0']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe("v1.3.0\nv1.2.0"); +});