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),
|
ci: check the generated code; deploy: migrate before the switch
Two of Serverpod's own conventions, as its v4 project template and roles have them.
CI now runs `serverpod generate` (CLI pinned to the server's Serverpod version, like
the template's workflow) and fails if the committed generated code differs from its
output, so a model or endpoint changed without regenerating can't reach main. Checked in
a clean clone: today's code passes, a committed stale generated file fails.
redeploy.sh now applies the migrations in a run of their own, with Serverpod's
maintenance role (--role=maintenance --apply-migrations), after the dump and before
switching to the new server. That run exits with 0 only if the migrations worked, and
each migration is one transaction, so a failing one stops the deploy while the old
server keeps serving; before, the new server applied them at start and would have
crash-looped. Tested on the local stack: a deploy through the new step, and a failing
run (wrong database password) that exits with 1 after six attempts. AGENTS.md says
migrations must keep the old server working until the switch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 22:55:24 +02:00
|
|
|
# dumps the database first (the last 10 dumps stay in ~/backups), applies the migrations
|
|
|
|
|
# before switching to the new server, and fails unless that server answers afterwards. A failure leaves the checkout at the deployed commit, so
|
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
|
|
|
# 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
|
ci: check the generated code; deploy: migrate before the switch
Two of Serverpod's own conventions, as its v4 project template and roles have them.
CI now runs `serverpod generate` (CLI pinned to the server's Serverpod version, like
the template's workflow) and fails if the committed generated code differs from its
output, so a model or endpoint changed without regenerating can't reach main. Checked in
a clean clone: today's code passes, a committed stale generated file fails.
redeploy.sh now applies the migrations in a run of their own, with Serverpod's
maintenance role (--role=maintenance --apply-migrations), after the dump and before
switching to the new server. That run exits with 0 only if the migrations worked, and
each migration is one transaction, so a failing one stops the deploy while the old
server keeps serving; before, the new server applied them at start and would have
crash-looped. Tested on the local stack: a deploy through the new step, and a failing
run (wrong database password) that exits with 1 after six attempts. AGENTS.md says
migrations must keep the old server working until the switch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 22:55:24 +02:00
|
|
|
# Build while the running server keeps serving; then dump, migrate, switch.
|
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
|
|
|
docker compose build -q server
|
|
|
|
|
dump
|
ci: check the generated code; deploy: migrate before the switch
Two of Serverpod's own conventions, as its v4 project template and roles have them.
CI now runs `serverpod generate` (CLI pinned to the server's Serverpod version, like
the template's workflow) and fails if the committed generated code differs from its
output, so a model or endpoint changed without regenerating can't reach main. Checked in
a clean clone: today's code passes, a committed stale generated file fails.
redeploy.sh now applies the migrations in a run of their own, with Serverpod's
maintenance role (--role=maintenance --apply-migrations), after the dump and before
switching to the new server. That run exits with 0 only if the migrations worked, and
each migration is one transaction, so a failing one stops the deploy while the old
server keeps serving; before, the new server applied them at start and would have
crash-looped. Tested on the local stack: a deploy through the new step, and a failing
run (wrong database password) that exits with 1 after six attempts. AGENTS.md says
migrations must keep the old server working until the switch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 22:55:24 +02:00
|
|
|
# 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
|
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
|
|
|
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
|