Files
Classroom_Desktop/tools/release-gate.sh
ekklesiaandClaude Opus 5.5 583f9b1809 feat: a CI mode for the gate, and a gate report publishing can rely on
--ci records an upgrade no room could take (master between releases, its
VERSION already on canary) as SKIP instead of failing every commit; its
report can never stand in for a release's. --still-current accepts a
report only for these exact bytes, passed, from exactly what canary and
stable serve now. update_canary.sh takes one through
RELEASE_GATE_REPORT_PASSED, so Preview's publish job needs the key and
not Docker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:47:42 -05:00

220 lines
7.7 KiB
Bash
Executable File

#!/bin/bash
# THE RELEASE GATE: may this exact file go to a classroom?
#
# tools/release-gate.sh PACKAGE.deb [FROM_VERSION...]
# tools/release-gate.sh --ci PACKAGE.deb for CI; see below
# tools/release-gate.sh --still-current REPORT PACKAGE.deb
#
# Runs tools/validate-trixie-release.sh against PACKAGE as
#
# * a fresh install;
# * an upgrade from what canary serves today, on a canary room;
# * an upgrade from what stable serves today, on a stable room;
# * an upgrade from each FROM_VERSION, on a stable room — the release a
# room is really still on, which can be several behind stable.
#
# The releases it upgrades from are downloaded over HTTPS the way a
# classroom downloads them, and each is checked against the SHA-256 its
# manifest publishes, or, for a FROM_VERSION, against the version dpkg
# reads out of it. Nothing here needs a credential or writes to the
# update server, so a CI runner can run it on code nobody has reviewed.
#
# Every run is one line in RELEASE_GATE_REPORT (default: printed only),
# so the result can be attached to the release it describes.
#
# It stops at the first failure: a gate that carries on reports a pass
# for a machine it never finished building.
#
# --ci is for a commit, not a release. Between releases master's VERSION
# is what canary already serves, and no room can upgrade to a version it
# has; such an upgrade is recorded as SKIP, with why, instead of failing
# every commit. Its report says it is a CI report and can never stand in
# for a release's.
#
# --still-current REPORT PACKAGE asks whether a gate report may still be
# relied on to publish PACKAGE: it has to be a release gate's report, for
# exactly these bytes, that passed, and whose upgrades were from exactly
# what canary and stable serve now. A report made before either channel
# moved is not current, and the gate has to be run again. Exit 0 or 1.
set -euo pipefail
# The fetches below run in $(...), which otherwise ignores set -e: a
# manifest that failed to download would go on to compare an empty hash
# with an empty hash, and match.
shopt -s inherit_errexit
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT/tools/release-lib.sh"
usage() {
echo "Usage: $0 [--ci] PACKAGE.deb [FROM_VERSION...]" >&2
echo " $0 --still-current REPORT PACKAGE.deb" >&2
exit 2
}
mode=release
case "${1:-}" in
--ci) mode=ci; shift ;;
--still-current) mode=check; shift ;;
esac
if [[ "$mode" == check ]]; then
[[ $# -eq 2 ]] || usage
gate_report="$1"
shift
fi
[[ $# -ge 1 ]] || usage
candidate="$(realpath "$1")"
shift
extra_from=("$@")
[[ -f "$candidate" ]] || { echo "Package not found: $candidate" >&2; exit 1; }
version="$(release_package_version "$candidate")"
release_require_version "$version"
# The name a release is published under is the version dpkg reads out of
# it. A file called 0.2.6.6 that says it is 0.2.6.5 is the wrong file.
if [[ "$(basename "$candidate")" != "$(release_package_name "$version")" ]]; then
echo "$(basename "$candidate") says it is $version." >&2
exit 1
fi
hash="$(sha256sum "$candidate" | awk '{print $1}')"
# The version a channel serves, read the way a classroom reads it.
served() {
curl -fsS -H 'Cache-Control: no-cache' "$RELEASE_URL/$1/manifest.json" \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])'
}
if [[ "$mode" == check ]]; then
stale() { echo "The gate report $gate_report cannot be relied on: $1" >&2; exit 1; }
name="$(basename "$candidate")"
[[ -f "$gate_report" ]] || stale "there is no such file"
grep -qx "release gate for $name" "$gate_report" || stale "it is not about $name"
grep -qx "mode release" "$gate_report" || stale "it is not a release gate's report"
grep -qx "sha256 $hash" "$gate_report" || stale "it is about other bytes than $hash"
[[ "$(tail -n 1 "$gate_report")" == *": $name passed the release gate" ]] \
|| stale "it does not end in a pass"
! grep -q '^SKIP' "$gate_report" || stale "it skipped something"
grep -q "^PASS fresh install of $version " "$gate_report" || stale "it has no fresh install"
for channel in canary stable; do
now="$(served "$channel")"
grep -qF "PASS upgrade $now -> $version on a $channel room ($channel serves $now)" "$gate_report" \
|| stale "$channel serves $now now, and the report did not upgrade from that"
done
echo "The gate report for $name, sha256 $hash, is current: canary and stable are where it left them."
exit 0
fi
report="${RELEASE_GATE_REPORT:-}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
record() {
printf '%s\n' "$1"
[[ -z "$report" ]] || printf '%s\n' "$1" >> "$report"
}
[[ -z "$report" ]] || : > "$report"
record "release gate for $(basename "$candidate")"
record "sha256 $hash"
record "mode $mode"
record "started $(date -u +%Y-%m-%dT%H:%M:%SZ)"
# A channel's current package, fetched as a classroom fetches it and
# proven to be what the manifest promises.
fetch_channel() {
local channel="$1" manifest from from_hash name got
manifest="$(curl -fsS -H 'Cache-Control: no-cache' "$RELEASE_URL/$channel/manifest.json")"
read -r from from_hash < <(python3 -c 'import json, sys
m = json.loads(sys.argv[1])
print(m["version"], m["sha256"])' "$manifest")
release_require_version "$from"
[[ "$from_hash" =~ ^[0-9a-f]{64}$ ]]
name="$(release_package_name "$from")"
curl -fsS -o "$work/$name" "$RELEASE_URL/packages/$name"
got="$(sha256sum "$work/$name" | awk '{print $1}')"
if [[ "$got" != "$from_hash" ]]; then
echo "$channel serves $name with sha256 $got; its manifest says $from_hash" >&2
return 1
fi
printf '%s\n' "$work/$name"
}
# A published release by version, for the rooms still on an older one.
fetch_version() {
local from="$1" name
release_require_version "$from"
name="$(release_package_name "$from")"
curl -fsS -o "$work/$name" "$RELEASE_URL/packages/$name"
if [[ "$(release_package_version "$work/$name")" != "$from" ]]; then
echo "$name does not say it is $from" >&2
return 1
fi
printf '%s\n' "$work/$name"
}
run() {
local label="$1" started
shift
started=$SECONDS
echo
echo "== $label"
"$@"
record "PASS $label ($(( (SECONDS - started) / 60 )) min)"
}
# A new machine is a stable room: that is what a fresh device.conf says,
# so that is the manifest its updater is asked about.
run "fresh install of $version (a new machine, on stable)" \
env VALIDATION_CHANNEL=stable \
"$ROOT/tools/validate-trixie-release.sh" "$candidate"
for channel in canary stable; do
previous="$(fetch_channel "$channel")"
from="$(release_package_version "$previous")"
if ! release_version_gt "$version" "$from"; then
if [[ "$mode" == ci ]]; then
record "SKIP upgrade $from -> $version on a $channel room: $channel serves $from, not older than this commit's VERSION (a CI build, not a release)"
continue
fi
echo "$channel is on $from; $version is not newer, so no room could take it." >&2
exit 1
fi
run "upgrade $from -> $version on a $channel room ($channel serves $from)" \
env VALIDATION_CHANNEL="$channel" \
"$ROOT/tools/validate-trixie-release.sh" "$candidate" "$previous"
done
for from in "${extra_from[@]}"; do
previous="$(fetch_version "$from")"
run "upgrade $from -> $version on a stable room" \
env VALIDATION_CHANNEL=stable \
"$ROOT/tools/validate-trixie-release.sh" "$candidate" "$previous"
done
if [[ "$mode" == ci ]]; then
record "finished $(date -u +%Y-%m-%dT%H:%M:%SZ): $(basename "$candidate") passed the CI gate, which is not a release gate"
else
record "finished $(date -u +%Y-%m-%dT%H:%M:%SZ): $(basename "$candidate") passed the release gate"
fi