#!/bin/bash # THE RELEASE STEPS, AS THE GITEA WORKFLOWS TAKE THEM — OR A PERSON DOES. # # tools/release-ci.sh build VERSION [FROM_VERSION...] # tools/release-ci.sh gate VERSION SHA256 # tools/release-ci.sh preview VERSION SHA256 GATE_REPORT_NAME # tools/release-ci.sh stable VERSION SHA256 APPROVAL # tools/release-ci.sh import VERSION SHA256 COMMIT [GATE_REPORT_FILE] # # Glue, and only glue. Building is tools/build-release.sh, the gate is # tools/release-gate.sh, publishing is update_canary.sh and promoting is # update_stable.sh, exactly as when a person runs them; the record is # tools/gitea-release. Nothing here decides what a good release is, so # nothing here can come to disagree with them. See docs/releasing.md. # # build from the commit checked out, whose VERSION says VERSION: the # package, its SHA256SUMS and build-info.json, the gate, and a # Gitea pre-release vVERSION holding all of it. Refuses a # version that has a release, a tag or a package already. # gate the gate again, on the file that release holds, for Preview; # the report is attached to the release under a new name. # preview that file, published to canary (Preview) by update_canary.sh # on that gate report, read back, and recorded. # stable that file, promoted by update_stable.sh VERSION SHA256 once a # person has approved it, read back, and recorded. # import a release published before Gitea kept the record (0.2.6.6): # the file as the update server serves it, recorded as-is. # # CREDENTIALS. build, gate and import need only GITEA_TOKEN, for the # release. preview and stable also need the update server, through # RELEASE_SSH_KEY (the private key's text), RELEASE_SSH_KNOWN_HOSTS, # RELEASE_SSH_ADDRESS and RELEASE_SSH_USER, which only the release runner's # jobs are given. Run by hand, ~/.ssh/config is used as it always was. set -euo pipefail shopt -s inherit_errexit ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" source "$ROOT/tools/release-lib.sh" GITEA="$ROOT/tools/gitea-release" WORK="${RELEASE_CI_WORK:-$(mktemp -d)}" mkdir -p "$WORK" run_url() { if [[ -n "${GITHUB_RUN_NUMBER:-}" ]]; then printf '%s/%s/actions/runs/%s\n' "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_NUMBER" else printf 'by hand on %s by %s\n' "$(hostname)" "$(id -un)" fi } actor() { printf '%s\n' "${GITHUB_ACTOR:-$(id -un)}"; } fail() { echo "release-ci: $*" >&2; exit 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"])' } # Does Gitea have a release vVERSION? Yes, no, or stop: a question Gitea # could not answer is never taken as "no". has_release() { local status=0 "$GITEA" show "v$1" >/dev/null 2>&1 || status=$? case "$status" in 0) return 0 ;; 3) return 1 ;; *) fail "could not ask Gitea whether v$1 exists" ;; esac } # Is a package of this name on the update server? Only a 404 is a no. on_server() { local code code="$(curl -sS -o /dev/null -w '%{http_code}' -I "$RELEASE_URL/packages/$1" || true)" case "$code" in 200) return 0 ;; 404) return 1 ;; *) fail "could not ask the update server about $1 (HTTP ${code:-none})" ;; esac } require_sha256() { [[ "$1" =~ ^[0-9a-f]{64}$ ]] || fail "not a SHA-256 checksum: $1" } # The release's package, downloaded and proven to be the file named: the # checksum given, the checksum the release's own SHA256SUMS records, and # the version dpkg reads out of it all have to agree. fetch() { local version="$1" hash="$2" name name="$(release_package_name "$version")" "$GITEA" download "v$version" "$name" "$WORK/$name" >&2 "$GITEA" download "v$version" SHA256SUMS "$WORK/SHA256SUMS" >&2 [[ "$(sha256sum "$WORK/$name" | awk '{print $1}')" == "$hash" ]] \ || fail "v$version's $name is not sha256 $hash" grep -qx "$hash $name" "$WORK/SHA256SUMS" \ || fail "v$version's SHA256SUMS does not record $name as $hash" [[ "$(release_package_version "$WORK/$name")" == "$version" ]] \ || fail "$name does not say it is $version" printf '%s\n' "$WORK/$name" } # The update server, for the release runner. The key is written to a file # only this job can read, and is gone when the job's container is. ssh_setup() { if [[ -z "${RELEASE_SSH_KEY:-}" ]]; then echo "release-ci: no RELEASE_SSH_KEY; using this machine's own ssh configuration" >&2 return fi [[ -n "${RELEASE_SSH_KNOWN_HOSTS:-}" ]] || fail "RELEASE_SSH_KNOWN_HOSTS is required with RELEASE_SSH_KEY" mkdir -p "$HOME/.ssh" chmod 700 "$HOME/.ssh" (umask 077; printf '%s\n' "$RELEASE_SSH_KEY" > "$HOME/.ssh/release-key") printf '%s\n' "$RELEASE_SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts" cat > "$HOME/.ssh/config" <&2; fail "the build changed the tree; the package is not the commit's"; } out="$ROOT/dist/release-$version" rm -rf "$out" mkdir -p "$out" package="$out/$name" cp "$ROOT/dist/$name" "$package" hash="$(sha256sum "$package" | awk '{print $1}')" (cd "$out" && sha256sum "$name" > SHA256SUMS) python3 - "$out/build-info.json" < "$out/notes.md" "$GITEA" create "v$version" "$commit" "Ekklesia Classroom $version" "$out/notes.md" --prerelease "$GITEA" upload "v$version" "$package" "$out/SHA256SUMS" "$out/build-info.json" \ "$out/gate-report.txt" "$out/checks.md" "$GITEA" record "v$version" "built and gated, sha256 \`$hash\` ($(run_url))" echo echo "Built $name" echo " sha256 $hash" echo " commit $commit" echo "Next, deliberately: the Preview workflow, with version $version and this sha256." [[ -z "${GITHUB_OUTPUT:-}" ]] || printf 'sha256=%s\n' "$hash" >> "$GITHUB_OUTPUT" } cmd_gate() { local version="$1" hash="$2" package report require_sha256 "$hash" package="$(fetch "$version" "$hash")" # A new name every run: a report attached to a release is never replaced. report="$WORK/gate-report-preview-$(date -u +%Y%m%dT%H%M%SZ).txt" RELEASE_GATE_REPORT="$report" "$ROOT/tools/release-gate.sh" "$package" ${RELEASE_GATE_FROM:-} "$GITEA" upload "v$version" "$report" "$GITEA" record "v$version" "gated for Preview: $(basename "$report") ($(run_url))" [[ -z "${GITHUB_OUTPUT:-}" ]] || printf 'report=%s\n' "$(basename "$report")" >> "$GITHUB_OUTPUT" } cmd_preview() { local version="$1" hash="$2" report_name="$3" package before after added require_sha256 "$hash" package="$(fetch "$version" "$hash")" "$GITEA" download "v$version" "$report_name" "$WORK/$report_name" ssh_setup export RELEASE_LOCAL="$WORK/published" before="$(server_packages)" RELEASE_GATE_REPORT_PASSED="$WORK/$report_name" "$ROOT/update_canary.sh" "$version" "$package" # One file added, and not a byte of any other changed. after="$(server_packages)" added="$(comm -13 <(printf '%s\n' "$before") <(printf '%s\n' "$after"))" [[ -z "$(comm -23 <(printf '%s\n' "$before") <(printf '%s\n' "$after"))" ]] \ || fail "a package already on the server changed while publishing" [[ "$added" == "$hash $(basename "$package")" ]] \ || fail "publishing added [$added], not exactly $(basename "$package")" release_verify_channel canary "$version" | tee "$WORK/preview.txt" echo "stable still serves $(served stable)" | tee -a "$WORK/preview.txt" "$GITEA" record "v$version" "published to Preview (canary), sha256 \`$hash\`, on gate report $report_name; manifest, package and latest.deb read back over HTTPS; no other package changed; stable still serves $(served stable) ($(run_url), $(actor))" } cmd_stable() { local version="$1" hash="$2" approval="$3" package before after body require_sha256 "$hash" [[ -n "$approval" ]] || fail "a promotion names who approved it and on what checks" # Only a file that went to Preview is promoted, and only that file. body="$("$GITEA" notes "v$version")" grep -qF "published to Preview (canary), sha256 \`$hash\`" <<< "$body" \ || fail "v$version has no record of $hash going to Preview; promote only what Preview ran" package="$(fetch "$version" "$hash")" ssh_setup export RELEASE_LOCAL="$WORK/published" mkdir -p "$RELEASE_LOCAL/packages" cp "$package" "$RELEASE_LOCAL/packages/" before="$(server_packages)" "$ROOT/update_stable.sh" "$version" "$hash" after="$(server_packages)" [[ "$before" == "$after" ]] || fail "a package on the server changed while promoting" "$GITEA" record "v$version" "promoted to Stable, sha256 \`$hash\`, the file Preview ran; read back over HTTPS. Approval: $approval ($(run_url), $(actor))" "$GITEA" stable "v$version" } cmd_import() { local version="$1" hash="$2" commit="$3" report="${4:-}" name out require_sha256 "$hash" name="$(release_package_name "$version")" out="$WORK/import-$version" mkdir -p "$out" git cat-file -e "$commit^{commit}" 2>/dev/null || fail "$commit is not a commit here" # Only a release with no record yet. One that has a record keeps it. if has_release "$version"; then fail "v$version already has a Gitea release; its record is kept as it is" fi curl -fsS -o "$out/$name" "$RELEASE_URL/packages/$name" [[ "$(sha256sum "$out/$name" | awk '{print $1}')" == "$hash" ]] \ || fail "the update server's $name is not sha256 $hash" [[ "$(release_package_version "$out/$name")" == "$version" ]] || fail "$name does not say it is $version" (cd "$out" && sha256sum "$name" > SHA256SUMS) { echo "Ekklesia Classroom $version, from \`$commit\`." echo echo "| | |" echo "| --- | --- |" echo "| Package | \`$name\` |" echo "| SHA-256 | \`$hash\` |" echo "| Source | \`$commit\` |" echo echo "Built and published before Gitea kept the release record. The" echo "package here is the file the update server serves, byte for byte." echo echo "## Changes" echo git log -1 --format=%B "$commit" } > "$out/notes.md" "$GITEA" create "v$version" "$commit" "Ekklesia Classroom $version" "$out/notes.md" --prerelease "$GITEA" upload "v$version" "$out/$name" "$out/SHA256SUMS" ${report:+"$report"} "$GITEA" record "v$version" "imported from the update server, sha256 \`$hash\` ($(run_url))" # Where it stands is read off the server, never assumed: the lines the # stable step looks for are only written if the channel really serves # this file now. for channel in canary stable; do if release_verify_channel "$channel" "$version" >/dev/null 2>&1 \ && [[ "$(curl -fsS "$RELEASE_URL/$channel/manifest.json" \ | python3 -c 'import json, sys; print(json.load(sys.stdin)["sha256"])')" == "$hash" ]]; then case "$channel" in canary) "$GITEA" record "v$version" "published to Preview (canary), sha256 \`$hash\`, by update_canary.sh before Gitea kept the record; canary serves it, read back over HTTPS at import" ;; stable) "$GITEA" record "v$version" "promoted to Stable, sha256 \`$hash\`, by update_stable.sh before Gitea kept the record; stable serves it, read back over HTTPS at import" "$GITEA" stable "v$version" ;; esac fi done } case "${1:-}" in build) shift; [[ $# -ge 1 ]] || fail "build VERSION [FROM_VERSION...]"; cmd_build "$@" ;; gate) [[ $# -eq 3 ]] || fail "gate VERSION SHA256"; cmd_gate "$2" "$3" ;; preview) [[ $# -eq 4 ]] || fail "preview VERSION SHA256 GATE_REPORT_NAME"; cmd_preview "$2" "$3" "$4" ;; stable) [[ $# -eq 4 ]] || fail "stable VERSION SHA256 APPROVAL"; cmd_stable "$2" "$3" "$4" ;; import) [[ $# -ge 4 && $# -le 5 ]] || fail "import VERSION SHA256 COMMIT [GATE_REPORT_FILE]"; shift; cmd_import "$@" ;; *) sed -n '2,8p' "$0" >&2; exit 2 ;; esac