Files
moments/.gitea/workflows/deploy.yml
rob thijssen ef8521b9fb
Some checks failed
deploy / Build api + worker (static musl) (push) Waiting to run
deploy / Build prerendered web (push) Has been cancelled
deploy / Deploy moments-api to nikola (push) Has been cancelled
deploy / Deploy moments-worker to frootmig (push) Has been cancelled
deploy / Deploy web to oolon (push) Has been cancelled
fix(ci): prerender the web bundle after the api/worker deploy, not beside it
`build-web` had no `needs:`, so it ran in parallel with the binary build
and both deploy jobs. The prerender fetches VITE_API_BASE at build time,
which means the job baked whatever the api happened to be serving at the
moment it ran — old binary or new, depending on which runner finished
first.

Both outcomes showed up on consecutive runs. Run 68 won by ~25 seconds
and the repo-visibility fix reached the api and the crawler snapshot
together. Run 69 lost: the api served the new activity/summary private
aggregate while the baked /activity/ snapshot had none, so the 15 August
card read "47 changes in 3 repositories" where the api said 54 with 7
private. Browsers hydrate and refetch, so a visitor saw the right page —
but curl, which is what crawlers and AI screeners get, saw the stale one
until refresh.yml was dispatched by hand. Nothing in the pipeline noticed.

deploy-worker is in the needs list alongside deploy-api because the
worker owns migrations: a schema change isn't live until it has
restarted, so an api that depends on one isn't answering correctly before
then either.

The graph is now serial — binaries -> api/worker -> web — which costs the
overlap between the web build and the binary build. Worth it: the
alternative is a published snapshot whose correctness depends on runner
scheduling, failing silently and for a whole day.

Job graph verified acyclic with every `needs:` resolving to a real job.

Closes #8
2026-08-17 12:58:17 +03:00

400 lines
18 KiB
YAML

