ci: check the generated code; deploy: migrate before the switch
All checks were successful
CI / check (push) Successful in 5m51s
CI / deploy (push) Successful in 5s

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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-09-30 22:55:24 +02:00
commit 4c2a363160
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 33 additions and 7 deletions

View file

@ -18,6 +18,9 @@ jobs:
env:
# The Flutter the app is developed with; move it together with the local one.
FLUTTER_VERSION: 3.47.5
# The Serverpod CLI, for the generated-code check: the server's Serverpod version
# (the root pubspec's overrides); move it together with them.
SERVERPOD_VERSION: 4.0.3
steps:
# Before anything else runs, the job proves it can neither see nor reach production
# (the runner's sandbox, deploy/runner/). If it can, nothing else should run.
@ -71,6 +74,22 @@ jobs:
- name: Dependencies
run: flutter pub get
# The generated code (the server's lib/src/generated, the client package) is
# committed, so it has to be exactly what the generator makes of the models and
# endpoints: a model changed without regenerating fails here.
- name: Generated code
run: |
bin="${XDG_STATE_HOME:-$HOME/.local/state}/Dart/install/bin"
if ! "$bin/serverpod" --no-analytics version 2>/dev/null | grep -qx "Serverpod version: $SERVERPOD_VERSION"; then
dart install "serverpod_cli@$SERVERPOD_VERSION"
fi
(cd halkyon_learn_server && "$bin/serverpod" --no-analytics generate)
if [ -n "$(git status --porcelain)" ]; then
git status --short
echo 'The generated code differs from what the generator makes now: run `serverpod generate` and commit.' >&2
exit 1
fi
# The test run mode's passwords, which passwords.yaml holds on a developer's machine
# and which is never committed. Without them the server tests stop without a word.
# Throwaway and new for every job: they only guard the test database the job creates

View file

@ -106,8 +106,8 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
## CI and deploys
- **CI** (`.forgejo/workflows/ci.yaml`) runs `tool/check.sh` on every push, on the Forgejo runner on sahaquiel (`deploy/runner/`). Jobs run directly on the host as the unprivileged user `runner`, never in containers: Docker access would be root on the production server. The runner's systemd unit keeps jobs away from production (no `/home`, no Docker socket, Docker's container networks unreachable, memory and CPU capped); keep it that way when changing it. `runner` has user ID 61000 and must never share one with a container's processes (PostgreSQL and the server run as 999, not remapped): the same user ID can read their environment and files. The job's first step (*Sandbox*) fails if any of this leaks. Flutter is pinned in the workflow (`FLUTTER_VERSION`): move it together with the local one. The server tests there get throwaway passwords from the environment (`SERVERPOD_…`, new for every job), since `passwords.yaml` is never committed; without them they exit with code 1 and no message. CI runs on pushes only: a pull request from a fork would run its code on the production server.
- **A green `main` deploys learn.halkyon.eu** through `deploy/redeploy.sh`, the only command the runner's key may run as `deploy` (pinned in `authorized_keys`, from sahaquiel itself only). It deploys the commit CI checked while it is still the tip, as a fast-forward, only if every new commit is signed by a key in `deploy/allowed-signers` (the deployed copy decides), after dumping the database (the last 10 dumps stay in `~deploy/backups`), and fails unless the server answers. A failed deploy leaves the checkout at the deployed commit; run the workflow again to retry. By hand: `ssh sahaquiel-deploy halkyon-learn/deploy/redeploy.sh <commit>`. Never `docker compose down -v` there.
- **CI** (`.forgejo/workflows/ci.yaml`) runs `tool/check.sh` on every push, on the Forgejo runner on sahaquiel (`deploy/runner/`). Jobs run directly on the host as the unprivileged user `runner`, never in containers: Docker access would be root on the production server. The runner's systemd unit keeps jobs away from production (no `/home`, no Docker socket, Docker's container networks unreachable, memory and CPU capped); keep it that way when changing it. `runner` has user ID 61000 and must never share one with a container's processes (PostgreSQL and the server run as 999, not remapped): the same user ID can read their environment and files. The job's first step (*Sandbox*) fails if any of this leaks. Flutter is pinned in the workflow (`FLUTTER_VERSION`): move it together with the local one. The server tests there get throwaway passwords from the environment (`SERVERPOD_…`, new for every job), since `passwords.yaml` is never committed; without them they exit with code 1 and no message (Serverpod's own template does the same). CI also runs `serverpod generate` and fails if the committed generated code differs from its output. CI runs on pushes only: a pull request from a fork would run its code on the production server.
- **A green `main` deploys learn.halkyon.eu** through `deploy/redeploy.sh`, the only command the runner's key may run as `deploy` (pinned in `authorized_keys`, from sahaquiel itself only). It deploys the commit CI checked while it is still the tip, as a fast-forward, only if every new commit is signed by a key in `deploy/allowed-signers` (the deployed copy decides), after dumping the database (the last 10 dumps stay in `~deploy/backups`), applies the migrations in a run of their own before switching (`--role=maintenance --apply-migrations`: a failing migration stops the deploy while the old server keeps serving, so migrations must keep the old server working until the switch), and fails unless the new server answers. A failed deploy leaves the checkout at the deployed commit; run the workflow again to retry. By hand: `ssh sahaquiel-deploy halkyon-learn/deploy/redeploy.sh <commit>`. Never `docker compose down -v` there.
## Rules the code relies on
@ -128,7 +128,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
- **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides, after reading the sync package's changelog.
- **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog.
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
- **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.

View file

@ -42,7 +42,8 @@ services:
image: halkyon-learn-server
restart: unless-stopped
# Applies pending migrations at every start, then serves. One instance only, so
# no two servers ever migrate at once.
# no two servers ever migrate at once. (On learn.halkyon.eu, deploy/redeploy.sh has
# applied them in a run of their own before, so nothing is left to do here.)
command: ["--apply-migrations"]
environment:
SERVERPOD_DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}

View file

@ -9,8 +9,8 @@
# - 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
# 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
# running the CI job again retries. By hand, with your own key:
#
# ssh sahaquiel-deploy halkyon-learn/deploy/redeploy.sh <commit>
@ -57,9 +57,15 @@ main() {
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.
# Build while the running server keeps serving; then dump, migrate, switch.
docker compose build -q server
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
docker compose up -d --remove-orphans
wait_until_answering
trap - EXIT