Files
ekklesiaandClaude Opus 5.5 14291fde37
CI / checks (push) Failing after 2m13s
CI / package and container gate (push) Skipped
fix: a release job uses only the key it is given, never the runner's own
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]>
2026-09-29 20:24:50 -05:00

402 lines
16 KiB
Bash
Executable File

#!/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, and without which a job refuses to publish. Run by hand
# (no GITHUB_ACTIONS), ~/.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 it was given, in a
# configuration of its own (RELEASE_SSH_CONFIG, used with ssh -F), so
# nothing on the runner's own disk is read or overwritten, and the key is
# deleted when this ends. Under Actions there is no other way: a job
# without the key stops, rather than reach for whatever credentials the
# machine it runs on happens to hold. Measured: on the development VM,
# which holds the production publish key, the fallback would have
# published a throwaway build to the real server.
ssh_setup() {
local dir="$WORK/ssh"
if [[ -z "${RELEASE_SSH_KEY:-}" ]]; then
if [[ -n "${GITHUB_ACTIONS:-}${GITEA_ACTIONS:-}" ]]; then
fail "no RELEASE_SSH_KEY: set the CLASSROOM_UPDATES_SSH_KEY secret. A job never uses the runner's own ssh keys"
fi
echo "release-ci: no RELEASE_SSH_KEY; by hand, so 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 "$dir"
chmod 700 "$dir"
(umask 077; printf '%s\n' "$RELEASE_SSH_KEY" > "$dir/key")
trap 'rm -f "$WORK/ssh/key"' EXIT
printf '%s\n' "$RELEASE_SSH_KNOWN_HOSTS" > "$dir/known_hosts"
cat > "$dir/config" <<EOF
Host $RELEASE_HOST
HostName ${RELEASE_SSH_ADDRESS:?RELEASE_SSH_ADDRESS is required}
User ${RELEASE_SSH_USER:?RELEASE_SSH_USER is required}
IdentityFile $dir/key
IdentitiesOnly yes
UserKnownHostsFile $dir/known_hosts
GlobalKnownHostsFile /dev/null
StrictHostKeyChecking yes
BatchMode yes
EOF
chmod 600 "$dir/config"
export RELEASE_SSH_CONFIG="$dir/config"
}
# Every package on the server and its checksum, to prove afterwards that
# publishing added one file and changed none.
server_packages() {
release_remote "cd '$RELEASE_ROOT/packages' && sha256sum -- *.deb" | sort
}
# ---------------------------------------------------------------------------
cmd_build() {
local version="$1" commit name out package hash previous
shift
release_require_version "$version"
# Releases are cut from the integration branch or a maintenance branch,
# never from a feature branch that nobody has reviewed.
local ref="${GITHUB_REF:-refs/heads/$(git rev-parse --abbrev-ref HEAD)}"
case "$ref" in
refs/heads/master|refs/heads/release/*) ;;
*) fail "releases are built from master or release/*, not $ref" ;;
esac
[[ -z "$(git status --porcelain --untracked-files=no)" ]] || fail "the checkout has uncommitted changes"
[[ "$(cat VERSION)" == "$version" ]] \
|| fail "VERSION says $(cat VERSION). Commit the release first: VERSION=$version, the manual rebuilt"
commit="$(git rev-parse HEAD)"
name="$(release_package_name "$version")"
# NEVER A VERSION TWICE. Not one Gitea has, not one the update server
# has, and not one that is not newer than what either channel serves.
# update_canary.sh asks the server the authoritative question again,
# over ssh, before anything is published.
if has_release "$version"; then
fail "v$version already has a Gitea release; a version is built once"
fi
if on_server "$name"; then
fail "$name is already on the update server"
fi
for channel in canary stable; do
release_version_gt "$version" "$(served "$channel")" \
|| fail "$channel serves $(served "$channel"); $version is not newer"
done
# The manual's revision date is the commit's, so the build reproduces
# the tree it was built from rather than stamping today on it.
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" CHECK_LOG_DIR="$WORK/checks" \
"$ROOT/tools/build-release.sh" "$version"
[[ -z "$(git status --porcelain --untracked-files=no)" ]] \
|| { git status --short >&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" <<PY
import json, sys
json.dump({
"version": "$version",
"package": "$name",
"sha256": "$hash",
"commit": "$commit",
"ref": "$ref",
"built": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"built_by": "$(run_url)",
"dpkg_version": "$(release_package_version "$package")",
}, open(sys.argv[1], "w"), indent=2)
PY
cp "$WORK/checks/summary.md" "$out/checks.md"
RELEASE_GATE_REPORT="$out/gate-report.txt" "$ROOT/tools/release-gate.sh" "$package" "$@"
previous="$(git describe --tags --abbrev=0 --match 'v*' HEAD^ 2>/dev/null || true)"
{
echo "Ekklesia Classroom $version, built from \`$commit\` on \`${ref#refs/heads/}\`."
echo
echo "| | |"
echo "| --- | --- |"
echo "| Package | \`$name\` |"
echo "| SHA-256 | \`$hash\` |"
echo "| Source | \`$commit\` |"
echo "| Built | $(run_url) |"
echo
echo "Status: **built and gated. Not published.** Preview and Stable are"
echo "separate, deliberate steps; see docs/releasing.md."
echo
echo "## Changes${previous:+ since $previous}"
echo
git log --no-merges --format='- %s (%h)' "${previous:+$previous..}HEAD" | head -n 60
echo
echo "## Release gate"
echo
echo '```'
cat "$out/gate-report.txt"
echo '```'
} > "$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