# 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 learn.halkyon.eu (deploy/redeploy.sh). # 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?"). # # Pushes only, no pull requests: a pull request from a fork would run its own code on # the production server's runner. name: CI on: push: # Branches only, not tags: a release pushes main and then its tag, both on the same # 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. workflow_dispatch: jobs: check: runs-on: sahaquiel # 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 env: # The Flutter the app is developed with; move it together with the local one. FLUTTER_VERSION: 3.47.8 # The Serverpod CLI, for the generated-code check: the servers' Serverpod version # (every package pins it; the root's tests hold it to the lock); move it together # 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. - name: Sandbox run: | fail() { echo "The runner's sandbox leaks: $*" >&2; exit 1; } # Only the runner's own processes are visible. A container's process shows up # (in a process namespace of its own) if it runs under the runner's user ID, # and then its environment and files are readable too. [ "$(ps -e -o uid= | sort -u | wc -l)" -eq 1 ] || fail "other users' processes are visible" namespaces=$(for p in /proc/[0-9]*; do readlink "$p/ns/pid"; done 2>/dev/null | sort -u | wc -l) [ "$namespaces" -le 1 ] || fail "processes of $namespaces process namespaces are visible" # deploy's home (.env, the checkout, the dumps) and the Docker socket are out of # reach. [ ! -e /home/deploy ] || fail "/home/deploy is visible" { [ ! -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. if timeout 5 bash -c 'exec 3<>/dev/tcp/172.17.0.1/443' 2>/dev/null; then fail "Docker's network is reachable" fi # Caddy's own network by IPv6 too, where it has an address there. 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" fi echo "This job can neither see nor reach production." # 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 # SIGKILL), and no later step runs, `if: always()` ones neither. Yet on 22 Oct a # cancelled job left its analysis server running, 4 GB, until someone noticed. The # runner takes one job at a time (capacity: 1), so a process here in a job's working # folder that is neither the runner nor this step is such a leftover: it is stopped # before anything else starts, and the log names it. - name: Leftovers run: | own_group=$(ps -o pgid= -p $$ | tr -d ' ') # This step and the runner above it. keep=' ' pid=$$ while [ -n "$pid" ] && [ "$pid" -gt 1 ]; do keep="$keep$pid " pid=$(ps -o ppid= -p "$pid" | tr -d ' ') done left=$(ps -e -o pid=,pgid= | while read -r pid pgid; do case "$keep" in *" $pid "*) continue ;; esac [ "$pgid" != "$own_group" ] || continue # Only what a job started: its working folder, or one since removed. case "$(readlink "/proc/$pid/cwd" 2>/dev/null)" in "$HOME/work/"*) echo "$pid" ;; esac done) if [ -z "$left" ]; then echo 'Nothing left behind by earlier jobs.' exit 0 fi echo 'Left behind by earlier jobs, stopped now:' # shellcheck disable=SC2086 # one process ID a word ps -o pid=,etime=,rss=,args= -p "$(echo $left | tr ' ' ,)" | cut -c1-200 # shellcheck disable=SC2086 kill -TERM $left 2>/dev/null || true sleep 5 # shellcheck disable=SC2086 kill -KILL $left 2>/dev/null || true # A commit that passed this job before isn't checked again: a fast-forward merge # brings the commit a branch's push checked to main unchanged, and only its deploy # is new. The marks live in the runner's home; only pushes run this workflow, so # only those who may push can leave one. Every later step of this job asks. - name: Checked before? run: | if [ -e "$HOME/checked/${{ github.sha }}" ]; then echo "${{ github.sha }} passed these checks before; they aren't run again." echo 'CHECKED_BEFORE=true' >> "$FORGEJO_ENV" fi - name: Check out if: env.CHECKED_BEFORE != 'true' env: TOKEN: ${{ github.token }} run: | git init -q . # The job's token as a header from the environment, so it never shows in a # command line; plain git, so the host needs no Node.js for actions/checkout. export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader \ GIT_CONFIG_VALUE_0="Authorization: Basic $(printf 'ci:%s' "$TOKEN" | base64 -w0)" git fetch -q --depth 1 "${{ github.server_url }}/${{ github.repository }}.git" "${{ github.sha }}" git checkout -q -f FETCH_HEAD git clean -q -ffdx - name: Flutter if: env.CHECKED_BEFORE != 'true' run: | sdk="$HOME/sdk/flutter-$FLUTTER_VERSION" if [ ! -x "$sdk/bin/flutter" ]; then rm -rf "$sdk" git clone -q --depth 1 --branch "$FLUTTER_VERSION" https://github.com/flutter/flutter.git "$sdk" fi # No usage reports to Google, every time: a cancelled job may have left an SDK # behind before switching them off. "$sdk/bin/flutter" config --no-analytics >/dev/null "$sdk/bin/dart" --disable-analytics >/dev/null echo "$sdk/bin" >> "$FORGEJO_PATH" "$sdk/bin/flutter" --version # Exactly the committed pubspec.lock, or a failure here with pub's own message: # CI never resolves something else on its own. # # The second try works around pub's https://github.com/dart-lang/pub/issues/4912 # (Dart v3.13): when the runner's cached copy of a git dependency's repository (the # editor fork, pure_zstd) lacks the locked commit, the first run resolves that # package without its dependencies and fails, but fetches the commit, so the # second goes through. Drop it once the Dart of FLUTTER_VERSION has pub's fix # (ebba0dc, pull request 4914). - name: Dependencies if: env.CHECKED_BEFORE != 'true' run: flutter pub get --enforce-lockfile || flutter pub get --enforce-lockfile # The Serverpod CLI at SERVERPOD_VERSION, for the checks' generated-code step # (tool/check.dart): the generated code is committed, so it has to be exactly what # the generator makes of the models and endpoints, and the step refuses a CLI at # another version than the lock holds Serverpod at. - name: Serverpod CLI if: env.CHECKED_BEFORE != 'true' 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 echo "$bin" >> "$FORGEJO_PATH" # 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 # in its own checkout. - name: Test passwords if: env.CHECKED_BEFORE != 'true' run: | for name in SERVERPOD_DATABASE_PASSWORD \ SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper \ SERVERPOD_PASSWORD_emailSignInPepper \ SERVERPOD_PASSWORD_totpKey \ SERVERPOD_PASSWORD_recoveryCodeKey \ SERVERPOD_PASSWORD_signInDecoyKey \ SERVERPOD_PASSWORD_serviceSecret; do echo "$name=$(openssl rand -hex 32)" >> "$FORGEJO_ENV" done - name: Checks if: env.CHECKED_BEFORE != 'true' run: dart run tool/check.dart # Reached only when every step above passed. Marks older than 90 days go. - name: Remember the commit if: env.CHECKED_BEFORE != 'true' run: | 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. - name: Deploy to learn.halkyon.eu 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 }}"