feat: four-part versions, and a published version is never used again

0.2.6.1 is a maintenance release of 0.2.6. The build refused four
parts, update_canary.sh's automatic bump could not add one to "6.1", and
neither publish script would have noticed a version being published
twice — which is what happened to 0.2.6 this morning: it was rebuilt
with the Presentations hardening and uploaded over the first 0.2.6, and
a room that had installed the first can never be offered the second.

One release library now holds the version pattern (three or four parts,
no leading zeros, no .0 maintenance), orders by dpkg --compare-versions,
refuses a version that has ever been published or is not newer than all
of them, uploads with --ignore-existing and checks the server's
checksum, writes a manifest only for a package the server holds, and
reads the channel back over HTTPS afterwards. update_canary.sh takes the
version explicitly; update_stable.sh refuses to go backwards.

The classroom updater refuses a manifest whose version, package name or
checksum is malformed, and refuses a download whose own control file is
not the version the manifest promised, so a room can never loop on
reinstalling and rebooting.

tests/test-release-versions.sh: 87 checks against a stand-in server and
real dpkg. The Trixie gate now also rehearses a stable room: 0.2.2 to an
interim 0.2.6.1 upgraded with settings, channel and Presentations=false
intact, and the installed updater answering correctly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-23 18:19:37 -05:00
co-authored by Claude Opus 5.5
parent 6f2d169355
commit 690ef95aff
9 changed files with 1260 additions and 147 deletions
+14 -5
View File
@@ -11,11 +11,10 @@ fi
VERSION="$1"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid version: $VERSION"
echo "Expected MAJOR.MINOR.PATCH, for example 0.1.6"
exit 1
fi
# Three parts for a feature release, four for a maintenance release of
# one: 0.2.6, then 0.2.6.1. See tools/release-lib.sh.
source "$ROOT/tools/release-lib.sh"
release_require_version "$VERSION" || exit 1
STAGE="$ROOT/packaging/root"
OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb"
@@ -568,6 +567,16 @@ dpkg-deb \
--root-owner-group \
--build "$STAGE" "$OUTPUT"
# The version dpkg reads out of the file is the one a classroom will
# report, so it is checked rather than assumed to have survived the sed.
built_version="$(release_package_version "$OUTPUT")"
if [[ "$built_version" != "$VERSION" ]]; then
echo "The package says it is $built_version, not $VERSION" >&2
rm -f "$OUTPUT"
exit 1
fi
echo
echo "Built:"
echo "$OUTPUT"
+286
View File
@@ -0,0 +1,286 @@
# Release helpers shared by tools/build-release.sh, update_canary.sh and
# update_stable.sh. Sourced, not run.
#
# VERSIONS.
#
# 0.2.6 a feature release
# 0.2.6.1 the first maintenance release of 0.2.6
# 0.2.6.2 the second
# 0.2.7 the next feature release
#
# Three or four dot-separated numbers, and nothing else. No leading zeros,
# because dpkg compares 0.2.06 and 0.2.6 as the SAME version, and two
# packages that dpkg thinks are equal are one package too many. No fourth
# part of 0: maintenance releases count from 1, and 0.2.6.0 would sort
# after 0.2.6 while claiming to be it.
#
# Ordering is dpkg's, always, through `dpkg --compare-versions`. That is
# what a classroom's updater and apt use to decide what is newer, so
# anything else here could only ever disagree with them.
#
# PUBLISHED IS PERMANENT.
#
# 0.2.6 was rebuilt and uploaded a second time under the same version, and
# the second upload replaced the first on the server. A room that had
# installed the first one could never be offered the second: the updater
# only installs a version newer than its own, and they were equal. So a
# version number, once it has been on the server, is never used again,
# and a package file on the server is never written twice.
RELEASE_HOST="${RELEASE_HOST:-classroom-control}"
RELEASE_ROOT="${RELEASE_ROOT:-/srv/ekklesia/classroom-updates}"
RELEASE_URL="${RELEASE_URL:-https://updates.riverofglorymn.org}"
# The local copy of what was published. ROOT is the checkout, set by
# whichever script sourced this.
RELEASE_LOCAL="${RELEASE_LOCAL:-$ROOT/repo}"
# The same pattern is in scripts/ekklesia-classroom-update, which is what
# a classroom checks a manifest against. tests/test-release-versions.sh
# holds both to the same list of good and bad versions.
RELEASE_VERSION_PATTERN='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.[1-9][0-9]*)?$'
release_version_valid() {
[[ "$1" =~ $RELEASE_VERSION_PATTERN ]]
}
release_require_version() {
if ! release_version_valid "$1"; then
echo "Invalid version: $1" >&2
echo "Expected MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH.MAINTENANCE," >&2
echo "for example 0.2.7 or 0.2.6.1" >&2
return 1
fi
}
release_version_gt() {
dpkg --compare-versions "$1" gt "$2"
}
release_package_name() {
printf 'ekklesia-classroom_%s_amd64.deb\n' "$1"
}
# The version dpkg itself reads out of a built package, which is the one a
# classroom will report after installing it.
release_package_version() {
dpkg-deb --field "$1" Version
}
release_remote() {
ssh "$RELEASE_HOST" "$@"
}
# Every version that has ever been published, one per line, read from the
# package file names on the server and in the local copy of it. Names that
# are not a classroom package (ekklesia-classroom-v2.deb) are skipped;
# experiment builds such as 0.1.45~hypr1 are kept, because dpkg orders
# them and they were published.
release_published_versions() {
{
release_remote "ls -1 '$RELEASE_ROOT/packages'" || return 1
ls -1 "$RELEASE_LOCAL/packages" 2>/dev/null || true
} | sed -n 's/^ekklesia-classroom_\(.*\)_amd64\.deb$/\1/p' | sort -u
}
release_highest() {
local highest="" version
while read -r version; do
[[ -n "$version" ]] || continue
if [[ -z "$highest" ]] || release_version_gt "$version" "$highest"; then
highest="$version"
fi
done
printf '%s\n' "$highest"
}
# The version a channel's manifest on the server names, or nothing.
release_channel_version() {
release_remote "cat '$RELEASE_ROOT/$1/manifest.json' 2>/dev/null" \
| python3 -c 'import json, sys
try:
print(json.load(sys.stdin)["version"])
except Exception:
pass'
}
# May VERSION be published as a new package at all? It has to be valid,
# its file name must never have been used, and it must be newer than every
# package ever published, so that it is newer than whatever any room is
# running whichever channel that room is on.
release_check_new_version() {
local version="$1" name published highest
release_require_version "$version" || return 1
name="$(release_package_name "$version")"
published="$(release_published_versions)" || {
echo "Could not list the published packages on $RELEASE_HOST." >&2
return 1
}
if grep -qxF -- "$version" <<< "$published"; then
echo "$version has already been published." >&2
echo "A published version is never rebuilt or replaced. Use the next one." >&2
return 1
fi
if release_remote "test -e '$RELEASE_ROOT/packages/$name'"; then
echo "$name already exists on $RELEASE_HOST." >&2
return 1
fi
highest="$(release_highest <<< "$published")"
if [[ -n "$highest" ]] && ! release_version_gt "$version" "$highest"; then
echo "$version is not newer than $highest, the highest version already published." >&2
echo "Every release must sort after every one before it." >&2
return 1
fi
}
# Upload a package that has never been on the server, and prove that what
# arrived is what was built. --ignore-existing is the second lock on the
# rule above: even if the check were wrong, rsync would not replace a
# published file.
release_upload_package() {
local package="$1" name local_hash remote_hash
name="$(basename "$package")"
local_hash="$(sha256sum "$package" | awk '{print $1}')"
rsync -av --ignore-existing -e ssh \
"$package" "$RELEASE_HOST:$RELEASE_ROOT/packages/"
remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')"
if [[ "$remote_hash" != "$local_hash" ]]; then
echo "The package on $RELEASE_HOST does not match the one built here." >&2
echo " built: $local_hash" >&2
echo " on server: ${remote_hash:-missing}" >&2
return 1
fi
}
# Write CHANNEL's manifest for VERSION locally, then upload it, then move
# the channel's latest.deb. The package must already be on the server with
# exactly the checksum the manifest is about to promise.
release_publish_manifest() {
local channel="$1" version="$2" hash="$3" name remote_hash
name="$(release_package_name "$version")"
remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')"
if [[ "$remote_hash" != "$hash" ]]; then
echo "Refusing to point $channel at $name:" >&2
echo " the manifest would say $hash" >&2
echo " the server has ${remote_hash:-no such file}" >&2
return 1
fi
mkdir -p "$RELEASE_LOCAL/$channel"
cat > "$RELEASE_LOCAL/$channel/manifest.json" <<EOF
{
"version": "$version",
"package": "/packages/$name",
"sha256": "$hash",
"requires_reboot": true
}
EOF
# Manifest deliberately goes LAST, after the package it names.
rsync -av -e ssh \
"$RELEASE_LOCAL/$channel/manifest.json" \
"$RELEASE_HOST:$RELEASE_ROOT/$channel/manifest.json"
# THE PERMANENT URL.
#
# .../CHANNEL/latest.deb is a symlink beside the manifest pointing at
# the versioned package the manifest names, so there is one download
# address that never changes and still no second place recording which
# version is current. The manifest remains the only answer to that
# question.
#
# It is swapped AFTER the manifest, and by rename, for two reasons. A
# rename is atomic, so nothing ever downloads a half-written pointer;
# and going last means latest.deb can only ever lag the manifest by a
# moment, never lead it — it can never offer a package the channel has
# not published. The versioned file itself is never touched again once
# uploaded.
release_remote "
set -e
cd '$RELEASE_ROOT/$channel'
test -f ../packages/$name
ln -sfn ../packages/$name latest.deb.new
mv -Tf latest.deb.new latest.deb
"
}
# What a classroom on CHANNEL actually sees, read over HTTPS the way the
# updater reads it: the manifest, the versioned package it names, and the
# permanent latest.deb, all agreeing with each other and with EXPECTED if
# one is given. Read-only; safe to run against stable at any time.
#
# A subshell, so the temporary directory is removed however it returns.
release_verify_channel() (
channel="$1"
expected="${2:-}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
manifest="$(curl -fsS -H 'Cache-Control: no-cache' \
"$RELEASE_URL/$channel/manifest.json")" || {
echo " FAIL $channel: manifest could not be fetched" >&2
return 1
}
read -r version package hash < <(python3 -c 'import json, sys
m = json.loads(sys.argv[1])
print(m["version"], m["package"], m["sha256"])' "$manifest")
name="$(release_package_name "$version")"
if [[ -n "$expected" && "$version" != "$expected" ]]; then
echo " FAIL $channel: manifest says $version, expected $expected" >&2
return 1
fi
if [[ "$package" != "/packages/$name" ]]; then
echo " FAIL $channel: manifest names $package for version $version" >&2
return 1
fi
curl -fsS -o "$work/versioned.deb" "$RELEASE_URL$package" || {
echo " FAIL $channel: $package could not be downloaded" >&2
return 1
}
curl -fsSL -H 'Cache-Control: no-cache' -o "$work/latest.deb" \
"$RELEASE_URL/$channel/latest.deb" || {
echo " FAIL $channel: latest.deb could not be downloaded" >&2
return 1
}
got_hash="$(sha256sum "$work/versioned.deb" | awk '{print $1}')"
latest_hash="$(sha256sum "$work/latest.deb" | awk '{print $1}')"
deb_version="$(release_package_version "$work/versioned.deb")"
if [[ "$got_hash" != "$hash" ]]; then
echo " FAIL $channel: $name has sha256 $got_hash, manifest says $hash" >&2
return 1
fi
if [[ "$latest_hash" != "$hash" ]]; then
echo " FAIL $channel: latest.deb has sha256 $latest_hash, manifest says $hash" >&2
return 1
fi
if [[ "$deb_version" != "$version" ]]; then
echo " FAIL $channel: the package itself says $deb_version, manifest says $version" >&2
return 1
fi
echo " ok $channel: $version, $name, sha256 $hash, latest.deb agrees"
)
+102 -5
View File
@@ -18,6 +18,16 @@ if [[ $# -eq 2 ]]; then
[[ -f "$previous" ]] || { echo "Package not found: $previous" >&2; exit 1; }
fi
# The channel the upgraded room is on. canary by default, which is the
# machine a new release goes to first; stable to rehearse the day the
# desktops are moved, from the release they are actually on.
channel="${VALIDATION_CHANNEL:-canary}"
case "$channel" in
stable|canary) ;;
*) echo "VALIDATION_CHANNEL must be stable or canary" >&2; exit 2 ;;
esac
echo "Validating $(basename "$candidate") on Debian Trixie"
docker_args=(
@@ -41,7 +51,7 @@ fi
# instead teaches everybody to re-run it until it passes.
timeout --signal=TERM --kill-after=15s 2700s \
docker "${docker_args[@]}" debian:trixie-slim \
bash -s -- "${previous:+/packages/previous.deb}" <<'CONTAINER'
bash -s -- "${previous:+/packages/previous.deb}" "$channel" <<'CONTAINER'
set -Eeuo pipefail
validation_failure() {
@@ -87,6 +97,7 @@ run_logged() {
run_logged apt-update 300 apt-get update -qq
previous="${1:-}"
channel="${2:-canary}"
if [[ -n "$previous" ]]; then
run_logged previous-install 1200 apt-get install -y -qq "$previous"
@@ -94,7 +105,7 @@ if [[ -n "$previous" ]]; then
cat > /etc/ekklesia-classroom/device.conf <<'CONFIG'
# Configuration carried forward from 0.1.36
UPDATE_URL=https://updates.riverofglorymn.org
CHANNEL=canary
CHANNEL=@CHANNEL@
DVD_ENABLED=false
UI_SCALE=1.25
MQTT_ENABLED=true
@@ -104,10 +115,28 @@ MQTT_USERNAME=classroom
MQTT_PASSWORD=preserve-me
CUSTOM_SETTING=preserve-me-too
CONFIG
sed -i "s/@CHANNEL@/${channel}/" /etc/ekklesia-classroom/device.conf
previous_version="$(dpkg-query -W -f='${Version}' ekklesia-classroom)"
fi
run_logged candidate-install 1200 apt-get install -y -qq /packages/candidate.deb
# THE VERSION, AS THE MACHINE REPORTS IT.
#
# Three places say which release a classroom is running: dpkg's database,
# which the updater compares against the manifest; the VERSION file the
# package carries; and the file name the release was published under.
# They have to be one number, or a room either never updates or updates
# for ever.
candidate_version="$(dpkg-deb --field /packages/candidate.deb Version)"
[[ "$(dpkg-query -W -f='${Version}' ekklesia-classroom)" == "$candidate_version" ]]
[[ "$(cat /usr/local/lib/ekklesia-classroom/VERSION)" == "$candidate_version" ]]
echo "installed ekklesia-classroom $candidate_version${previous_version:+ over $previous_version}"
if [[ -n "${previous_version:-}" ]]; then
dpkg --compare-versions "$candidate_version" gt "$previous_version"
fi
command -v nmtui >/dev/null
command -v lpadmin >/dev/null
command -v lpinfo >/dev/null
@@ -376,6 +405,64 @@ timeout --signal=TERM --kill-after=5s 45s classroom-provision \
>/tmp/provision-second.log
grep -q 'Elmer is already correctly configured' /tmp/provision-second.log
# THE UPDATER THIS PACKAGE INSTALLED, AGAINST A MANIFEST.
#
# Served from this container, so nothing reaches the real update server.
# The installed version has to be "up to date" against a manifest naming
# itself — or the room reinstalls and reboots at every check — and has to
# see the next feature release as newer.
mkdir -p /tmp/updates/packages "/tmp/updates/$channel"
cp /packages/candidate.deb "/tmp/updates/packages/ekklesia-classroom_${candidate_version}_amd64.deb"
serve_manifest() {
python3 - "$1" "$channel" <<'MANIFEST'
import hashlib, json, sys
version, channel = sys.argv[1], sys.argv[2]
name = f"ekklesia-classroom_{version}_amd64.deb"
try:
digest = hashlib.sha256(open(f"/tmp/updates/packages/{name}", "rb").read()).hexdigest()
except FileNotFoundError:
digest = "0" * 64
json.dump({"version": version, "package": f"/packages/{name}",
"sha256": digest, "requires_reboot": True},
open(f"/tmp/updates/{channel}/manifest.json", "w"))
MANIFEST
}
python3 -m http.server --bind 127.0.0.1 --directory /tmp/updates 8765 \
>/tmp/updates.log 2>&1 &
updates_server=$!
sleep 1
cp /etc/ekklesia-classroom/device.conf /tmp/device.conf.before-updater
sed -i 's|^UPDATE_URL=.*|UPDATE_URL=http://127.0.0.1:8765|' \
/etc/ekklesia-classroom/device.conf
serve_manifest "$candidate_version"
ekklesia-classroom-update --check >/tmp/updater-same.log 2>&1
grep -q "Already up to date" /tmp/updater-same.log
# The next milestone after whatever this is, and a release every
# classroom has long since passed.
IFS=. read -r major minor _ <<< "$candidate_version"
next_version="${major}.$((minor + 1)).0"
serve_manifest "$next_version"
ekklesia-classroom-update --check >/tmp/updater-next.log 2>&1
grep -q "Update available: $candidate_version -> $next_version" /tmp/updater-next.log
serve_manifest 0.2.2
ekklesia-classroom-update --check >/tmp/updater-older.log 2>&1
grep -q "Already up to date" /tmp/updater-older.log
echo '{"version": "0.2.06", "package": "/packages/x.deb", "sha256": "x"}' \
> "/tmp/updates/$channel/manifest.json"
! ekklesia-classroom-update --check >/tmp/updater-bad.log 2>&1
grep -q "invalid version" /tmp/updater-bad.log
kill "$updates_server"
cp /tmp/device.conf.before-updater /etc/ekklesia-classroom/device.conf
if [[ -n "$previous" ]]; then
# The room's own values, untouched by the upgrade.
grep -q '^DVD_ENABLED=false$' /etc/ekklesia-classroom/device.conf
@@ -386,7 +473,17 @@ if [[ -n "$previous" ]]; then
# The channel a room was put on deliberately survives the upgrade.
# canary is where the next development release goes, and the Plasma
# classroom is the stable line now, so nothing here has to move.
grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf
grep -q "^CHANNEL=${channel}\$" /etc/ekklesia-classroom/device.conf
# An optional feature an existing room never had stays off. A
# Presentations launcher appearing on the desktops' panels by itself
# would be an upgrade changing a classroom, which upgrades do not do.
[[ "$(grep -c '^PRESENTATIONS_ENABLED=' /etc/ekklesia-classroom/device.conf)" -eq 1 ]]
grep -q '^PRESENTATIONS_ENABLED=false$' /etc/ekklesia-classroom/device.conf
classroom-presentations 2>&1 | grep -q 'PRESENTATIONS_ENABLED is false'
classroom-desktop-layout >/dev/null 2>&1 || true
grep -q 'var PRESENTATIONS = false;' \
/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js
# And a room that was carried through the experiment on its own
# channel comes home, because that channel is the obsolete one now.
@@ -395,7 +492,7 @@ if [[ -n "$previous" ]]; then
grep -q '^CHANNEL=stable$' /etc/ekklesia-classroom/device.conf
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf
sed -i 's/^CHANNEL=.*/CHANNEL=canary/' /etc/ekklesia-classroom/device.conf
sed -i "s/^CHANNEL=.*/CHANNEL=${channel}/" /etc/ekklesia-classroom/device.conf
# Every setting this release has that the room predates, appended
# exactly once. classroom-migrate hands device.conf to the shared
@@ -415,7 +512,7 @@ if [[ -n "$previous" ]]; then
done
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf
grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf
grep -q "^CHANNEL=${channel}\$" /etc/ekklesia-classroom/device.conf
# And by hand, on a machine that is already converged: the command a
# technician runs must be safe to run twice.