halkyon/deploy/redeploy.sh

106 lines
3.8 KiB
Shell
Raw Normal View History

ci: check every push on sahaquiel and deploy a green main Pushes to main were deployed by hand. Now Forgejo Actions runs tool/check.sh on every push, and a green main deploys learn.halkyon.eu by itself. The runner (forgejo-runner v13.1.0) runs on sahaquiel as the unprivileged user `runner`, with jobs directly on the host: containers would take Docker access, which is root on the production server, and Forgejo warns that host jobs have no isolation from the runner's user. So its systemd unit (deploy/runner/forgejo-runner.service) provides it: /home and the Docker socket invisible, Docker's container networks unreachable, no capabilities, two cores and 3 GB at most, lowest priority. It is registered to this repository only, with the UUID and token of Forgejo's "Create new runner" (the old `register` is deprecated); the token reaches it as a systemd credential. The configuration (deploy/runner/config.yml) was dry-run against v13.1.0, the unit rates 1.8 in `systemd-analyze security`. CI runs on pushes only: a pull request from a fork would run its code on the production server. The deploy job calls deploy/redeploy.sh as `deploy`, through a key that authorized_keys pins to that script and to connections from sahaquiel itself. The script deploys the commit CI checked while it is still main's tip, as a fast-forward, only if every new commit is signed by a key in deploy/allowed-signers (as the deployed checkout lists them), after dumping the database (the last ten are kept), and fails unless the server answers afterwards; a failure puts the checkout back. Tested against the local stack: refusals of a malformed argument, a stale commit, a forged unsigned commit and a non-fast-forward, a real deploy with its dump, a repeat, and a failed build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 21:49:04 +02:00
#!/bin/sh
# Deploys learn.halkyon.eu from main. The only command CI's deploy key may run: deploy's
# ~/.ssh/authorized_keys on sahaquiel pins that key to this script, from sahaquiel 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 10 dumps stay in ~/backups), and fails unless the
# 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 'git reset -q --hard "$deployed"; echo "The checkout is back at $deployed." >&2' EXIT
cd deploy
# Build while the running server keeps serving; then dump, then switch.
docker compose build -q server
dump
docker compose up -d --remove-orphans
wait_until_answering
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 10. (ls is safe here: the names are ours, letters, digits and dashes.)
# shellcheck disable=SC2012
ls -1t "$HOME"/backups/halkyon_learn-*.dump | tail -n +11 | 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"
}
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