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>
This commit is contained in:
parent
9877fba155
commit
4c2a363160
4 changed files with 33 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue