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
+45 -22
View File
@@ -235,16 +235,33 @@ check("container service startup is suppressed", "/usr/sbin/policy-rc.d" in vali
print()
print("6. canary release gate")
validation = canary.index('"$ROOT/tools/validate-trixie-release.sh"')
package_copy = canary.index('cp "$package_path"')
first_rsync = canary.index("rsync -av")
package_copy = canary.index('cp -n "$package_path"')
first_upload = canary.index("release_upload_package")
check("Trixie validation runs before the repository copy", validation < package_copy)
check("Trixie validation runs before any upload", validation < first_rsync)
check("Trixie validation runs before any upload", validation < first_upload)
# A version is checked before anything is built for it, and again after
# the long gate, immediately before the first upload.
check("a spent version is refused before the build",
canary.index("release_check_new_version") < canary.index("build-release.sh"))
check("and again after the gate",
validation < canary.rindex("release_check_new_version") < first_upload)
check("the published channel is read back afterwards",
canary.index("release_verify_channel canary") > canary.index("release_publish_manifest canary"))
# The gate checks convergence, not just that the package installs: an
# upgraded room ends on the v2 channel with every setting this release
# has, exactly once, and the command a technician runs is safe to run on
# a machine that is already converged.
check("the gate checks a deliberate channel survives",
"'^CHANNEL=canary$'" in validation_script)
'"^CHANNEL=${channel}\\$"' in validation_script)
check("and that channel can be stable as well as canary",
"VALIDATION_CHANNEL" in validation_script
and "stable|canary)" in validation_script)
check("the gate checks the installed version is the package's own",
"/usr/local/lib/ekklesia-classroom/VERSION" in validation_script)
check("and asks the installed updater about it",
"ekklesia-classroom-update --check" in validation_script)
check("and that an upgrade leaves Presentations off",
"grep -q '^PRESENTATIONS_ENABLED=false$'" in validation_script)
check("and that the experiment's channel comes home",
"'^CHANNEL=stable$'" in validation_script)
check("and that every new setting arrived exactly once",
@@ -477,27 +494,33 @@ print("7c-iii. the permanent download addresses")
# .../stable/latest.deb and .../canary/latest.deb. One address per channel
# that never changes, and still no second record of which version is
# current: the pointer is a symlink to the package the manifest names.
# Both channels publish through one function, so the order below is the
# order for both.
release_lib = (root / "tools/release-lib.sh").read_text()
publish = release_lib[release_lib.index("release_publish_manifest() {"):]
publish = publish[:publish.index("\n}\n\n")]
publish_code = "\n".join(line for line in publish.splitlines()
if not line.lstrip().startswith("#"))
for channel, script in (("canary", "update_canary.sh"),
("stable", "update_stable.sh")):
text = (root / script).read_text()
code = "\n".join(line for line in text.splitlines()
if not line.lstrip().startswith("#"))
check(f"{script} moves the {channel} pointer",
f"{channel}/latest.deb" in text or "latest.deb" in code)
check(f"{script} points it at the versioned package",
"ln -sfn ../packages/$package_name" in code)
# Atomic, so nothing downloads a half-written pointer, and after the
# manifest, so the pointer can lag it but never lead it — it can never
# offer a package the channel has not published.
check(f"{script} swaps it by rename rather than in place",
"mv -Tf latest.deb.new latest.deb" in code)
# Against the rsync that actually uploads the manifest, not the echo
# at the end that prints its URL.
upload = f"classroom-updates/{channel}/manifest.json"
check(f"{script} moves it after the manifest goes up",
code.index("latest.deb") > code.index(upload))
check(f"{script} refuses to point at a package that is not there",
"test -f ../packages/$package_name" in code)
check(f"{script} publishes {channel} through the shared function",
f"release_publish_manifest {channel} " in text)
check("the pointer is moved to the versioned package",
"ln -sfn ../packages/$name latest.deb.new" in publish_code)
# Atomic, so nothing downloads a half-written pointer, and after the
# manifest, so the pointer can lag it but never lead it — it can never
# offer a package the channel has not published.
check("it is swapped by rename rather than in place",
"mv -Tf latest.deb.new latest.deb" in publish_code)
check("after the manifest goes up",
publish_code.index("latest.deb") > publish_code.index("$channel/manifest.json\""))
check("and refuses to point at a package that is not there",
"test -f ../packages/$name" in publish_code)
check("the manifest is refused unless the server holds exactly that package",
publish_code.index("sha256sum") < publish_code.index("cat >"))
check("a package upload never replaces a published file",
"--ignore-existing" in release_lib)
check("and neither script writes a version anywhere but the manifest",
"latest.json" not in (root / "update_canary.sh").read_text()
+549
View File
@@ -0,0 +1,549 @@
#!/bin/bash
# Three- and four-part versions, through everything that builds, publishes
# and installs a classroom package.
#
# Nothing here reaches the real update server. The server is a directory,
# ssh and rsync are stand-ins that act on it, and it is served over HTTP
# on the loopback interface so that the real updater, the real channel
# check and the real dpkg are the ones being asked.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
SERVER_PID=""
cleanup() {
[[ -z "$SERVER_PID" ]] || kill "$SERVER_PID" 2>/dev/null
rm -rf "$WORK"
}
trap cleanup EXIT
passed=0
failed=0
check() {
local label="$1"
shift
if "$@" >/dev/null 2>&1; then
echo " ok $label"
passed=$((passed + 1))
else
echo " FAIL $label"
failed=$((failed + 1))
fi
}
refuses() {
! "$@"
}
# ---------------------------------------------------------------------------
# A package that is nothing but its version, the way dpkg will read it.
fake_deb() {
local version="$1" out="$2" stage="$WORK/stage-$version"
mkdir -p "$stage/DEBIAN"
cat > "$stage/DEBIAN/control" <<EOF
Package: ${3:-ekklesia-classroom}
Version: $version
Architecture: amd64
Maintainer: test
Description: test package
EOF
dpkg-deb --root-owner-group --build "$stage" "$out" >/dev/null
}
# ---------------------------------------------------------------------------
# The update server, as a directory.
SERVER="$WORK/server"
LOCAL="$WORK/local"
mkdir -p "$SERVER/packages" "$SERVER/stable" "$SERVER/canary" \
"$LOCAL/packages" "$LOCAL/canary" "$LOCAL/stable" "$WORK/bin"
for version in 0.1.45~hypr1 0.1.45 0.2.2 0.2.5 0.2.6; do
fake_deb "$version" "$SERVER/packages/ekklesia-classroom_${version}_amd64.deb"
cp "$SERVER/packages/ekklesia-classroom_${version}_amd64.deb" "$LOCAL/packages/"
done
# Not a classroom package, and on the real server all the same.
echo v2 > "$SERVER/packages/ekklesia-classroom-v2.deb"
manifest() {
local version="$1" file="${2:-ekklesia-classroom_$1_amd64.deb}" hash
hash="$(sha256sum "$SERVER/packages/$file" | awk '{print $1}')"
printf '{\n "version": "%s",\n "package": "/packages/%s",\n "sha256": "%s",\n "requires_reboot": true\n}\n' \
"$version" "$file" "$hash"
}
manifest 0.2.2 > "$SERVER/stable/manifest.json"
manifest 0.2.6 > "$SERVER/canary/manifest.json"
ln -s ../packages/ekklesia-classroom_0.2.2_amd64.deb "$SERVER/stable/latest.deb"
ln -s ../packages/ekklesia-classroom_0.2.6_amd64.deb "$SERVER/canary/latest.deb"
cp "$SERVER/stable/manifest.json" "$LOCAL/stable/"
cp "$SERVER/canary/manifest.json" "$LOCAL/canary/"
# ssh HOST COMMAND: run COMMAND here. The release scripts only ever talk
# to one host, and it is this directory.
cat > "$WORK/bin/ssh" <<'EOF'
#!/bin/bash
shift
exec bash -c "$*"
EOF
# rsync [-av] [--ignore-existing] -e ssh SOURCE HOST:DEST
cat > "$WORK/bin/rsync" <<'EOF'
#!/bin/bash
keep=0
args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--ignore-existing) keep=1 ;;
-e) shift ;;
-*) ;;
*) args+=("$1") ;;
esac
shift
done
source="${args[0]}"
dest="${args[1]#*:}"
[[ -d "$dest" ]] && dest="$dest/$(basename "$source")"
if [[ "$keep" == 1 && -e "$dest" ]]; then
exit 0
fi
cp "$source" "$dest"
EOF
chmod +x "$WORK/bin/ssh" "$WORK/bin/rsync"
PORT="$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')"
python3 -m http.server --bind 127.0.0.1 --directory "$SERVER" "$PORT" \
>/dev/null 2>&1 &
SERVER_PID=$!
for _ in $(seq 1 50); do
curl -fsS "http://127.0.0.1:$PORT/stable/manifest.json" >/dev/null 2>&1 && break
sleep 0.1
done
echo "0. the library, sourced the way the release scripts source it"
# With nothing in the environment and set -u in force. Every test below
# exports its own RELEASE_* values, and a default that only worked when
# somebody had already set it once stopped the real build dead.
check "it loads under set -u with nothing set" \
env -i PATH="$PATH" bash -c "set -euo pipefail; ROOT=/checkout; source '$ROOT/tools/release-lib.sh'
[[ \$RELEASE_HOST == classroom-control ]]
[[ \$RELEASE_ROOT == /srv/ekklesia/classroom-updates ]]
[[ \$RELEASE_URL == https://updates.riverofglorymn.org ]]
[[ \$RELEASE_LOCAL == /checkout/repo ]]"
check "and build-release.sh gets as far as checking the version" \
bash -c "'$ROOT/tools/build-release.sh' 0.2.06 2>&1 | grep -q 'Invalid version: 0.2.06'"
echo
export PATH="$WORK/bin:$PATH"
export RELEASE_HOST=test-server RELEASE_ROOT="$SERVER" \
RELEASE_URL="http://127.0.0.1:$PORT" RELEASE_LOCAL="$LOCAL"
# shellcheck source=../tools/release-lib.sh
source "$ROOT/tools/release-lib.sh"
GOOD=(0.2.2 0.2.6 0.2.6.1 0.2.6.2 0.2.7 0.3.0 0.2.10 0.2.6.10 1.0.0 10.20.30.40)
BAD=("" 0.2 0.2.6.1.1 0.2.06 00.2.6 0.2.6.0 0.2.6.01 v0.2.6 0.2.6-1 0.2.6~rc1
"0.2.6 " " 0.2.6" 0.2.6. .0.2.6 0..2 0.2.a "0.2.6;reboot" "0.2.6
0.2.7" 0.2.6+1 -0.2.6)
echo "1. what a version may look like"
for version in "${GOOD[@]}"; do
check "the release scripts accept $version" release_version_valid "$version"
done
for version in "${BAD[@]}"; do
check "the release scripts refuse '${version//$'\n'/\\n}'" refuses release_version_valid "$version"
done
# The updater decides with its own copy of the pattern, because it runs
# on a classroom that has no checkout. The two copies are held to the
# same list.
python3 - "$ROOT/scripts/ekklesia-classroom-update" "${GOOD[@]}" -- "${BAD[@]}" <<'PY'
import importlib.machinery
import importlib.util
import sys
loader = importlib.machinery.SourceFileLoader("updater", sys.argv[1])
spec = importlib.util.spec_from_loader("updater", loader)
updater = importlib.util.module_from_spec(spec)
loader.exec_module(updater)
rest = sys.argv[2:]
split = rest.index("--")
good, bad = rest[:split], rest[split + 1:]
wrong = [v for v in good if not updater.valid_version(v)]
wrong += [v for v in bad if updater.valid_version(v)]
wrong += [v for v in (None, 26, 0.26, ["0.2.6"]) if updater.valid_version(v)]
for version in wrong:
print(f" FAIL the updater disagrees about {version!r}")
sys.exit(1 if wrong else 0)
PY
check "the updater agrees about every one of them, and refuses non-strings" test $? -eq 0
echo
echo "2. which version is newer, as dpkg decides it"
ordered=(0.1.45~hypr1 0.1.45 0.2.2 0.2.5 0.2.6 0.2.6.1 0.2.6.2 0.2.6.10 0.2.7 0.2.10 0.3.0)
for ((i = 1; i < ${#ordered[@]}; i++)); do
check "${ordered[i-1]} < ${ordered[i]}" \
release_version_gt "${ordered[i]}" "${ordered[i-1]}"
check "and not the other way round" \
refuses release_version_gt "${ordered[i-1]}" "${ordered[i]}"
done
check "0.2.6.1 is not newer than itself" refuses release_version_gt 0.2.6.1 0.2.6.1
check "the highest of everything published is 0.2.6" \
test "$(release_published_versions | release_highest)" = 0.2.6
echo
echo "3. a version may be published once, and only if it is newer"
check "0.2.6.1 may be published" release_check_new_version 0.2.6.1
check "0.2.7 may be published" release_check_new_version 0.2.7
check "0.2.6 may not: it is already on the server" refuses release_check_new_version 0.2.6
check "0.2.5.1 may not: it is older than 0.2.6" refuses release_check_new_version 0.2.5.1
check "0.2.2 may not" refuses release_check_new_version 0.2.2
check "0.2.06 may not: dpkg would call it 0.2.6" refuses release_check_new_version 0.2.06
check "0.2.6.0 may not" refuses release_check_new_version 0.2.6.0
# Published only in the local copy — an upload that failed after the copy
# was made. The number is spent all the same.
fake_deb 0.2.8 "$LOCAL/packages/ekklesia-classroom_0.2.8_amd64.deb"
check "a version already copied into repo/ is spent too" refuses release_check_new_version 0.2.8
rm "$LOCAL/packages/ekklesia-classroom_0.2.8_amd64.deb"
echo
echo "4. uploading never replaces a published package"
fake_deb 0.2.6.1 "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb"
check "0.2.6.1 uploads and arrives intact" \
release_upload_package "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb"
check "and the server copy is the built one" \
cmp "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" \
"$SERVER/packages/ekklesia-classroom_0.2.6.1_amd64.deb"
before="$(sha256sum "$SERVER/packages/ekklesia-classroom_0.2.6_amd64.deb")"
mkdir -p "$WORK/rebuilt"
fake_deb 0.2.6 "$WORK/rebuilt/ekklesia-classroom_0.2.6_amd64.deb" ekklesia-classroom-rebuilt
check "a different 0.2.6 is refused" \
refuses release_upload_package "$WORK/rebuilt/ekklesia-classroom_0.2.6_amd64.deb"
check "and the published 0.2.6 is untouched" \
test "$(sha256sum "$SERVER/packages/ekklesia-classroom_0.2.6_amd64.deb")" = "$before"
echo
echo "5. a channel points at exactly what was uploaded"
hash_0261="$(sha256sum "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" | awk '{print $1}')"
stable_before="$(cat "$SERVER/stable/manifest.json")"
check "a manifest promising a different checksum is refused" \
refuses release_publish_manifest canary 0.2.6.1 "$(printf '0%.0s' {1..64})"
check "and canary still says 0.2.6" test "$(release_channel_version canary)" = 0.2.6
check "canary is moved to 0.2.6.1" release_publish_manifest canary 0.2.6.1 "$hash_0261"
check "its manifest says so" test "$(release_channel_version canary)" = 0.2.6.1
check "latest.deb follows it" \
test "$(readlink "$SERVER/canary/latest.deb")" = ../packages/ekklesia-classroom_0.2.6.1_amd64.deb
check "what a classroom downloads agrees, over HTTP" release_verify_channel canary 0.2.6.1
check "stable's manifest was not touched" \
test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before"
check "and stable still verifies as 0.2.2" release_verify_channel stable 0.2.2
check "the check notices a channel saying the wrong version" \
refuses release_verify_channel stable 0.2.6.1
check "0.2.6.1 is now spent" refuses release_check_new_version 0.2.6.1
check "0.2.6.2 is next in line" release_check_new_version 0.2.6.2
# A package on the server that says it is a different version from its
# name. The channel check has to read the package, not just its name.
fake_deb 0.2.6 "$SERVER/packages/ekklesia-classroom_0.2.9_amd64.deb"
manifest 0.2.9 > "$SERVER/canary/manifest.json"
ln -sfn ../packages/ekklesia-classroom_0.2.9_amd64.deb "$SERVER/canary/latest.deb"
check "a package that says it is another version fails the channel check" \
refuses release_verify_channel canary 0.2.9
rm "$SERVER/packages/ekklesia-classroom_0.2.9_amd64.deb"
manifest 0.2.6.1 > "$SERVER/canary/manifest.json"
ln -sfn ../packages/ekklesia-classroom_0.2.6.1_amd64.deb "$SERVER/canary/latest.deb"
echo
echo "6. update_stable.sh"
cp "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" "$LOCAL/packages/"
check "refuses a version that is not newer than stable" \
refuses "$ROOT/update_stable.sh" 0.2.2
check "refuses a malformed version" refuses "$ROOT/update_stable.sh" 0.2.06
fake_deb 0.2.9 "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb"
check "refuses a package that was built but never uploaded" \
refuses "$ROOT/update_stable.sh" 0.2.9
rm "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb"
check "stable is still 0.2.2 after all of that" \
test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before"
echo
echo "7. update_canary.sh"
check "without a version it builds nothing and names both choices" \
bash -c "'$ROOT/update_canary.sh' 2>&1 | grep -q '0.2.6.2 .*maintenance' \
&& '$ROOT/update_canary.sh' 2>&1 | grep -q '0.2.7 .*feature' \
&& ! '$ROOT/update_canary.sh'"
check "a published version is refused before anything is built" \
bash -c "! '$ROOT/update_canary.sh' 0.2.6 2>&1 | grep -q 'Validating sources'"
check "a malformed version is refused" refuses "$ROOT/update_canary.sh" 0.2.6.1.1
# ---------------------------------------------------------------------------
echo
echo "8. a classroom's updater, against that server"
# The package database and apt, as far as the updater can tell. The
# stand-in apt installs by reading the version out of the file it is
# given, which is what the real one does.
cat > "$WORK/bin/dpkg-query" <<EOF
#!/bin/bash
[[ -s "$WORK/installed" ]] || exit 1
cat "$WORK/installed"
EOF
cat > "$WORK/bin/apt-get" <<EOF
#!/bin/bash
echo "\$*" >> "$WORK/apt.log"
dpkg-deb --field "\${@: -1}" Version > "$WORK/installed"
EOF
cat > "$WORK/bin/systemctl" <<EOF
#!/bin/bash
echo "\$*" >> "$WORK/systemctl.log"
EOF
chmod +x "$WORK/bin/dpkg-query" "$WORK/bin/apt-get" "$WORK/bin/systemctl"
UPDATE_URL="http://127.0.0.1:$PORT" WORK="$WORK" SERVER="$SERVER" \
python3 - "$ROOT/scripts/ekklesia-classroom-update" <<'PY'
import contextlib
import hashlib
import importlib.machinery
import importlib.util
import io
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
work = Path(os.environ["WORK"])
server = Path(os.environ["SERVER"])
loader = importlib.machinery.SourceFileLoader("updater", sys.argv[1])
spec = importlib.util.spec_from_loader("updater", loader)
updater = importlib.util.module_from_spec(spec)
loader.exec_module(updater)
updater.LOCKFILE = str(work / "update.lock")
updater.CACHE_DIR = str(work / "cache")
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
def device(channel, installed):
config = work / f"device-{channel}.conf"
config.write_text(
"# a classroom\n"
f"UPDATE_URL={os.environ['UPDATE_URL']}\n"
f"CHANNEL={channel}\n"
"PRESENTATIONS_ENABLED=false\n"
"UI_SCALE=1.25\n"
)
updater.CONFIG = str(config)
(work / "installed").write_text(installed)
for log in ("apt.log", "systemctl.log"):
(work / log).unlink(missing_ok=True)
return config
def run(*args):
sys.argv = ["ekklesia-classroom-update", *args]
out = io.StringIO()
with contextlib.redirect_stdout(out):
status = updater.main()
return status, out.getvalue()
def installs():
log = work / "apt.log"
return log.read_text().splitlines() if log.exists() else []
def installed():
return (work / "installed").read_text().strip()
def deb(version, name=None, package="ekklesia-classroom"):
stage = work / f"stage-{version}-{package}"
(stage / "DEBIAN").mkdir(parents=True, exist_ok=True)
(stage / "DEBIAN" / "control").write_text(
f"Package: {package}\nVersion: {version}\nArchitecture: amd64\n"
"Maintainer: test\nDescription: test\n"
)
out = server / "packages" / (name or f"ekklesia-classroom_{version}_amd64.deb")
out.unlink(missing_ok=True)
subprocess.run(["dpkg-deb", "--root-owner-group", "--build", stage, out],
check=True, capture_output=True)
return out
def publish(channel, version, file=None, sha=None, raw=None):
path = server / channel / "manifest.json"
if raw is not None:
path.write_text(raw)
return
file = file or f"ekklesia-classroom_{version}_amd64.deb"
digest = sha or hashlib.sha256((server / "packages" / file).read_bytes()).hexdigest()
path.write_text(json.dumps({
"version": version, "package": f"/packages/{file}",
"sha256": digest, "requires_reboot": True,
}))
publish("stable", "0.2.2")
publish("canary", "0.2.6.1")
config = device("canary", "0.2.6")
before = config.read_text()
status, out = run("--check")
check("a canary device on 0.2.6 sees 0.2.6.1 as an update",
status == 0 and "Update available: 0.2.6 -> 0.2.6.1" in out, out)
check("and --check installs nothing", installs() == [])
status, out = run("--no-reboot")
check("it installs 0.2.6.1", status == 0 and installed() == "0.2.6.1", out)
check("once, from the versioned file",
len(installs()) == 1 and installs()[0].endswith("ekklesia-classroom_0.2.6.1_amd64.deb"),
installs())
check("--no-reboot is respected", not (work / "systemctl.log").exists())
check("its device.conf is exactly as it was", config.read_text() == before)
for attempt in range(3):
run("--no-reboot")
check("a device on 0.2.6.1 does not install 0.2.6.1 again",
len(installs()) == 1, installs())
status, out = run()
check("nor reboot for it", "Already up to date" in out
and not (work / "systemctl.log").exists(), out)
deb("0.2.7")
publish("canary", "0.2.7")
status, out = run()
check("a device on 0.2.6.1 recognises 0.2.7 as newer and installs it",
status == 0 and installed() == "0.2.7", out)
check("and reboots, because the manifest asks it to",
(work / "systemctl.log").read_text().strip() == "reboot")
publish("canary", "0.2.6.1")
device("canary", "0.2.7")
status, out = run()
check("a device on 0.2.7 is not moved back to 0.2.6.1",
"Already up to date" in out and installs() == [], out)
# Three parts, exactly as before.
publish("canary", "0.2.6")
device("canary", "0.2.5")
status, out = run("--no-reboot")
check("a device on 0.2.5 still updates to 0.2.6", installed() == "0.2.6", out)
publish("canary", "0.2.6.1")
# The channels.
config = device("stable", "0.2.2")
status, out = run("--no-reboot")
check("a stable device on 0.2.2 reads the stable manifest",
f"{os.environ['UPDATE_URL']}/stable/manifest.json" in out, out)
check("and does not install the canary release",
installs() == [] and installed() == "0.2.2" and "Already up to date" in out, out)
deb("0.2.6.2")
publish("stable", "0.2.6.2")
status, out = run("--no-reboot")
check("a stable device goes from 0.2.2 straight to a four-part release when stable has one",
installed() == "0.2.6.2", out)
publish("stable", "0.2.2")
(server / "packages" / "ekklesia-classroom_0.2.6.2_amd64.deb").unlink()
# A room with no package installed at all reports 0.0.0 and takes anything.
device("canary", "")
status, out = run("--check")
check("a machine with nothing installed is offered 0.2.6.1",
"0.0.0 -> 0.2.6.1" in out, out)
# Malformed manifests: nothing downloaded, nothing installed, and a
# failure the timer's journal will show.
sha = hashlib.sha256((server / "packages" / "ekklesia-classroom_0.2.6.1_amd64.deb").read_bytes()).hexdigest()
bad = {
"a version with a suffix": {"version": "0.2.6.1-1"},
"a version with five parts": {"version": "0.2.6.1.1"},
"a version with a leading zero": {"version": "0.2.06"},
"a version that is a number": {"version": 0.3},
"an empty version": {"version": ""},
"a version with a shell in it": {"version": "0.2.7;reboot"},
"no version at all": {"version": None},
"a package for a different version": {"version": "0.2.7",
"package": "/packages/ekklesia-classroom_0.2.6.1_amd64.deb"},
"a package outside /packages": {"version": "0.2.7",
"package": "/../ekklesia-classroom_0.2.7_amd64.deb"},
"a checksum that is not one": {"version": "0.2.7", "sha256": "abc"},
}
for label, fields in bad.items():
manifest = {"version": "0.2.7",
"package": "/packages/ekklesia-classroom_0.2.7_amd64.deb",
"sha256": sha, "requires_reboot": True}
manifest.update(fields)
if manifest["version"] is None:
del manifest["version"]
publish("canary", None, raw=json.dumps(manifest))
device("canary", "0.2.6")
status, out = run("--no-reboot")
check(f"refused: {label}",
status == 1 and installs() == [] and installed() == "0.2.6", out)
for label, raw in (("a manifest that is not JSON", "<html>502</html>"),
("a manifest that is a list", "[]")):
publish("canary", None, raw=raw)
device("canary", "0.2.6")
status, out = run("--no-reboot")
check(f"refused: {label}", status == 1 and installs() == [], out)
# The file arrives, matches its checksum, and is the wrong package.
shutil.rmtree(updater.CACHE_DIR, ignore_errors=True)
deb("0.2.6", name="ekklesia-classroom_0.2.7_amd64.deb")
publish("canary", "0.2.7")
device("canary", "0.2.6")
status, out = run("--no-reboot")
check("a 0.2.7 file that installs as 0.2.6 is refused",
status == 1 and installs() == [] and "not ekklesia-classroom 0.2.7" in out, out)
check("and not left in the cache",
not list(Path(updater.CACHE_DIR).glob("*0.2.7*")))
deb("0.2.7", package="something-else", name="ekklesia-classroom_0.2.7_amd64.deb")
publish("canary", "0.2.7")
status, out = run("--no-reboot")
check("so is a file that is some other package", status == 1 and installs() == [], out)
deb("0.2.7")
publish("canary", "0.2.7", sha="0" * 64)
status, out = run("--no-reboot")
check("and a file whose checksum is wrong",
status == 1 and installs() == [] and "verification FAILED" in out, out)
publish("canary", "0.2.6.1")
print(f"\n updater: {passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
check "every updater case above passed" test $? -eq 0
echo
echo "$passed passed, $failed failed"
[[ "$failed" -eq 0 ]]