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]>
287 lines
10 KiB
Bash
287 lines
10 KiB
Bash
# 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"
|
|
)
|