ci: check every push on sahaquiel and deploy a green main
Pushes to main were deployed by hand. Now Forgejo Actions runs tool/check.sh on every
push, and a green main deploys learn.halkyon.eu by itself.
The runner (forgejo-runner v13.1.0) runs on sahaquiel as the unprivileged user `runner`,
with jobs directly on the host: containers would take Docker access, which is root on the
production server, and Forgejo warns that host jobs have no isolation from the runner's
user. So its systemd unit (deploy/runner/forgejo-runner.service) provides it: /home and
the Docker socket invisible, Docker's container networks unreachable, no capabilities,
two cores and 3 GB at most, lowest priority. It is registered to this repository only,
with the UUID and token of Forgejo's "Create new runner" (the old `register` is
deprecated); the token reaches it as a systemd credential. The configuration
(deploy/runner/config.yml) was dry-run against v13.1.0, the unit rates 1.8 in
`systemd-analyze security`. CI runs on pushes only: a pull request from a fork would run
its code on the production server.
The deploy job calls deploy/redeploy.sh as `deploy`, through a key that authorized_keys
pins to that script and to connections from sahaquiel itself. The script deploys the
commit CI checked while it is still main's tip, as a fast-forward, only if every new
commit is signed by a key in deploy/allowed-signers (as the deployed checkout lists
them), after dumping the database (the last ten are kept), and fails unless the server
answers afterwards; a failure puts the checkout back. Tested against the local stack:
refusals of a malformed argument, a stale commit, a forged unsigned commit and a
non-fast-forward, a real deploy with its dump, a repeat, and a failed build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 21:49:04 +02:00
|
|
|
# CI: every push runs the checks of the pre-push hook (tool/check.sh); a push to main is
|
|
|
|
|
# then deployed to learn.halkyon.eu (deploy/redeploy.sh). Both jobs run on the runner on
|
|
|
|
|
# sahaquiel, directly on the host as an unprivileged user (deploy/runner/).
|
|
|
|
|
#
|
|
|
|
|
# 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:
|
|
|
|
|
# Runs the workflow again from the web UI, e.g. to retry a failed deploy.
|
|
|
|
|
workflow_dispatch:
|
|
|
|
|
|
|
|
|
|
jobs:
|
|
|
|
|
check:
|
|
|
|
|
runs-on: sahaquiel
|
|
|
|
|
timeout-minutes: 45
|
|
|
|
|
env:
|
|
|
|
|
# The Flutter the app is developed with; move it together with the local one.
|
|
|
|
|
FLUTTER_VERSION: 3.47.5
|
|
|
|
|
steps:
|
ci: stop a job whose sandbox leaks, first thing
Installing the runner on sahaquiel, `useradd --system` gave `runner` user ID 999: the ID
under which PostgreSQL and the server run inside their containers, which Docker doesn't
remap. To the kernel, those processes were the runner's own, so a job could have read the
server's environment (database password, session pepper, GitHub secret) and, through
/proc, PostgreSQL's files and its local socket, which the official image trusts. The
sandbox's other walls don't help there: ProtectProc hides only other users' processes,
IPAddressDeny doesn't cover Unix sockets. Only our own check ran as 999; `runner` now has
user and group ID 61000.
The check job now starts with a Sandbox step that fails unless only the runner's own
processes are visible, all in one process namespace, /home/deploy is invisible, the
Docker socket is unusable and Docker's bridge network unreachable. It would have caught
this; outside a sandbox it fails as it should. The unit and AGENTS.md say how to create
the user.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 22:12:31 +02:00
|
|
|
# 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
|
|
|
|
|
echo "This job can neither see nor reach production."
|
|
|
|
|
|
ci: check every push on sahaquiel and deploy a green main
Pushes to main were deployed by hand. Now Forgejo Actions runs tool/check.sh on every
push, and a green main deploys learn.halkyon.eu by itself.
The runner (forgejo-runner v13.1.0) runs on sahaquiel as the unprivileged user `runner`,
with jobs directly on the host: containers would take Docker access, which is root on the
production server, and Forgejo warns that host jobs have no isolation from the runner's
user. So its systemd unit (deploy/runner/forgejo-runner.service) provides it: /home and
the Docker socket invisible, Docker's container networks unreachable, no capabilities,
two cores and 3 GB at most, lowest priority. It is registered to this repository only,
with the UUID and token of Forgejo's "Create new runner" (the old `register` is
deprecated); the token reaches it as a systemd credential. The configuration
(deploy/runner/config.yml) was dry-run against v13.1.0, the unit rates 1.8 in
`systemd-analyze security`. CI runs on pushes only: a pull request from a fork would run
its code on the production server.
The deploy job calls deploy/redeploy.sh as `deploy`, through a key that authorized_keys
pins to that script and to connections from sahaquiel itself. The script deploys the
commit CI checked while it is still main's tip, as a fast-forward, only if every new
commit is signed by a key in deploy/allowed-signers (as the deployed checkout lists
them), after dumping the database (the last ten are kept), and fails unless the server
answers afterwards; a failure puts the checkout back. Tested against the local stack:
refusals of a malformed argument, a stale commit, a forged unsigned commit and a
non-fast-forward, a real deploy with its dump, a repeat, and a failed build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 21:49:04 +02:00
|
|
|
- name: Check out
|
|
|
|
|
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
|
|
|
|
|
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"
|
|
|
|
|
"$sdk/bin/flutter" config --no-analytics >/dev/null
|
|
|
|
|
"$sdk/bin/dart" --disable-analytics >/dev/null
|
|
|
|
|
fi
|
|
|
|
|
echo "$sdk/bin" >> "$FORGEJO_PATH"
|
|
|
|
|
"$sdk/bin/flutter" --version
|
|
|
|
|
|
|
|
|
|
- name: Dependencies
|
|
|
|
|
run: flutter pub get
|
|
|
|
|
|
|
|
|
|
- name: Checks
|
|
|
|
|
run: tool/check.sh
|
|
|
|
|
|
|
|
|
|
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 sahaquiel 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 }}"
|