Files
Classroom_Desktop/tests/test-release-versions.sh
T
ekklesiaandClaude Opus 5.5 3c40e849ea fix: an update reboots the computer only once nobody is using it
The update check has been daily at 03:00, Persistent, since 0.1.5 — the
lab's installed stable machine logged it at 03:01 and 03:04 on successive
days — and it stays so. What changes is the reboot after an update. It
happened the moment the update was installed: harmless at 03:00, and a
room restarting itself mid-morning when a computer switched off at 03:00
caught up after being switched on.

Now the updater installs, with every check it had (the device's own
channel, a newer version, the manifest's SHA-256, name and version), and
starts classroom-reboot-when-idle, which reboots once no input device has
produced an event for 30 minutes and no sound card has played for as
long. At 03:00 that is about 03:30; switched off first, the next start is
on the new version. The service has no [Install] and nothing enables it.

The release gate checks after every install and upgrade that the timer is
enabled, daily at 03:00, Persistent, with no boot or repeat trigger and
no drop-in, and that the updater no longer reboots by itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 22:56:08 -05:00

553 lines
21 KiB
Bash
Executable File

#!/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)
# Since 0.2.6.5 not there and then: the reboot is handed to a service that
# waits until nobody is using the computer (classroom-reboot-when-idle).
check("and asks for the reboot the manifest wants, once nobody is using the computer",
(work / "systemctl.log").read_text().strip()
== "start --no-block ekklesia-classroom-reboot-when-idle.service")
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 ]]