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]>
305 lines
11 KiB
Bash
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"
|
|
)
|