build(ci,deploy)!: the runner at home in a nightly window, deploys by hand (ADR 0039, D6, D13, D16)
Some checks failed
CI / check (push) Has been cancelled

The CI runner moves to halkyon.li's host at home and runs from 02:00 to
05:00 alone: a timer starts it, its unit's RuntimeMaxSec ends it, and
night.sh rests the instance's heavy sidecars around it as deploy, while
Caddy, PostgreSQL and the apps' servers keep serving. Jobs pushed during the
day wait in Forgejo's queue.

Its sandbox fits the new host: its folder on the HDD, the rest of /data
hidden, containerd's store out of reach, no private IPv4 network and no
IPv6 at all (the home network's IPv6 addresses are public ones under a
changing prefix, on the internet's own interface), 5 GB of memory with swap
beyond. The Sandbox step checks the tunnel, the home router and IPv6.

BREAKING CHANGE: CI deploys nothing any more: the deploy job goes with
learn.halkyon.eu's server, and halkyon.li deploys by hand with redeploy.sh
once a commit's night's run passed. Jobs run on the runner labelled
balthasar; sahaquiel's runner picks up none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-08 19:20:11 +01:00
commit c8d67b1eb7
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 401 additions and 76 deletions

View file

@ -1,13 +1,13 @@
# CI: every push of a branch runs the checks of the pre-push hook (tool/check.dart, every app's and
# shared package's); a push to main is then deployed to the instance on learn.halkyon.eu's server
# (deploy/redeploy.sh): Learn's server and every service .env switches on beside it, Train's
# and Todo's among them.
# Both jobs run on the runner on learn.halkyon.eu's server, directly on the host as an
# unprivileged user (deploy/runner/). A commit that passed the checks before isn't checked again
# (see "Checked before?").
# shared package's). Nothing is deployed from here: halkyon.li deploys by hand, a commit once
# its night's run passed (deploy/redeploy.sh; ADR 0039, D13).
# The job runs on the runner on halkyon.li's host at home, directly on the host as an
# unprivileged user, in the nightly window alone (02:00 to 05:00, deploy/runner/): what is
# pushed during the day waits in the queue. A commit that passed the checks before isn't
# checked again (see "Checked before?").
#
# Pushes only, no pull requests: a pull request from a fork would run its own code on
# the production server's runner.
# the instance's host.
name: CI
on:
@ -16,12 +16,12 @@ on:
# commit, and the tag's run would check that commit a second time while main's still
# runs ("Checked before?" knows only runs that finished). A tag deploys nothing.
branches: ['**']
# Runs the workflow again from the web UI, e.g. to retry a failed deploy.
# Runs the workflow again from the web UI, e.g. to check a commit once more.
workflow_dispatch:
jobs:
check:
runs-on: sahaquiel
runs-on: balthasar
# A warm run took 42 minutes with Train's packages (30 Oct 2026), a new Flutter more:
# the limit only catches a hung job.
timeout-minutes: 60
@ -33,8 +33,9 @@ jobs:
# with them.
SERVERPOD_VERSION: 4.0.4
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.
# Before anything else runs, the job proves it can neither see nor reach the instance
# or the home network (the runner's sandbox, deploy/runner/). If it can, nothing else
# should run.
- name: Sandbox
run: |
fail() { echo "The runner's sandbox leaks: $*" >&2; exit 1; }
@ -47,6 +48,11 @@ jobs:
# deploy's home (.env, the checkout, the dumps) and the Docker socket are out of
# reach.
[ ! -e /home/deploy ] || fail "/home/deploy is visible"
# Nor /data, where /home really lies, with Docker's data and the media: the
# runner sees its own folder there alone.
for hidden in /data/home /data/docker /data/containerd /data/media; do
[ ! -e "$hidden" ] || fail "$hidden is visible"
done
{ [ ! -r /run/docker.sock ] && [ ! -w /run/docker.sock ]; } || fail "the Docker socket is usable"
# Docker's networks, where the database listens, are unreachable. Caddy listens on
# every address of the host, the Docker bridge's included.
@ -57,11 +63,20 @@ jobs:
if timeout 5 bash -c 'exec 3<>/dev/tcp/fd4a:6c6b:7e80::2/443' 2>/dev/null; then
fail "Caddy's IPv6 network is reachable"
fi
# Nor is Jellyfin at home through the host's tunnel, where it is up (ADR 0032, D6).
if timeout 5 bash -c 'exec 3<>/dev/tcp/10.61.96.2/8096' 2>/dev/null; then
fail "the tunnel to Jellyfin at home is reachable"
# Nor Caddy at the tunnel's address, where ramiel reaches it (ADR 0039, D16).
if timeout 5 bash -c 'exec 3<>/dev/tcp/10.61.97.2/443' 2>/dev/null; then
fail "the tunnel's address is reachable"
fi
echo "This job can neither see nor reach production."
# Nor the home network: its router answers on 80.
if timeout 5 bash -c 'exec 3<>/dev/tcp/192.168.178.1/80' 2>/dev/null; then
fail "the home network is reachable"
fi
# Nor anything over IPv6, where the home network's addresses are public ones:
# Cloudflare's resolver answers on 443 over IPv6 wherever IPv6 works.
if timeout 5 bash -c 'exec 3<>/dev/tcp/2606:4700:4700::1111/443' 2>/dev/null; then
fail "IPv6 is reachable"
fi
echo "This job can neither see nor reach the instance or the home network."
# A job cancelled, or stopped at timeout-minutes, is killed with its step's process
# group (forgejo-runner v13.1.0 starts every host step as a group and sends it
@ -195,17 +210,3 @@ jobs:
mkdir -p "$HOME/checked"
touch "$HOME/checked/${{ github.sha }}"
find "$HOME/checked" -type f -mtime +90 -delete
deploy:
needs: check
if: github.ref == 'refs/heads/main'
runs-on: sahaquiel
timeout-minutes: 30
steps:
# The key can run nothing but deploy/redeploy.sh, and only from that server itself. It
# deploys every service there, whichever domain each serves (learn., train., todo.).
- name: Deploy the instance
run: >-
ssh -i "$HOME/.ssh/deploy_trigger" -o IdentitiesOnly=yes -o BatchMode=yes
-o StrictHostKeyChecking=yes -o UserKnownHostsFile="$HOME/.ssh/known_hosts"
deploy@127.0.0.1 "${{ github.sha }}"

View file

@ -28,8 +28,8 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
- **Before keeping a framework default, name the alternatives** and recommend one, especially where a choice becomes expensive once users or data exist.
- Every decision carries its reason (in `NEXT.md` or an ADR under `docs/adr/`).
- **Commits** are GPG-signed and carry a DCO `Signed-off-by` trailer (`git commit -s`; a local `prepare-commit-msg` hook adds it automatically), and **keep their type prefix** (`feat`, `fix`, `perf`, `refactor`, `docs`, `build`, `chore`, a `!` or a `BREAKING CHANGE:` line for what breaks): releases work out their versions from them (Learn's `AGENTS.md`, *Releases*). A commit whose change people using an app would notice says so for them in its trailers, `Whats-New: <one sentence>` and `Whats-New-De: <its German>`, once a change, short (a release's lines share F-Droid's 500 characters) and in the app's own names for its screens and buttons; the release drafts its *What's new* from them alone (Train's ADR 0030, D61, 4 Nov 2026). A commit scoped to apps (`feat(todo,meditate):`) counts for those alone, a shared package's for every app it touches; a refactor, a build change or a test carries none.
- **Servers are named in prose by the domain they serve** (learn.halkyon.eu's server, the halkyon.app server), which says what a reader needs; the SSH aliases and the CI runner's label keep the servers' own names (`sahaquiel-deploy`, `ramiel-fdroid`, `sahaquiel:host`).
- **Pushes** pass the checks first, through a local `pre-push` hook (in a fresh clone: `ln -s ../../tool/pre-push .git/hooks/pre-push`): `tool/pre-push` first refuses a push to `main` that brings a commit not signed by a key in `deploy/allowed-signers` (`tool/check_signatures.sh`, the deploy's own rule), so it fails on the laptop rather than after CI, then runs `tool/check.dart`, but for a push of release commits alone (the release tool's `chore(release):` commits changing an app's version line and its texts of what's new, nothing else: `tool/release_push.dart`, decided 3 Nov 2026, so that releases in a row don't run the checks once each; CI still runs them). CI runs the checks again, and a green `main` deploys learn.halkyon.eu (see *CI and deploys*), so an unsigned commit on `main` blocks every deploy after it.
- **Servers are named in prose by the domain they serve** (halkyon.li's host at home, the halkyon.app server), which says what a reader needs; the SSH aliases and the CI runner's label keep the machines' own names (`balthasar-deploy`, `ramiel`, `ramiel-fdroid`, `balthasar:host`). learn.halkyon.eu's server, `sahaquiel`, retires in November 2026 (ADR 0039).
- **Pushes** pass the checks first, through a local `pre-push` hook (in a fresh clone: `ln -s ../../tool/pre-push .git/hooks/pre-push`): `tool/pre-push` first refuses a push to `main` that brings a commit not signed by a key in `deploy/allowed-signers` (`tool/check_signatures.sh`, the deploy's own rule), so it fails on the laptop rather than after CI, then runs `tool/check.dart`, but for a push of release commits alone (the release tool's `chore(release):` commits changing an app's version line and its texts of what's new, nothing else: `tool/release_push.dart`, decided 3 Nov 2026, so that releases in a row don't run the checks once each; CI still runs them). CI runs the checks again, and `main` is deployed to halkyon.li by hand once its night's run passed (see *CI and deploys*), so an unsigned commit on `main` blocks every deploy after it.
- **Lints**: LeanCode's strict set (`leancode_lint`), configured once in the root `analysis_options.yaml`, which also enables its analyzer plugin. Plugins can only be enabled at the root of a pub workspace and only reach packages whose options include that file: Learn's, Todo's and Train's servers and apps, Learn's curator and every shared package include it; the generated client packages keep their own, but for the module's, which includes it for its SRP half and leaves `lib/src/protocol/` out. Every `// ignore:` gets a comment saying why (`document_ignores`). Each server package switches off the four rules for Dart v3.13's constructor syntax (`use_primary_constructors`, `use_declaring_parameters`, `empty_container_bodies`, `unnecessary_type_name_in_constructor`): Serverpod's code generator (checked with v4.0.3) can't parse that syntax in endpoint files yet.
## Flutter and Serverpod
@ -88,5 +88,5 @@ Checklist after doing changes, in this order:
## CI and deploys
- **CI** (`.forgejo/workflows/ci.yaml`) runs `dart run tool/check.dart` on every push of a branch (not of a tag: a release pushes its tag right after `main`, on the same commit), on the Forgejo runner on learn.halkyon.eu's server (`deploy/runner/`), except for a commit that passed before: the job marks passed commits in the runner's home (`checked/`, kept 90 days), so a branch's commit fast-forwarded into `main` goes straight to its deploy. It checks every package, whichever a push changed. 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 Learn's 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. Its second (*Leftovers*) stops whatever an earlier job left running in a job's working folder: the runner (forgejo-runner v13.1.0) kills a cancelled or timed-out step's process group and runs no later step, not even an `if: always()` one, and on 22 Oct 2026 a cancelled job still left its 4 GB analysis server behind; with one job at a time, any such process is a leftover. Flutter is pinned in the workflow (`FLUTTER_VERSION`): move it together with the local one. Every server package's tests get throwaway passwords from the environment (`SERVERPOD_…`, new for every job), since no `passwords.yaml` is ever committed; without them they exit with code 1 and no message (Serverpod's own template does the same). CI installs the Serverpod CLI at `SERVERPOD_VERSION` (which the root's tests hold to the lock's Serverpod), so the checks' generated-code step fails there too if the committed generated code of any server differs from what the generator makes, as it does on the laptop since 23 Oct 2026 (before, only CI compared, and a push went red for an endpoint's changed comment). So never run `dart format` over a server's `lib/` or `test/` as a whole: it reformats the generated code (`lib/src/generated/`, `serverpod_test_tools.dart`); format the files you wrote. 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**: this repository's `main`, whichever app or package a push changed, through `deploy/redeploy.sh` (Learn's `AGENTS.md`, *CI and deploys*, with the rules a deploy checks, the sidecars, the backups and the releases).
- **CI** (`.forgejo/workflows/ci.yaml`) runs `dart run tool/check.dart` on every push of a branch (not of a tag: a release pushes its tag right after `main`, on the same commit), on the Forgejo runner on halkyon.li's host at home (`deploy/runner/`; ADR 0039, D6, D16), in a nightly window alone, 02:00 to 05:00 (`forgejo-runner.timer` starts it, its unit's `RuntimeMaxSec` ends it), while `deploy/night.sh` rests the instance's heavy sidecars: what is pushed during the day waits in Forgejo's queue. A commit that passed before isn't checked again: the job marks passed commits in the runner's home (`checked/`, kept 90 days). It checks every package, whichever a push changed. Jobs run directly on the host as the unprivileged user `runner`, never in containers: Docker access would be root on the instance's host. The runner's systemd unit keeps jobs away from the instance and the home network (no `/home`, nothing of `/data` but its own folder, no Docker or containerd socket, no private IPv4 network and no IPv6 at all, as the home network's IPv6 addresses are public ones under a changing prefix; memory, swap and CPU capped so the instance keeps serving through the night); keep it that way when changing it. `runner` has user ID 61000 and must never share one with a container's processes (PostgreSQL and Learn's 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. Its second (*Leftovers*) stops whatever an earlier job left running in a job's working folder: the runner (forgejo-runner v13.1.0) kills a cancelled or timed-out step's process group and runs no later step, not even an `if: always()` one, and on 22 Oct 2026 a cancelled job still left its 4 GB analysis server behind; with one job at a time, any such process is a leftover. Flutter is pinned in the workflow (`FLUTTER_VERSION`): move it together with the local one. Every server package's tests get throwaway passwords from the environment (`SERVERPOD_…`, new for every job), since no `passwords.yaml` is ever committed; without them they exit with code 1 and no message (Serverpod's own template does the same). CI installs the Serverpod CLI at `SERVERPOD_VERSION` (which the root's tests hold to the lock's Serverpod), so the checks' generated-code step fails there too if the committed generated code of any server differs from what the generator makes, as it does on the laptop since 23 Oct 2026 (before, only CI compared, and a push went red for an endpoint's changed comment). So never run `dart format` over a server's `lib/` or `test/` as a whole: it reformats the generated code (`lib/src/generated/`, `serverpod_test_tools.dart`); format the files you wrote. CI runs on pushes only: a pull request from a fork would run its code on the instance's host.
- **halkyon.li deploys by hand** (ADR 0039, D13): this repository's `main`, whichever app or package a push changed, through `deploy/redeploy.sh`, run with a commit once its night's run passed (Learn's `AGENTS.md`, *CI and deploys*, with the rules a deploy checks, the sidecars, the backups and the releases). No job of CI deploys.

View file

@ -64,7 +64,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
## CI and deploys
- **CI** checks Learn with every other package (root AGENTS.md, *CI and deploys*): its server's, curator's and app's tests through Melos, its server's generated code through `generate`.
- **A green `main` deploys learn.halkyon.eu** through `deploy/redeploy.sh`, the only command `deploy`'s keys may run (pinned in its `authorized_keys`: the runner's from that server itself only, the developer's for deploys by hand). 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 3 dumps stay in `~deploy/backups`: the way back from a deploy, not 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 <commit>`, the commit alone, as CI passes it (the server's checkout is `~deploy/halkyon`, renamed from `halkyon-learn` on 2 Nov 2026 with the command the keys are pinned to; the Compose project keeps the name `halkyon-learn`, as Docker names its volumes after it). Never `docker compose down -v` there.- **From the single repository** (ADR 0015): learn.halkyon.eu deploys this repository's `main`, whichever app or shared package a push changed. Its images are built from the repository's root with only what they need (Learn's server and the module it builds on, with the module's client, which the module's tests depend on; the curator), each Dockerfile narrowing the workspace to those packages (`tool/workspace_only.sh`). The first deploy after the move starts the server once more than it needs to, as the deployed copy of `redeploy.sh` looks for migrations at their old path.
- **halkyon.li is deployed by hand** (ADR 0039, D13; learn.halkyon.eu was deployed by CI until its server retired in November 2026) through `deploy/redeploy.sh`, the only command `deploy`'s key may run (the developer's, pinned in its `authorized_keys`), once the commit's night's CI run passed. It deploys the commit 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 3 dumps stay in `~deploy/backups`: the way back from a deploy, not 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 it again to retry: `ssh balthasar-deploy <commit>` from melchior, the commit alone (the server's checkout is `~deploy/halkyon`, renamed from `halkyon-learn` on 2 Nov 2026 with the command the keys are pinned to; the Compose project keeps the name `halkyon-learn`, as Docker names its volumes after it). Never `docker compose down -v` there.- **From the single repository** (ADR 0015): learn.halkyon.eu deploys this repository's `main`, whichever app or shared package a push changed. Its images are built from the repository's root with only what they need (Learn's server and the module it builds on, with the module's client, which the module's tests depend on; the curator), each Dockerfile narrowing the workspace to those packages (`tool/workspace_only.sh`). The first deploy after the move starts the server once more than it needs to, as the deployed copy of `redeploy.sh` looks for migrations at their old path.
- **Sidecars** (`deploy/sidecars/README.md`, decided 2026-10-14): services an instance may run beside itself, each a compose file of its own beside `deploy/compose.learn.yaml` (`compose.jellyfin.yaml`, `compose.languagetool.yaml`; later Fedify), switched on in `deploy/.env` (`COMPOSE_FILE=compose.learn.yaml:compose.jellyfin.yaml`), so `redeploy.sh` deploys them with the instance. The instance's compose has two networks: `edge` (`halkyon-learn-edge`, 172.30.80.0/24 and the unique local fd4a:6c6b:7e80::/64, so Docker hands Caddy IPv6 visitors' own addresses), Caddy's at fixed addresses (172.30.80.2, fd4a:6c6b:7e80::2; the server's `TRUSTED_PROXIES`), shared with the server, and the internal default one, where the server reaches Postgres; a sidecar joins `edge` alone and publishes no port (the CI runner may reach `127.0.0.1`, never Docker's 172.16.0.0/12 or fd00::/8). A sidecar reached from outside mounts its site from `deploy/sidecars/` into Caddy's `/etc/caddy/sidecars/`, which the Caddyfile imports (none without), and `redeploy.sh` recreates Caddy when a site changes. Each runs as a user ID of its own (Jellyfin 61096, LanguageTool 61081), never one a person, another container or the runner uses; its files on the server are git-ignored (`deploy/jellyfin/`, `deploy/languagetool/`). The Jellyfin sidecar sets the server's `JELLYFIN_URL`, so the instance suggests it; the LanguageTool sidecar (ADR 0024) sets `LANGUAGETOOL_URL` and has no site, as LanguageTool has no sign-in: only the server reaches it, and logs nothing (`logging: none`).
- **Backups** (`deploy/backups.md`): every hour, `deploy/backup.sh` (a systemd timer, as `deploy`) dumps the database, which holds people's files too, then bundles each published studyset's repository (`git bundle`, from the server's `repositories` volume), and restic stores both in a Hetzner Storage Box (24 hourly, 7 daily, 4 weekly, 3 monthly); the box's own snapshots protect it from a taken-over server. A restore takes the instance back up to an hour, and people who started since lose what their device held: their app no longer finds its session and asks how to start, and an account made since is gone with it. So a change that makes devices lose their session, or keeps deleted people's data longer, changes what a restore costs; say so in `deploy/backups.md`.
- **Releases** are the project's, not an instance's: the release, the Snap Store listing and the F-Droid listing point to the project's page on Halkyon's website, halkyon.app/learn (its own repository, `project-halkyon/website`; learn.halkyon.app redirects there); the builds name learn.halkyon.eu, the project's instance (`SERVER_URL`), which a new install offers first (see *Instances*). They come from a developer's laptop, never from CI: `dart run tool/release.dart learn` on `main` as pushed and green in CI (the apps' shared release tool, ADR 0015, configured in `apps/learn/release.dart`; `--dry-run` shows the version, the commits that count, every step and the notes without changing anything). Build 21 (`v0.18.0`) was the last from `tool/release.sh`, the Bash script it replaced, removed since. Versions follow semantic versioning, worked out from the commits touching Learn since its last release (`apps/learn/`, the shared packages it uses, the root pubspec and lock, its release files, and its packages' folders at the root from before the move; `feat` raises the minor number, `fix`/`perf` the patch number, breaking ones the major from 1.0 on; other types release nothing), so **commit messages keep their type prefix**. It writes the version and a raised build number into the app's pubspec in a signed release commit (`chore(release): learn-vX.Y.Z`) with a signed tag (`learn-vX.Y.Z`, by a key in `deploy/allowed-signers`; Learn's earlier `vX.Y.Z` tags count as its history), builds one universal APK from a checkout of the tag, signed with the app's key, and the snap (`snap/snapcraft.yaml`, the committed HEAD, built in LXD), both with `SERVER_URL=https://learn.halkyon.eu/`; signs `SHA256SUMS` with GPG; drafts what's new in the release for the developer to edit, committed with the version in `assets/whats_new/` (Train's ADR 0030, D61: the sheet after the update, Settings' entry above About, the notes and F-Droid's changelog); pushes only once both builds passed their checks; and publishes on code.halkyon.app through Forgejo's API and in the F-Droid repository. The snap goes to the Snap Store's edge channel last (`storeChannel` in `apps/learn/release.dart`; the release refuses to start unless `snapcraft whoami` shows a login). A run that stopped after its release commit and tag carries on with them. The key's password and the Forgejo token are asked for at each run and never stored. The app's key is a PKCS #12 keystore outside the repository (also in the maintainer's password manager); there is no Google Play, so it is the app's identity: lost, installed apps can't be updated. Its certificate's SHA-256 is in `tool/release/android-certificate.sha256`, and the release tool releases no APK signed otherwise; other release builds fall back to the debug key (`android/app/build.gradle.kts`), so `flutter run --release` works anywhere. The APK also goes into the project's own F-Droid repository, https://halkyon.app/fdroid/repo, through `tool/fdroid_repo.sh` (fdroidserver v2.4.5): its settings and the app's listing are in `tool/release/fdroid/` (the anti-feature NonFreeNet declared with its reasons and F-Droid's own icon; not TetheredNet, as the app chooses its instance); its index is signed with a key of its own (a PKCS #12 keystore beside the app's, password asked for, certificate pinned in `tool/release/fdroid-index-certificate.sha256`, since a new key makes the F-Droid app refuse the repository); and it is uploaded to the halkyon.app server (`ssh ramiel-fdroid`) as the user `fdroid`, whose key can only write into the folder Caddy serves (rrsync), the APKs first and the indexes last.

View file

@ -129,5 +129,36 @@ for the later.
6. **Reboot**, at home, to see each step hold: the passphrase over SSH (step 2), no
desktop (step 3), Docker on the HDD (step 4), the tunnel up before Docker (step 5).
The instance itself follows (its users, checkout, `.env` and first deploy), once its
compose files for Meditate and Jellyfin are built.
The instance itself follows (its users, checkout, `.env` and first deploy). Then:
7. **The CI runner in its nightly window** (D6, D16), once `deploy` and its checkout
exist. Its user, its folder on the HDD, Forgejo's runner (v13.1.0, the version
`../runner/config.yml` is written for) and the units:
```sh
sudo useradd --system --uid 61000 --user-group --no-create-home \
--home-dir /var/lib/forgejo-runner --shell /usr/sbin/nologin runner
sudo install -d -o runner -g runner -m 700 /data/forgejo-runner /var/lib/forgejo-runner
sudo install -m 755 forgejo-runner /usr/local/bin/forgejo-runner # v13.1.0, its checksum checked
sudo install -d -m 700 /etc/forgejo-runner
sudo install -m 644 deploy/runner/config.yml /etc/forgejo-runner/config.yml
sudo install -m 644 deploy/runner/forgejo-runner.service deploy/runner/forgejo-runner.timer \
deploy/runner/halkyon-night.service deploy/runner/halkyon-night.timer \
deploy/runner/halkyon-morning.service deploy/runner/halkyon-morning.timer \
/etc/systemd/system/
```
In Forgejo (`code.halkyon.app`, the repository's *Settings › Actions › Runners*): a new
runner, `balthasar`, its UUID into `/etc/forgejo-runner/config.yml` (`RUNNER_UUID`) and
its token into `/etc/forgejo-runner/token` (root, 0600); sahaquiel's runner removed
there once it is gone. Then:
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now forgejo-runner.timer halkyon-night.timer halkyon-morning.timer
systemctl list-timers 'forgejo-runner*' 'halkyon-*' # 01:55, 02:00, 05:00
```
To try it by day: `sudo systemctl start forgejo-runner` (it stops itself three hours
later) and push a branch. The job's first step, *Sandbox*, fails if the runner
reaches the instance, the home network, the tunnel or any IPv6 address, or sees `/data`.

37
deploy/night.sh Executable file
View file

@ -0,0 +1,37 @@
#!/bin/sh
# Rests the instance's heavy sidecars during the CI runner's nightly window and starts them
# again after it (ADR 0039, D6): `night.sh stop` at 01:55, `night.sh start` at 05:00, as
# deploy, from deploy/runner/'s halkyon-night and halkyon-morning units. Caddy, PostgreSQL
# and the apps' servers keep serving through the night, so people sign in and sync;
# Jellyfin, LanguageTool, the dictionaries, routes, map tiles and place search, with their
# refreshers, wait until the morning. Only those .env switches on; a deploy in between
# starts them with the rest, which is harmless.
set -eu
# Every service that takes memory or builds data, by its compose name.
heavy="jellyfin languagetool dictionaries brouter brouter-segments map-tiles map-tiles-refresh photon photon-data"
cd "$(dirname "$0")"
action=${1:-}
case $action in
stop | start) ;;
*)
echo "usage: $0 stop|start" >&2
exit 2
;;
esac
on=$(docker compose config --services)
services=
for service in $heavy; do
if printf '%s\n' "$on" | grep -qx "$service"; then
services="$services $service"
fi
done
if [ -z "$services" ]; then
echo "No heavy sidecar is switched on."
exit 0
fi
# shellcheck disable=SC2086 # one service a word
docker compose "$action" $services
echo "night.sh $action:$services"

View file

@ -1,12 +1,13 @@
#!/bin/sh
# Deploys learn.halkyon.eu from main. The only command deploy's keys may run: deploy's
# ~/.ssh/authorized_keys on the server pins both to this script, CI's from the server itself:
# Deploys the instance (halkyon.li, ADR 0039, D13; learn.halkyon.eu until November 2026)
# from main, by hand, once the commit's night's CI run passed. The only command deploy's
# keys may run: deploy's ~/.ssh/authorized_keys on the instance's host pins the
# developer's key to this script:
#
# restrict,from="127.0.0.1,::1",command="/home/deploy/halkyon/deploy/redeploy.sh" ssh-ed25519 … (CI deploy trigger)
# restrict,command="/home/deploy/halkyon/deploy/redeploy.sh" ssh-ed25519 … (manual deploys)
# restrict,command="/home/deploy/halkyon/deploy/redeploy.sh" ssh-ed25519 … (deploys by hand)
#
# 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),
# The developer passes the commit. This deploys it only
# - while it is still the tip of main (otherwise the newer tip is deployed instead),
# - 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),
@ -14,10 +15,10 @@
# too where .env switches each on, compose.train.yaml, compose.todo.yaml,
# compose.meditate.yaml), 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, which passes the commit as CI does:
# and fails unless that server answers afterwards. A failure leaves the checkout at the
# deployed commit, so running it again retries. From melchior, at home or through ramiel:
#
# ssh sahaquiel-deploy <commit>
# ssh balthasar-deploy <commit>
set -eu
main() {

View file

@ -1,10 +1,11 @@
# The Forgejo Actions runner of this repository's CI, on learn.halkyon.eu's server. Installed as
# The Forgejo Actions runner of this repository's CI, on halkyon.li's host at home (ADR 0039,
# D16), in the nightly window alone (forgejo-runner.timer). Installed as
# /etc/forgejo-runner/config.yml, read by forgejo-runner.service (next to this file).
# Written for forgejo-runner v13.1.0; its full reference: `forgejo-runner generate-config`.
#
# Jobs run directly on the host as the unprivileged user `runner`, never in containers:
# running containers would take Docker access, which is root on the production server.
# The systemd unit keeps the jobs away from production.
# running containers would take Docker access, which is root on the instance's host.
# The systemd unit keeps the jobs away from the instance and the home network.
log:
level: info
@ -21,7 +22,7 @@ runner:
fetch_interval: 10s
report_interval: 1s
# `runs-on: sahaquiel` in a workflow means: here, on the instance's server, on the host.
labels: ["sahaquiel:host"]
labels: ["balthasar:host"]
cache:
# No cache server (it would listen on a port of its own); the SDKs and package

View file

@ -1,6 +1,10 @@
# The Forgejo Actions runner of this repository's CI, on learn.halkyon.eu's server. Installed as
# /etc/systemd/system/forgejo-runner.service; its configuration is config.yml, next to
# this file.
# The Forgejo Actions runner of this repository's CI, on halkyon.li's host at home (ADR
# 0039, D6, D16; on learn.halkyon.eu's server until it retired in November 2026).
# Installed as /etc/systemd/system/forgejo-runner.service; its configuration is
# config.yml, next to this file. It runs in the nightly window alone: started at 02:00 by
# forgejo-runner.timer, stopped three hours later (RuntimeMaxSec), while night.sh rests
# the instance's heavy sidecars (halkyon-night.timer, halkyon-morning.timer). Jobs pushed
# during the day wait in Forgejo's queue.
#
# Jobs run directly on the host as the unprivileged user `runner` ("without any
# isolation" from that user, as Forgejo's security guide puts it), and they run our
@ -12,14 +16,19 @@
# container's processes (PostgreSQL and the server run as 999; Docker doesn't remap
# them): processes of one user ID can read each other's environment and, through
# /proc, their files. `useradd --system` picks 999 when it is free, so create it with
# useradd --system --uid 61000 --user-group --create-home \
# useradd --system --uid 61000 --user-group --no-create-home \
# --home-dir /var/lib/forgejo-runner --shell /usr/sbin/nologin runner
# Its home is /data/forgejo-runner on the HDD (the root disk has no room for Flutter and
# the caches), seen by the runner as /var/lib/forgejo-runner (BindPaths, below):
# install -d -o runner -g runner -m 700 /data/forgejo-runner /var/lib/forgejo-runner
# CI's first step checks this sandbox and fails if it leaks.
[Unit]
Description=Forgejo Actions runner (CI of halkyon_learn; jobs on the host)
Documentation=https://forgejo.org/docs/latest/admin/actions/security/
After=network-online.target
Wants=network-online.target
# Its folder is on the HDD, which its LUKS key file unlocks once the root disk is open.
RequiresMountsFor=/data
[Service]
User=runner
@ -32,18 +41,29 @@ Restart=on-failure
RestartSec=30
# The runner waits up to 10 minutes for a running job (shutdown_timeout).
TimeoutStopSec=11min
# The nightly window's end: three hours after forgejo-runner.timer started it at 02:00. A
# job still running is stopped, and the next one's Leftovers step clears what it left.
RuntimeMaxSec=3h
# Away from production: /home (deploy's .env, the checkout, the database dumps) is
# invisible, the rest of the system read-only except the runner's own directory, other
# users' processes hidden, and Docker's container networks, where the database listens,
# unreachable, and so is the tunnel to Jellyfin at home (10.61.96.0/30, ADR 0032, D6).
# invisible, and so is /data, where /home really lies, with Docker's data and the media,
# but for the runner's own folder; the rest of the system is read-only, other users'
# processes are hidden, and Docker and containerd out of reach.
ProtectHome=yes
ProtectSystem=strict
TemporaryFileSystem=/data:ro
BindPaths=/data/forgejo-runner:/var/lib/forgejo-runner
ReadWritePaths=/var/lib/forgejo-runner
PrivateTmp=yes
ProtectProc=invisible
InaccessiblePaths=-/run/docker.sock -/run/containerd -/var/lib/docker
IPAddressDeny=172.16.0.0/12 192.168.0.0/16 fd00::/8 10.61.96.0/30
InaccessiblePaths=-/run/docker.sock -/run/containerd -/var/lib/docker -/var/lib/containerd
# The network: the internet over IPv4 alone. No private IPv4 network (the home network
# and its router, Docker's networks, where the database listens, the tunnel to ramiel,
# Multipass' bridges, CGNAT and link-local addresses), and no IPv6 at all: the home
# network's IPv6 addresses are public ones, under a prefix the provider changes, on the
# same interface as the internet, so no address or interface could tell them apart. All
# CI fetches (pub.dev, GitHub, code.halkyon.app) answers over IPv4.
IPAddressDeny=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 169.254.0.0/16 ::/0
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
@ -66,20 +86,19 @@ SystemCallFilter=~@clock @cpu-emulation @debug @module @mount @obsolete @raw-io
SystemCallErrorNumber=EPERM
UMask=0077
# Never at the instance's expense: two of the four cores, at most 6 GB of the 8 (the
# kernel reclaims from CI first above 5.5 GB), the lowest priority for CPU and disk, and
# CI's processes the first the kernel kills should the whole server run out of memory.
# A cold `dart analyze` of the workspace with its lint plugin needs about 5.4 GB since
# Todo and the shared packages joined it (3.7 GB warm); below that the kernel slowed it
# to a crawl and then killed it, and OOMPolicy's default then stopped the whole unit.
CPUQuota=200%
MemoryHigh=5.5G
MemoryMax=6G
# Never at the instance's expense, which keeps serving through the night: three of the
# four cores (its servers idle then), at most 5 GB of the 7.2 (the kernel reclaims from CI
# first above 4.5 GB), and swap beyond, the lowest priority for CPU and disk, and CI's
# processes the first the kernel kills should the whole machine run out of memory. A
# cold `dart analyze` of the workspace with its lint plugin needs about 5.4 GB (3.7 GB
# warm), so it swaps a little rather than being killed: slower, within the window.
CPUQuota=300%
MemoryHigh=4.5G
MemoryMax=5G
OOMScoreAdjust=1000
TasksMax=2048
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
[Install]
WantedBy=multi-user.target
# No [Install]: never started at boot, only by forgejo-runner.timer.

View file

@ -0,0 +1,15 @@
# The CI runner's nightly window (ADR 0039, D6): started at 02:00, after night.sh has
# rested the heavy sidecars (halkyon-night.timer, 01:55), and stopped by its own unit three
# hours later (RuntimeMaxSec). Never started to catch up after a night the machine was
# off: the queue waits for the next night. As /etc/systemd/system/forgejo-runner.timer,
# enabled with `systemctl enable --now forgejo-runner.timer`.
[Unit]
Description=The CI runner's nightly window
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=false
Unit=forgejo-runner.service
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,12 @@
# night.sh start as deploy: starts the heavy sidecars again after the CI runner's window (ADR 0039, D6). As
# /etc/systemd/system/halkyon-morning.service, started by halkyon-morning.timer.
[Unit]
Description=halkyon.li: night.sh start
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
User=deploy
WorkingDirectory=/home/deploy/halkyon/deploy
ExecStart=/home/deploy/halkyon/deploy/night.sh start

View file

@ -0,0 +1,12 @@
# halkyon-morning.service at 05:00:00 daily (ADR 0039, D6). As
# /etc/systemd/system/halkyon-morning.timer, enabled with
# `systemctl enable --now halkyon-morning.timer`.
[Unit]
Description=halkyon.li: night.sh start at 05:00:00
[Timer]
OnCalendar=*-*-* 05:00:00
Persistent=false
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,12 @@
# night.sh stop as deploy: rests the heavy sidecars before the CI runner's window (ADR 0039, D6). As
# /etc/systemd/system/halkyon-night.service, started by halkyon-night.timer.
[Unit]
Description=halkyon.li: night.sh stop
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
User=deploy
WorkingDirectory=/home/deploy/halkyon/deploy
ExecStart=/home/deploy/halkyon/deploy/night.sh stop

View file

@ -0,0 +1,12 @@
# halkyon-night.service at 01:55:00 daily (ADR 0039, D6). As
# /etc/systemd/system/halkyon-night.timer, enabled with
# `systemctl enable --now halkyon-night.timer`.
[Unit]
Description=halkyon.li: night.sh stop at 01:55:00
[Timer]
OnCalendar=*-*-* 01:55:00
Persistent=false
[Install]
WantedBy=timers.target

View file

@ -148,13 +148,15 @@
`compose.jellyfin-home.yaml` go, with ADR 0032's open question.
- **D16 The runner on balthasar**: `deploy/runner/`'s unit as it is (its own user, no
`/home`, no Docker socket, Docker's networks denied), with these changes:
- its folder on the HDD outside `/home`;
- the home network denied by interface rather than by address, so Vodafone's
changing IPv6 prefix is covered too;
- the tunnel's `10.61.97.0/30` denied;
- the timer's window (D6).
CI's `runs-on` and the Sandbox step's checks follow it.
- its folder on the HDD, `/data/forgejo-runner`, seen as `/var/lib/forgejo-runner`;
the rest of `/data`, where `/home` really lies, hidden from it;
- no IPv6 at all, and no private IPv4 network (`10.0.0.0/8`, which holds the tunnel,
among them). Corrected while building: the proposal had the home network denied by
interface, but the home network and the internet come through the same one, and the
home network's IPv6 addresses are public ones under a prefix Vodafone changes. Every
host CI fetches from answers over IPv4;
- the timer's window (D6), and memory to fit beside the instance: 5 GB, swap beyond;
- CI's `runs-on` (`balthasar`) and the Sandbox step's checks following it.
### Decided after the proposal (8 Nov 2026)

View file

@ -0,0 +1,170 @@
import 'dart:io';
import 'package:test/test.dart';
import 'package:yaml/yaml.dart';
/// The CI runner on halkyon.li's host at home (ADR 0039, D6, D16): jobs in
/// the nightly window alone, while the heavy sidecars rest, and a sandbox
/// that keeps them away from the instance, the home network and the tunnel.
void main() {
String read(String path) => File(path).readAsStringSync();
/// [unit]'s value of [key], the last where it is set more than once.
String? setting(String unit, String key) =>
RegExp('^$key=(.*)\$', multiLine: true).allMatches(unit).lastOrNull?[1];
group("the runner's sandbox (D16)", () {
final unit = read('deploy/runner/forgejo-runner.service');
test('reaches no IPv6 address at all, so no home address, however its '
'prefix changes', () {
expect(setting(unit, 'IPAddressDeny')!.split(' '), contains('::/0'));
});
test('reaches no private IPv4 network: the home network, Docker, the '
'tunnel, link-local, CGNAT', () {
expect(
setting(unit, 'IPAddressDeny')!.split(' '),
containsAll([
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'100.64.0.0/10',
'169.254.0.0/16',
]),
);
});
test('sees nothing of /home, nor of /data but its own folder', () {
expect(setting(unit, 'ProtectHome'), 'yes');
expect(setting(unit, 'TemporaryFileSystem'), '/data:ro');
expect(
setting(unit, 'BindPaths'),
'/data/forgejo-runner:/var/lib/forgejo-runner',
);
expect(unit, contains('RequiresMountsFor=/data'));
});
test("can't reach Docker or containerd", () {
final hidden = setting(unit, 'InaccessiblePaths')!.split(' ');
expect(
hidden,
containsAll([
'-/run/docker.sock',
'-/run/containerd',
'-/var/lib/docker',
'-/var/lib/containerd',
]),
);
});
test('leaves the instance its share of memory, swapping rather than '
'starving it', () {
expect(setting(unit, 'MemoryMax'), '5G');
expect(setting(unit, 'MemoryHigh'), '4.5G');
expect(setting(unit, 'MemorySwapMax'), isNull);
expect(setting(unit, 'OOMScoreAdjust'), '1000');
});
});
group('the nightly window (D6)', () {
final unit = read('deploy/runner/forgejo-runner.service');
final timer = read('deploy/runner/forgejo-runner.timer');
test('starts at 02:00 alone, never at boot or to catch up', () {
expect(setting(timer, 'OnCalendar'), '*-*-* 02:00:00');
expect(setting(timer, 'Persistent'), 'false');
expect(unit, isNot(contains('WantedBy=multi-user.target')));
});
test('ends three hours later, whatever runs then', () {
expect(setting(unit, 'RuntimeMaxSec'), '3h');
});
group('rests the heavy sidecars around it, as deploy', () {
final night = read('deploy/night.sh');
final rest = RegExp(
r'^heavy="(.+)"$',
multiLine: true,
).firstMatch(night)![1]!.split(' ');
test('the ones that take memory or build', () {
expect(
rest,
unorderedEquals([
'jellyfin',
'languagetool',
'dictionaries',
'brouter',
'brouter-segments',
'map-tiles',
'map-tiles-refresh',
'photon',
'photon-data',
]),
);
});
test('never Caddy, PostgreSQL or an app server', () {
expect(
rest,
isNot(
anyOf(
contains('caddy'),
contains('postgres'),
contains('server'),
contains('train-server'),
contains('todo-server'),
contains('meditate-server'),
),
),
);
});
test('before the runner starts and after it ends', () {
expect(
setting(read('deploy/runner/halkyon-night.timer'), 'OnCalendar'),
'*-*-* 01:55:00',
);
expect(
setting(read('deploy/runner/halkyon-morning.timer'), 'OnCalendar'),
'*-*-* 05:00:00',
);
final evening = read('deploy/runner/halkyon-night.service');
final morning = read('deploy/runner/halkyon-morning.service');
expect(setting(evening, 'User'), 'deploy');
expect(setting(morning, 'User'), 'deploy');
expect(setting(evening, 'ExecStart'), contains('night.sh stop'));
expect(setting(morning, 'ExecStart'), contains('night.sh start'));
});
});
});
group('the workflow', () {
final workflow = loadYaml(read('.forgejo/workflows/ci.yaml')) as YamlMap;
final jobs = workflow['jobs'] as YamlMap;
test('checks on the runner at home', () {
expect((jobs['check'] as YamlMap)['runs-on'], 'balthasar');
expect(read('deploy/runner/config.yml'), contains('"balthasar:host"'));
});
test('deploys nothing: halkyon.li deploys by hand (D13)', () {
expect(jobs.keys, ['check']);
});
test('first proves it reaches neither the tunnel, the home network nor '
'any IPv6 address', () {
final sandbox =
((jobs['check'] as YamlMap)['steps'] as YamlList)
.cast<YamlMap>()
.singleWhere((step) => step['name'] == 'Sandbox')['run']
as String;
expect(sandbox, contains('/dev/tcp/10.61.97.2/443'));
expect(sandbox, contains('/dev/tcp/192.168.178.1/80'));
expect(sandbox, contains('/dev/tcp/2606:4700:4700::1111/443'));
expect(sandbox, contains('/data/home'));
expect(sandbox, isNot(contains('10.61.96.2')));
});
});
}