Versions-Arithmetik kennt eine Decke

feat/versandtakt
nexxo 2026-08-04 14:41:18 +02:00
parent d4a9166c7a
commit cfae6e990a
2 changed files with 125 additions and 8 deletions

View File

@ -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"

View File

@ -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");
});