halkyon/.forgejo/workflows/ci.yaml
Tizian 「ティツィアーン」 c8d67b1eb7
Some checks failed
CI / check (push) Has been cancelled
build(ci,deploy)!: the runner at home in a nightly window, deploys by hand (ADR 0039, D6, D13, D16)
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>
2026-11-08 19:59:40 +01:00

212 lines
11 KiB
YAML

# CI: every push of a branch runs the checks of the pre-push hook (tool/check.dart, every app's and
# 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 instance's host.
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 check a commit once more.
workflow_dispatch:
jobs:
check:
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
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 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; }
# 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"
# 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.
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 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
# 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
# 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