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). It replaces script/deploy.sh: # instead of an operator running it from a workstation with `pass`, 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 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 API_UPSTREAM_SCHEME: http API_UPSTREAM_ADDR: nikola.kosherinata.internal:42424 MUSL_TARGET: x86_64-unknown-linux-musl # The prerender fetches the live public API at build time; the client bundle # keeps the same-origin relative /api/v1 that nginx proxies. VITE_API_BASE: https://rob.tn/api/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 runs-on: fedora-44 steps: - uses: actions/checkout@v4 # The fedora-44 runner image bakes in node + pnpm (+ rsync) — no install. - name: Build web (vite client + prerender) run: | pnpm --dir ui install --frozen-lockfile pnpm --dir ui 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 run: | mkdir -p rendered python3 - <<'PY' import os t = open("asset/nginx/site.conf.tmpl").read() subs = { "SERVER_NAME": os.environ["SERVER_NAME"], "DOCROOT": os.environ["WEB_ROOT"], "API_UPSTREAM_SCHEME": os.environ["API_UPSTREAM_SCHEME"], "API_UPSTREAM_ADDR": os.environ["API_UPSTREAM_ADDR"], } for k, v in subs.items(): t = t.replace("{{%s}}" % k, v) open("rendered/site.conf", "w").write(t) PY - 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 sudo /usr/bin/systemctl reload nginx'