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>
212 lines
11 KiB
YAML
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
|