# 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" <&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" )