halkyon/deploy/redeploy.sh
Tizian 「ティツィアーン」 9d2a93a648
All checks were successful
CI / check (push) Successful in 15m42s
CI / deploy (push) Successful in 15s
fix(deploy): a deploy failing after the switch brings the containers back
A failed deploy put the checkout back at the deployed commit but left
the containers as the new one had made them: when Caddy couldn't start
on the new network (2026-10-14), learn.halkyon.eu stayed down until they
were brought back by hand. Failing after the switch, the deploy now also
starts the containers as the deployed commit has them, on the new
images, which fit the database the migrations have changed. Before the
switch, the running containers are never touched, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 01:01:35 +02:00

153 lines
6.6 KiB
Shell
Executable file

#!/bin/sh
# Deploys learn.halkyon.eu from main. The only command CI's deploy key may run: deploy's
# ~/.ssh/authorized_keys on the server pins that key to this script, from the server itself:
#
# restrict,from="127.0.0.1,::1",command="/home/deploy/halkyon-learn/deploy/redeploy.sh" ssh-ed25519 … (CI deploy trigger)
#
# CI passes the commit it has checked. This deploys it only
# - while it is still the tip of main (otherwise the CI run of the newer tip deploys),
# - as a fast-forward of the deployed commit,
# - if every new commit is signed by a key in deploy/allowed-signers, as the deployed
# checkout lists them (so no commit can add its own key),
# dumps the database first (the last 3 dumps stay in ~/backups), applies the migrations
# before switching to the new server, starts it once more after a migration (see below),
# and fails unless that server answers afterwards. A failure leaves the checkout at the deployed commit, so
# running the CI job again retries. By hand, with your own key:
#
# ssh sahaquiel-deploy halkyon-learn/deploy/redeploy.sh <commit>
set -eu
main() {
commit=${SSH_ORIGINAL_COMMAND:-${1:-}}
case $commit in
'' | *[!0-9a-f]*) fail 'expects the full commit to deploy (40 hex digits)' ;;
esac
[ ${#commit} -eq 40 ] || fail 'expects the full commit to deploy (40 hex digits)'
cd "$HOME/halkyon-learn"
# One deploy at a time.
exec 9>"$HOME/.redeploy.lock"
flock 9
git fetch -q origin main
tip=$(git rev-parse origin/main)
if [ "$tip" != "$commit" ]; then
echo "main is at $tip now, not $commit: the CI run of $tip deploys that."
return 0
fi
deployed=$(git rev-parse HEAD)
if [ "$deployed" = "$commit" ]; then
echo "$commit is deployed already."
return 0
fi
git merge-base --is-ancestor "$deployed" "$commit" ||
fail "$commit is no fast-forward of the deployed $deployed"
unsigned=$(git log --format='%H %G? %GP' "$deployed..$commit" |
while read -r each status key; do
case $status in
# G: good signature; U: good, by a key deploy's keyring doesn't vouch for
# (the list below does the vouching).
G | U) [ -n "$key" ] && grep -qx "$key" deploy/allowed-signers && continue ;;
esac
echo "$each"
done)
[ -z "$unsigned" ] || fail "not signed by a key in deploy/allowed-signers: $unsigned"
echo "Deploying $commit (was $deployed)."
git merge -q --ff-only "$commit"
trap back_out EXIT
cd deploy
# Build while the running server keeps serving; then dump, migrate, switch.
docker compose build -q server dictionaries
dump
# Migrations as a run of their own, before the switch: Serverpod's maintenance role
# applies them and exits with 0 only if that worked. Each migration is one
# transaction, so a failing one stops the deploy here and the running server keeps
# serving the schema it knows. (Until the switch, the old server serves the new
# schema; migrations that only add, as they should, keep that harmless.)
docker compose run --rm --no-deps -e role=maintenance server --apply-migrations
switched=yes
docker compose up -d --remove-orphans
# Caddy reads its Caddyfile when it starts, and `up` doesn't restart it for a
# changed file it mounts. Nor would `caddy reload` see the change: Docker
# mounts a single file by its inode, and git writes a changed file anew, so
# the container keeps the old one (found 2026-10-10). A changed Caddyfile, or a
# sidecar's site, therefore recreates the container, which mounts the new file; the
# certificates are in a volume, and the instance is away for seconds.
if ! git diff --quiet "$deployed" "$commit" -- Caddyfile 'sidecars/*.caddy'; then
docker compose up -d --force-recreate --no-deps caddy
fi
wait_until_answering
# After a migration the new server is started once more. The sync engine
# (serverpod_offline_sync v0.0.10) reconciles its registry of synced tables as a
# server first uses the database; when a migration changed a synced table, that
# changes the registry, and the server then rebuilds every space's projections in
# every session until it stops (issue #150): after v0.13.0's deploy every request
# took a minute or two, and syncs failed, until the server was restarted
# (2026-10-13). The maintenance run doesn't spare it. Started again, the server
# finds the registry as the first start left it.
if ! git diff --quiet "$deployed" "$commit" -- ../halkyon_learn_server/migrations; then
docker compose restart server
wait_until_answering
fi
trap - EXIT
docker image prune -f >/dev/null
echo "Deployed $commit."
}
# Dumps the database, as it is before the new server applies its migrations.
dump() {
mkdir -p "$HOME/backups"
file="$HOME/backups/halkyon_learn-$(git rev-parse --short "$deployed")-$(date -u +%Y%m%dT%H%M%SZ).dump"
(
umask 077
docker compose exec -T postgres pg_dump -U halkyon_learn -Fc halkyon_learn >"$file"
)
echo "Database dumped to $file ($(du -h "$file" | cut -f1))."
# Only the newest 3 (decided 2026-10-02): they are the deploys' way back, not backups;
# those go off-site. Every file people keep is in each of them, so fewer leave room.
# (ls is safe here: the names are ours, letters, digits and dashes.)
# shellcheck disable=SC2012
ls -1t "$HOME"/backups/halkyon_learn-*.dump | tail -n +4 | xargs -r rm --
}
# Waits up to a minute for the new server to answer inside the stack's network.
wait_until_answering() {
tries=0
while [ $tries -lt 30 ]; do
if docker compose exec -T caddy wget -q -O /dev/null http://server:8080/ 2>/dev/null; then
echo 'The server answers.'
return 0
fi
tries=$((tries + 1))
sleep 2
done
docker compose logs --tail 40 server >&2
fail "the server doesn't answer a minute after the switch"
}
# A failed deploy puts the checkout back at the deployed commit, so running the CI job
# again retries. Failing after the switch, it also brings the containers back as that
# commit has them (found 2026-10-14: Caddy couldn't start on the new network, and the
# instance stayed down until they were brought back by hand), on the new images, which
# fit the database the migrations have changed. Before the switch, the running
# containers were never touched.
back_out() {
cd "$HOME/halkyon-learn"
git reset -q --hard "$deployed"
echo "The checkout is back at $deployed." >&2
if [ "${switched:-}" = yes ]; then
(cd deploy && docker compose up -d --remove-orphans) >&2 &&
echo "The containers are back as $deployed has them." >&2
fi
}
fail() {
echo "Error: $*" >&2
exit 1
}
# All of the above is read before anything runs, so the script can update its own file.
main "$@"
exit