From a9700536bfa686762f61d25d17c48cf0a45eb078 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Tue, 29 Sep 2026 18:05:54 -0500 Subject: [PATCH] fix: the file the gate passed is the file published, and stable takes only the approved one tools/release-gate.sh is the one gate: a fresh install, and the upgrade from what canary and stable serve, each on a room of that channel, plus any older release rooms are still on (RELEASE_GATE_FROM). The releases it upgrades from are fetched over HTTPS and checked against their manifests, so it needs no credential and a CI runner can run it. update_canary.sh VERSION PACKAGE gates and publishes that file without rebuilding it, since a second build is a second, untested file; the hash is taken before the gate and checked after it. update_stable.sh VERSION SHA256 refuses anything but the file that was approved. Co-Authored-By: Claude Opus 5.5 --- tests/test-deployment-support.sh | 40 +++++++-- tests/test-release-versions.sh | 24 +++++ tools/release-gate.sh | 146 +++++++++++++++++++++++++++++++ update_canary.sh | 82 +++++++++-------- update_stable.sh | 25 +++++- 5 files changed, 274 insertions(+), 43 deletions(-) create mode 100755 tools/release-gate.sh diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index 108160d..5be529a 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -244,20 +244,48 @@ 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"') +validation = canary.index('"$ROOT/tools/release-gate.sh"') 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_upload) +check("the release gate runs before the repository copy", validation < package_copy) +check("the release gate 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 stable rooms' upgrade is rehearsed on the same file before publishing", - "VALIDATION_CHANNEL=stable" in canary - and canary.index("VALIDATION_CHANNEL=stable") < first_upload) +# A package handed in is the one published: a second build is a second, +# untested file. +build_at = canary.index('"$ROOT/tools/build-release.sh"') +check("a package handed in is not rebuilt", + canary.rindex('if [[ -n "$prebuilt" ]]', 0, build_at) < build_at + and "else" in canary[canary.rindex('if [[ -n "$prebuilt" ]]', 0, build_at):build_at]) +check("and must say it is the version being published", + 'release_package_version "$package_path")" != "$version"' in canary) +check("the file is hashed before the gate and checked after it", + canary.index('hash="$(sha256sum "$package_path"') < validation + < canary.index("changed while it was being gated") < first_upload) +check("the repository copy is proven to be the gated file", + package_copy < canary.index("is not the file that passed the gate") < first_upload) + +gate = (root / "tools/release-gate.sh").read_text() +gate_code = "\n".join(line for line in gate.splitlines() + if not line.lstrip().startswith("#")) +check("the gate runs a fresh install", + '"$ROOT/tools/validate-trixie-release.sh" "$candidate"\n' in gate_code) +check("and the upgrade from what canary and stable serve, as rooms of each", + "for channel in canary stable" in gate_code + and 'VALIDATION_CHANNEL="$channel"' in gate_code) +check("the stable rooms' older releases can be rehearsed too", + "VALIDATION_CHANNEL=stable" in gate_code and "RELEASE_GATE_FROM" in canary) +check("what it upgrades from is checked against its manifest's checksum", + 'if [[ "$got" != "$from_hash" ]]' in gate_code) +check("the gate needs no credential and writes nothing to the server", + "release_remote" not in gate_code and "ssh" not in gate_code + and "rsync" not in gate_code) +check("the gate stops at the first failure", + "set -euo pipefail" in gate and "inherit_errexit" in gate) 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 diff --git a/tests/test-release-versions.sh b/tests/test-release-versions.sh index c751c29..1923c40 100755 --- a/tests/test-release-versions.sh +++ b/tests/test-release-versions.sh @@ -274,8 +274,21 @@ fake_deb 0.2.9 "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb" check "refuses a package that was built but never uploaded" \ refuses "$ROOT/update_stable.sh" 0.2.9 rm "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb" +check "refuses a checksum that is not one" \ + refuses "$ROOT/update_stable.sh" 0.2.6.1 not-a-checksum +check "refuses a file that is not the one approved" \ + refuses "$ROOT/update_stable.sh" 0.2.6.1 "$(printf '0%.0s' {1..64})" check "stable is still 0.2.2 after all of that" \ test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before" +approved="$(sha256sum "$LOCAL/packages/ekklesia-classroom_0.2.6.1_amd64.deb" | awk '{print $1}')" +check "promotes the approved file, named by its checksum" \ + "$ROOT/update_stable.sh" 0.2.6.1 "$approved" +check "and stable now serves exactly that file" \ + bash -c "grep -q '\"sha256\": \"$approved\"' '$SERVER/stable/manifest.json' \ + && cmp -s '$SERVER/stable/latest.deb' '$LOCAL/packages/ekklesia-classroom_0.2.6.1_amd64.deb'" +# Back to 0.2.2 for the updater's cases below. +printf '%s\n' "$stable_before" > "$SERVER/stable/manifest.json" +ln -sfn ../packages/ekklesia-classroom_0.2.2_amd64.deb "$SERVER/stable/latest.deb" echo echo "7. update_canary.sh" @@ -286,6 +299,17 @@ check "without a version it builds nothing and names both choices" \ check "a published version is refused before anything is built" \ bash -c "! '$ROOT/update_canary.sh' 0.2.6 2>&1 | grep -q 'Validating sources'" check "a malformed version is refused" refuses "$ROOT/update_canary.sh" 0.2.6.1.1 +# A package handed in is gated and published as it is, so it has to be +# the release named, before anything is gated at all. +fake_deb 0.2.6.3 "$WORK/ekklesia-classroom_0.2.6.2_amd64.deb" +check "a package handed in that says it is another version is refused" \ + bash -c "out=\$('$ROOT/update_canary.sh' 0.2.6.2 '$WORK/ekklesia-classroom_0.2.6.2_amd64.deb' 2>&1); \ + [[ \$? -ne 0 ]] && grep -q 'says it is 0.2.6.3' <<< \"\$out\" && ! grep -q 'release gate' <<< \"\$out\"" +fake_deb 0.2.6.2 "$WORK/renamed.deb" +check "and so is one that is not named as the release" \ + bash -c "out=\$('$ROOT/update_canary.sh' 0.2.6.2 '$WORK/renamed.deb' 2>&1); \ + [[ \$? -ne 0 ]] && grep -q 'is not named' <<< \"\$out\" && ! grep -q 'release gate' <<< \"\$out\"" +rm -f "$WORK/ekklesia-classroom_0.2.6.2_amd64.deb" "$WORK/renamed.deb" # --------------------------------------------------------------------------- echo diff --git a/tools/release-gate.sh b/tools/release-gate.sh new file mode 100755 index 0000000..087c862 --- /dev/null +++ b/tools/release-gate.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# THE RELEASE GATE: may this exact file go to a classroom? +# +# tools/release-gate.sh PACKAGE.deb [FROM_VERSION...] +# +# 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. +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" + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 PACKAGE.deb [FROM_VERSION...]" >&2 + exit 2 +fi + +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}')" +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 "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)" +} + +run "fresh install of $version" \ + "$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 + 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 + +record "finished $(date -u +%Y-%m-%dT%H:%M:%SZ): $(basename "$candidate") passed the release gate" diff --git a/update_canary.sh b/update_canary.sh index 8cb2279..bf664a4 100755 --- a/update_canary.sh +++ b/update_canary.sh @@ -12,9 +12,12 @@ source "$ROOT/tools/release-lib.sh" # there are two — 0.2.6 is followed by 0.2.6.1 if it is a fix and by # 0.2.7 if it is not — and which one a release is, is a decision about # what is in it, not arithmetic. -if [[ $# -ne 1 ]]; then +if [[ $# -lt 1 || $# -gt 2 ]]; then current="$(release_channel_version canary || true)" - echo "Usage: $0 VERSION" >&2 + echo "Usage: $0 VERSION [PACKAGE.deb]" >&2 + echo >&2 + echo "With PACKAGE.deb, that file is gated and published and nothing is" >&2 + echo "built, so the file that was tested is the file that is published." >&2 echo >&2 echo "Canary is on ${current:-an unknown version}. Name the release, for example:" >&2 if [[ -n "$current" ]] && release_version_valid "$current"; then @@ -28,62 +31,69 @@ fi version="$1" package_name="$(release_package_name "$version")" package_path="$ROOT/dist/$package_name" +prebuilt="${2:-}" echo "Current canary: $(release_channel_version canary || echo unknown)" echo "New canary: $version" echo +# RELEASE_GATE_FROM="0.2.2" adds the upgrade from an older release that +# rooms are still on to the gate, beside canary's and stable's own. +# # Before anything is built: a version that has been published, or that # would not be newer than everything that has been, stops here. release_check_new_version "$version" -"$ROOT/tools/build-release.sh" "$version" +# ONE FILE, FROM THE GATE TO THE ROOM. +# +# Given a package, that package is what is gated and published, and +# nothing is rebuilt: a build is not byte-for-byte repeatable, so a second +# build is a second, untested file. Without one, it is built here. +if [[ -n "$prebuilt" ]]; then + package_path="$(realpath "$prebuilt")" -# Validate the exact artifact that will be published. If a previous canary is -# available locally, exercise the real upgrade path as well as a fresh install. -validation_args=("$package_path") - -if [[ -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then - previous_package_name="$(python3 -c \ - 'import json, sys; print(json.load(open(sys.argv[1]))["package"].rsplit("/", 1)[-1])' \ - "$RELEASE_LOCAL/canary/manifest.json")" - previous_package="$RELEASE_LOCAL/packages/$previous_package_name" - - if [[ -f "$previous_package" && "$previous_package" != "$package_path" ]]; then - validation_args+=("$previous_package") + if [[ "$(basename "$package_path")" != "$package_name" ]]; then + echo "$prebuilt is not named $package_name." >&2 + exit 1 fi + + if [[ "$(release_package_version "$package_path")" != "$version" ]]; then + echo "$prebuilt says it is $(release_package_version "$package_path"), not $version." >&2 + exit 1 + fi + + echo "Publishing the package already built: $package_path" +else + "$ROOT/tools/build-release.sh" "$version" fi -"$ROOT/tools/validate-trixie-release.sh" "${validation_args[@]}" +# A fresh install, and the upgrade from whatever canary and stable serve +# today, each on a room of that channel. Every canary is a stable release +# in waiting, and the desktops are not on the previous canary: they are on +# whatever stable names, which can be several releases back (0.2.2 under +# 0.2.6.1). A package that upgrades the laptop and breaks the desktops has +# to fail here, not on a Sunday. See tools/release-gate.sh. +hash="$(sha256sum "$package_path" | awk '{print $1}')" -# And the stable rooms' own path, on the same file. Every canary is a -# stable release in waiting, and the desktops are not on the previous -# canary: they are on whatever stable names, which can be several -# releases back (0.2.2 under 0.2.6.1). A package that upgrades the -# laptop and breaks the desktops has to fail here, not on a Sunday. -if [[ -f "$RELEASE_LOCAL/stable/manifest.json" ]]; then - stable_package_name="$(python3 -c \ - 'import json, sys; print(json.load(open(sys.argv[1]))["package"].rsplit("/", 1)[-1])' \ - "$RELEASE_LOCAL/stable/manifest.json")" - stable_package="$RELEASE_LOCAL/packages/$stable_package_name" - - if [[ -f "$stable_package" && "$stable_package" != "${validation_args[1]:-}" ]]; then - echo - echo "Rehearsing the stable rooms' upgrade from $stable_package_name" - VALIDATION_CHANNEL=stable \ - "$ROOT/tools/validate-trixie-release.sh" "$package_path" "$stable_package" - fi -fi +"$ROOT/tools/release-gate.sh" "$package_path" ${RELEASE_GATE_FROM:-} # Checked again after the gate, which takes long enough for somebody else -# to have published in the meantime. +# to have published in the meantime — or for the file to have changed. release_check_new_version "$version" -hash="$(sha256sum "$package_path" | awk '{print $1}')" +if [[ "$(sha256sum "$package_path" | awk '{print $1}')" != "$hash" ]]; then + echo "$package_path changed while it was being gated. Nothing was published." >&2 + exit 1 +fi mkdir -p "$RELEASE_LOCAL/packages" cp -n "$package_path" "$RELEASE_LOCAL/packages/$package_name" +if [[ "$(sha256sum "$RELEASE_LOCAL/packages/$package_name" | awk '{print $1}')" != "$hash" ]]; then + echo "$RELEASE_LOCAL/packages/$package_name is not the file that passed the gate." >&2 + exit 1 +fi + echo echo "Publishing package..." release_upload_package "$RELEASE_LOCAL/packages/$package_name" diff --git a/update_stable.sh b/update_stable.sh index b44b832..cfabae3 100755 --- a/update_stable.sh +++ b/update_stable.sh @@ -9,7 +9,23 @@ source "$ROOT/tools/release-lib.sh" # Stable never gets a package of its own. It is pointed at one canary # already published, so the file every classroom downloads is byte for # byte the one the canary machine ran. -if [[ $# -eq 1 ]]; then +# +# ./update_stable.sh VERSION SHA256 the release that was approved, by +# the checksum it was approved as +# ./update_stable.sh VERSION the release, whatever file it is +# ./update_stable.sh whatever canary is on +# +# With SHA256, anything but that exact file is refused. The checksum is +# the one written down when the release went to canary and passed its +# physical checks, so the approval and the promotion name the same bytes. +expected_hash="${2:-}" + +if [[ -n "$expected_hash" && ! "$expected_hash" =~ ^[0-9a-f]{64}$ ]]; then + echo "Not a SHA-256 checksum: $expected_hash" >&2 + exit 1 +fi + +if [[ $# -ge 1 ]]; then version="$1" else if [[ ! -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then @@ -50,6 +66,13 @@ fi hash="$(sha256sum "$package_path" | awk '{print $1}')" +if [[ -n "$expected_hash" && "$hash" != "$expected_hash" ]]; then + echo "$package_name is not the file that was approved:" >&2 + echo " approved: $expected_hash" >&2 + echo " here: $hash" >&2 + exit 1 +fi + echo "Promoting $version to STABLE (from ${current:-nothing})" # Refuses unless the server already holds exactly this package.