Files
ekklesiaandClaude Opus 5.5 14291fde37
CI / checks (push) Failing after 2m13s
CI / package and container gate (push) Skipped
fix: a release job uses only the key it is given, never the runner's own
Measured on a throwaway Gitea: with the key secret unset, Preview's
publish job would have fallen back to the runner machine's own ssh
configuration — on the development VM, the production publish key — and
published a throwaway 9.9.9 to the real server. It was cancelled before
it ran. And with the key set, the job wrote over the runner account's own
~/.ssh/config and known_hosts.

Now, under Actions, a job without RELEASE_SSH_KEY stops; with it, the key,
its known_hosts and an ssh configuration live in the job's own directory
and every ssh and rsync goes through `ssh -F` (RELEASE_SSH_CONFIG in
release-lib), which reads nothing else. The key is deleted when the job
ends. By hand, nothing changes. Also records the end-to-end measurements
of Release 1 and of Preview's gate job in docs/releasing.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:24:50 -05:00

305 lines
11 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
}
# ssh to the update server. With RELEASE_SSH_CONFIG, that file and nothing
# else configures it — `ssh -F` ignores ~/.ssh/config and the system's — so
# a release job uses only the key it was given, and never the credentials
# of whatever machine it happens to run on. Without it, this machine's own
# configuration, as a person publishing by hand always has.
release_ssh() {
if [[ -n "${RELEASE_SSH_CONFIG:-}" ]]; then
ssh -F "$RELEASE_SSH_CONFIG" "$@"
else
ssh "$@"
fi
}
# The same, as rsync's -e.
release_rsync_ssh() {
printf 'ssh%s\n' "${RELEASE_SSH_CONFIG:+ -F $RELEASE_SSH_CONFIG}"
}
release_remote() {
release_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 "$(release_rsync_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 "$(release_rsync_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"
)