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()