name: deploy
# Build and roll out all three moments components on a push to main:
# - moments-api (static musl binary, read-only API) -> nikola
# - moments-worker (static musl binary, ingestion daemon) -> frootmig
# - web (prerendered static SPA + nginx vhost) -> oolon
#
# This workflow is the source of infra truth (hosts, ports, paths live in `env`
# below — there is no manifest.yml in this model). A Gitea Actions runner deploys
# as the dedicated `gitea_ci` user over SSH, with secrets from the repo settings
# and scoped sudo (see asset/sudoers.d/ + script/infra-setup.sh). The nginx vhost
# is rendered by script/render-site-conf.py, shared with refresh.yml so the two
# never drift on what they substitute.
#
# The api/worker binaries are pure-rustls (no openssl), so they build as fully
# static musl — a runner newer than the target host can't produce an unloadable
# glibc binary (architecture/deployment-gitea-actions.md §6).
#
# Secrets (Gitea -> repo -> Settings -> Actions -> Secrets):
# RSYNC_SSH_KEY private SSH key whose pubkey script/infra-setup.sh installed
# QUERY_GITHUB_TOKEN github api token for the worker's poller
# QUERY_GITEA_TOKEN git.lair.cafe api token for the worker's poller
# (GITHUB_TOKEN / GITEA_TOKEN are reserved Actions names, hence the QUERY_ prefix.)
#
# One-time per-host provisioning (gitea_ci user, authorized_keys, scoped
# sudoers, and the postgres mTLS host cert the api/worker need) is done by
# script/infra-setup.sh — run it once per host before this workflow can succeed.
on:
push:
branches: [main]
workflow_dispatch:
# Serialize deploys; never cancel an in-flight one (a half-applied rollout is
# worse than a slightly stale one).
concurrency:
group: deploy
cancel-in-progress: false
env:
# --- infra truth (mirrors the former asset/manifest.yml) ---
API_HOST: nikola.kosherinata.internal
WORKER_HOST: frootmig.kosherinata.internal
WEB_HOST: oolon.kosherinata.internal
API_BIND: 0.0.0.0:42424
API_PORT: "42424"
SERVER_NAME: rob.tn
WEB_ROOT: /var/www/rob.tn
# TCP 443 on the oolon edge is owned by the stream SNI router
# (/etc/nginx/streams-enabled/sni-router.conf), which ssl_prereads the
# handshake and forwards to the local https tier on 127.0.0.1:14443 with
# PROXY protocol. An http vhost that binds 443 itself collides with the
# router: `nginx -t` still passes (nginx only detects duplicate listeners
# within a context, not across http{} and stream{}), but every subsequent
# reload aborts with "bind() to 0.0.0.0:443 failed (98: Address already in
# use)" while `systemctl reload` still exits 0. That silently froze oolon's
# running config for a day and stranded every renewed cert on disk.
WEB_LISTEN: 127.0.0.1:14443 ssl proxy_protocol
API_UPSTREAM_SCHEME: http
API_UPSTREAM_ADDR: nikola.kosherinata.internal:42424
MUSL_TARGET: x86_64-unknown-linux-musl
# Build-time prerender base: the runner is on the internal mesh and can't
# resolve/reach the public rob.tn, so fetch the API directly over the mesh
# (same host the deploy reaches). The api binary serves its routes under /v1.
# The client bundle still ships the same-origin relative /api/v1 that nginx
# proxies (VITE_API_BASE only affects the SSR build, not the browser bundle).
VITE_API_BASE: http://nikola.kosherinata.internal:42424/v1
DEPLOY_KEY: |
${{ secrets.RSYNC_SSH_KEY }}
jobs:
# The rust runner has cargo + musl but no node; the fedora runner has
# node + pnpm but no cargo — so the build is split across the two images.
build-binaries:
name: Build api + worker (static musl)
runs-on: rust
steps:
- uses: actions/checkout@v4
# The `rust` runner image provides cargo/clippy/rustfmt, musl-gcc, and the
# x86_64-unknown-linux-musl std. No package installs at run time.
- name: Lint + test (deploy gate)
run: |
cargo fmt --check --all
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
- name: Build moments-api + moments-worker (static musl release)
run: cargo build --release --target "${MUSL_TARGET}" -p moments-api -p moments-worker
- name: Stage binaries
run: |
mkdir --parents artifacts
cp "target/${MUSL_TARGET}/release/moments-api" artifacts/moments-api
cp "target/${MUSL_TARGET}/release/moments-worker" artifacts/moments-worker
- uses: actions/upload-artifact@v3
with: { name: moments-api, path: artifacts/moments-api, retention-days: 1 }
- uses: actions/upload-artifact@v3
with: { name: moments-worker, path: artifacts/moments-worker, retention-days: 1 }
build-web:
name: Build prerendered web
# The prerender fetches VITE_API_BASE at build time, so this job bakes
# whatever the api is serving when it runs. Without these dependencies it
# raced the api/worker deploys and the crawler snapshot silently disagreed
# with the live api for a whole day, until the nightly refresh: it won the
# race once (a visibility fix reached both together) and lost it once (an
# api field the snapshot then lacked). deploy-worker is in the list too,
# not just deploy-api — the worker owns migrations, so a schema change
# isn't applied until it has restarted.
#
# The cost is a serial deploy: binaries -> api/worker -> web. Worth it;
# the alternative is a snapshot whose correctness depends on which runner
# finished first.
needs: [deploy-api, deploy-worker]
runs-on: fedora-44
steps:
- uses: actions/checkout@v4
# The fedora-44 runner image bakes in node + pnpm (+ rsync) — no install.
# pnpm 10 blocks dependency build scripts unless approved, and CI doesn't
# reliably pick up the pnpm-workspace.yaml allowlist; so install with
# --ignore-scripts (no approval gate) and explicitly rebuild the two native
# deps vite needs (esbuild, @swc/core). Version-independent.
- name: Build web (vite client + prerender)
working-directory: ui
run: |
pnpm install --frozen-lockfile --ignore-scripts
pnpm rebuild @swc/core esbuild
pnpm run build
- uses: actions/upload-artifact@v3
with: { name: web-dist, path: ui/dist, retention-days: 1 }
deploy-api:
name: Deploy moments-api to nikola
needs: build-binaries
runs-on: fedora-44
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: moments-api, path: artifact }
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
gitea_ci@"${API_HOST}" 'hostname -f'
- name: Render config + units from infra truth
run: |
mkdir -p rendered
# HOSTNAME is the host's own fqdn (used for the postgres mTLS cert path).
python3 - "$API_HOST" "$API_BIND" "$API_PORT" <<'PY'
import sys
host, bind, port = sys.argv[1:4]
def render(src, dst, subs):
t = open(src).read()
for k, v in subs.items():
t = t.replace("{{%s}}" % k, v)
open(dst, "w").write(t)
render("asset/config/api.env.tmpl", "rendered/api.env",
{"HOSTNAME": host, "BIND": bind})
render("asset/systemd/moments-api-cert.path", "rendered/moments-api-cert.path",
{"HOSTNAME": host})
render("asset/firewalld/moments-api.xml.tmpl", "rendered/moments-api.xml",
{"API_PORT": port})
PY
- name: Provision service account + directories
run: |
# --mkpath: /etc/sysusers.d and /etc/firewalld/services don't exist by
# default on Fedora (only the /usr/lib variants ship).
rsync -az --mkpath --rsync-path='sudo rsync' \
asset/systemd/moments.sysusers.conf \
gitea_ci@"${API_HOST}":/etc/sysusers.d/moments.conf
ssh gitea_ci@"${API_HOST}" '
set -euo pipefail
sudo /usr/bin/systemd-sysusers
sudo /usr/bin/install -d -o root -g moments -m 0750 /etc/moments
sudo /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments'
- name: Sync binary, env, units, firewalld service
run: |
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0755 \
artifact/moments-api gitea_ci@"${API_HOST}":/usr/local/bin/moments-api
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:moments --chmod=0640 \
rendered/api.env gitea_ci@"${API_HOST}":/etc/moments/api.env
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
asset/systemd/moments-api.service \
gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api.service
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/moments-api-cert.path \
gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api-cert.path
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
asset/systemd/moments-api-cert-reload.service \
gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api-cert-reload.service
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/moments-api.xml \
gitea_ci@"${API_HOST}":/etc/firewalld/services/moments-api.xml
- name: Apply SELinux + firewalld + cert ACL (idempotent)
run: |
ssh gitea_ci@"${API_HOST}" '
set -euo pipefail
fqdn="$(hostname -f)"
# Let the moments user read the host private key for postgres mTLS.
sudo /usr/bin/setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem"
sudo /usr/sbin/restorecon -R /usr/local/bin/moments-api /etc/moments /var/lib/moments
if ! sudo /usr/sbin/semanage port -l | grep -E "^http_port_t" | grep -qw '"${API_PORT}"'; then
sudo /usr/sbin/semanage port -a -t http_port_t -p tcp '"${API_PORT}"'
fi
# firewalld only learns a freshly-shipped custom service after reload.
sudo /usr/bin/firewall-cmd --reload
if ! sudo /usr/bin/firewall-cmd --query-service=moments-api; then
sudo /usr/bin/firewall-cmd --add-service=moments-api --permanent
sudo /usr/bin/firewall-cmd --reload
fi'
- name: Restart moments-api
run: |
ssh gitea_ci@"${API_HOST}" '
set -euo pipefail
sudo /usr/bin/systemctl daemon-reload
sudo /usr/bin/systemctl enable --now moments-api-cert.path
sudo /usr/bin/systemctl enable moments-api.service
sudo /usr/bin/systemctl restart moments-api.service'
- name: Health probe
run: |
for i in 1 2 3 4 5 6 7 8 9 10; do
if ssh gitea_ci@"${API_HOST}" "curl -fsS http://127.0.0.1:${API_PORT}/v1/healthz"; then
echo; echo "moments-api healthy"; exit 0
fi
sleep 2
done
echo "moments-api did not become healthy" >&2
exit 1
- name: Capture startup journal
if: always()
run: |
sleep 3
ssh gitea_ci@"${API_HOST}" 'journalctl --unit moments-api.service --no-pager -n 200'
deploy-worker:
name: Deploy moments-worker to frootmig
needs: build-binaries
runs-on: fedora-44
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: moments-worker, path: artifact }
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
gitea_ci@"${WORKER_HOST}" 'hostname -f'
- name: Render worker config + cert.path from secrets
env:
# GITHUB_TOKEN / GITEA_TOKEN are reserved Actions secret names, so the
# repo secrets are QUERY_*; the rendered worker.env still uses the
# GITHUB_TOKEN / GITEA_TOKEN env vars the worker poller expects.
GITHUB_TOKEN: ${{ secrets.QUERY_GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.QUERY_GITEA_TOKEN }}
run: |
mkdir -p rendered
# Literal substitution via python so tokens with shell-special chars
# survive; secrets come from the environment, never a command line.
python3 - "$WORKER_HOST" <<'PY'
import os, sys
host = sys.argv[1]
t = open("asset/config/worker.env.tmpl").read()
subs = {
"HOSTNAME": host,
"GITHUB_TOKEN": os.environ.get("GITHUB_TOKEN", ""),
"GITEA_TOKEN": os.environ.get("GITEA_TOKEN", ""),
}
for k, v in subs.items():
t = t.replace("{{%s}}" % k, v)
open("rendered/worker.env", "w").write(t)
c = open("asset/systemd/moments-worker-cert.path").read().replace("{{HOSTNAME}}", host)
open("rendered/moments-worker-cert.path", "w").write(c)
PY
- name: Provision service account + directories
run: |
rsync -az --mkpath --rsync-path='sudo rsync' \
asset/systemd/moments.sysusers.conf \
gitea_ci@"${WORKER_HOST}":/etc/sysusers.d/moments.conf
ssh gitea_ci@"${WORKER_HOST}" '
set -euo pipefail
sudo /usr/bin/systemd-sysusers
sudo /usr/bin/install -d -o root -g moments -m 0750 /etc/moments
sudo /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments'
- name: Sync binary, env, units
run: |
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0755 \
artifact/moments-worker gitea_ci@"${WORKER_HOST}":/usr/local/bin/moments-worker
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:moments --chmod=0640 \
rendered/worker.env gitea_ci@"${WORKER_HOST}":/etc/moments/worker.env
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
asset/systemd/moments-worker.service \
gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker.service
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/moments-worker-cert.path \
gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker-cert.path
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
asset/systemd/moments-worker-cert-reload.service \
gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker-cert-reload.service
- name: Apply cert ACL + SELinux, restart worker
run: |
ssh gitea_ci@"${WORKER_HOST}" '
set -euo pipefail
fqdn="$(hostname -f)"
sudo /usr/bin/setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem"
sudo /usr/sbin/restorecon -R /usr/local/bin/moments-worker /etc/moments /var/lib/moments
sudo /usr/bin/systemctl daemon-reload
sudo /usr/bin/systemctl enable --now moments-worker-cert.path
sudo /usr/bin/systemctl enable moments-worker.service
sudo /usr/bin/systemctl restart moments-worker.service
sudo /usr/bin/systemctl is-active --quiet moments-worker.service'
- name: Capture startup journal
if: always()
run: |
sleep 3
ssh gitea_ci@"${WORKER_HOST}" 'journalctl --unit moments-worker.service --no-pager -n 200'
deploy-web:
name: Deploy web to oolon
needs: build-web
runs-on: fedora-44
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: web-dist, path: dist }
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
gitea_ci@"${WEB_HOST}" 'hostname -f'
- name: Render nginx vhost
# Shared with refresh.yml so the two pipelines can't drift on what they
# substitute; fails loudly if any {{PLACEHOLDER}} lacks an env value or
# survives substitution, so a broken vhost never reaches oolon.
run: python3 script/render-site-conf.py rendered/site.conf
- name: Sync static site (prerendered)
run: |
ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/bin/install -d -m 0755 '"${WEB_ROOT}"
rsync -az --delete --mkpath --rsync-path='sudo rsync' \
--chown=root:root --chmod=D755,F644 \
dist/ gitea_ci@"${WEB_HOST}":"${WEB_ROOT}/"
ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/sbin/restorecon -R '"${WEB_ROOT}"
- name: Sync nginx vhost + apply SELinux
run: |
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/site.conf \
gitea_ci@"${WEB_HOST}":/etc/nginx/conf.d/"${SERVER_NAME}".conf
ssh gitea_ci@"${WEB_HOST}" '
set -euo pipefail
# nginx proxies /api/ to the api host across the WG mesh.
sudo /usr/sbin/setsebool -P httpd_can_network_connect on
if ! sudo /usr/sbin/semanage port -l | grep -E "^http_port_t" | grep -qw '"${API_PORT}"'; then
sudo /usr/sbin/semanage port -a -t http_port_t -p tcp '"${API_PORT}"'
fi
sudo /usr/sbin/restorecon -R /etc/nginx/conf.d/'"${SERVER_NAME}"'.conf
sudo /usr/sbin/nginx -t
# `nginx -t` cannot see a listener that collides across http{} and
# stream{}, and `systemctl reload` is just SIGHUP — it exits 0 even
# when nginx aborts the reconfiguration and keeps the old cycle. A
# reload that really landed always spawns a fresh worker generation,
# so assert that rather than trusting the exit code.
master=$(cat /run/nginx.pid)
before=$(pgrep -P "$master" | sort | tr "\n" " ")
sudo /usr/bin/systemctl reload nginx
for _ in $(seq 1 10); do
sleep 1
after=$(pgrep -P "$master" | sort | tr "\n" " ")
[ "$before" = "$after" ] || break
done
if [ "$before" = "$after" ]; then
echo "nginx reload did not take effect: worker generation unchanged" >&2
sudo /usr/bin/tail -n 50 /var/log/nginx/error.log >&2 || true
exit 1
fi'