Files
tireless/.gitea/workflows/deploy.yaml
rob thijssen 3c7d95edf9
Some checks failed
deploy / deploy (push) Failing after 5m31s
fix(deploy): correct the runner label, the vhost listen line and the cert paths
Three faults, any one of which would have failed the first deploy — found by
reading the house conventions in ~/git/architecture rather than by running it.

`runs-on: fedora-43-rust` is not a registered runner label. The catalogue in
gitea-runners.md §2 has `rust`; the job would never have been scheduled. Also
collapse build and deploy into one job: the `rust` image descends from
runner-fedora-44 and carries node, ssh and rsync, so the artifact upload and
download — on actions/*-artifact@v3, EOL upstream — bought nothing but a
round-trip and a directory-structure assumption. rpm.lair.cafe's working deploy
is single-job for the same reason.

The nginx vhost bound `listen 443 ssl`. TCP 443 on hanzalova belongs to the
stream SNI router (reverse-proxies.md §4-5, and the live bench.internal.conf
confirms it), so the vhost would never have been reached — the router answers
first with whichever certificate its default branch holds. `nginx -t` passes
either way, which is what makes this worth catching by reading rather than by
deploying. Now `listen 127.0.0.1:14443 ssl proxy_protocol;`.

Cert paths pointed at the host identity cert under /etc/pki/tls. A per-service
name needs its own cert (internal-tls.md §2): the host cert's SAN is bob's FQDN,
not tireless.internal, so verification would have failed. Now
/etc/nginx/tls/{cert,key}/tireless.internal.pem, minted with --san and renewed
by step@tireless.timer.

infra-setup.sh now provisions the ingress rather than describing it: mints the
cert through the JWK provisioner (removing the credential even on failure),
installs the vhost via sites-available + symlink, and registers the
split-horizon record on BOTH routers — a record on one router NXDOMAINs at the
other site. opn-cli has no reconfigure verb, so the apply is a direct API POST;
without it the name resolves only whenever Unbound next happens to reload.

Also fold the stale-bindings check into the workflow (closes the CI half of #8)
and let the runner unit fail without failing the deploy, since it correctly
refuses to start until the interactive login exists.

Refs #9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
2026-08-07 16:23:32 +03:00

177 lines
7.4 KiB
YAML

name: deploy
# The workflow is the source of infra truth: hosts, ports and paths live here,
# not in a separate manifest (architecture/deployment-gitea-actions.md).
#
# One job, not build + deploy. The `rust` runner image is based on
# runner-fedora-44, so it carries node, npm, ssh and rsync alongside the Rust
# toolchain — everything this needs. Splitting the deploy onto a lighter runner
# is a cost convenience (architecture/gitea-runners.md §3), and it would cost an
# artifact round-trip to buy it. Keeping one job removes that entirely.
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: false
env:
API_HOST: bob.hanzalova.internal
API_PORT: "23296"
# Ingress is the office proxy, not bob (doc/plan/design.md §6.2): nginx there
# serves the static dashboard and reverse-proxies /v1 across the mesh. The
# dashboard therefore ships to the proxy, and only the binaries ship to bob.
WEB_HOST: hanzalova.internal
WEB_ROOT: /var/www/tireless
VITE_API_BASE_URL: ""
jobs:
deploy:
# `rust` — not `fedora-43`, which has no cargo, and not `fedora-43-rust`,
# which is not a registered label at all (architecture/gitea-runners.md §2).
runs-on: rust
steps:
- uses: actions/checkout@v4
# Quality gate first: a commit that fails lint or tests never deploys.
- name: format
run: cargo fmt --all --check
- name: lint
run: cargo clippy --all-targets --all-features -- -D warnings
- name: test
run: cargo test --workspace
# The dashboard consumes TypeScript generated from the Rust domain types
# by ts-rs during the test run. If the committed bindings no longer match,
# fail here rather than let the dashboard build against a stale type.
- name: generated bindings are current
run: |
if ! git diff --exit-code dashboard/src/api/generated; then
echo "::error::generated TypeScript is stale."
echo "Run 'cargo test -p tireless-entities' and commit the result."
exit 1
fi
# Static build so a runner newer than the target cannot produce a binary
# the target's glibc rejects (architecture/deployment-gitea-actions.md §6).
- name: build binaries
run: cargo build --release --target x86_64-unknown-linux-musl
- name: build dashboard
working-directory: dashboard
run: |
npm ci
npm run lint
npm run build
- name: authorise
env:
RSYNC_SSH_KEY: ${{ secrets.RSYNC_SSH_KEY }}
run: |
install -d -m 0700 ~/.ssh
printf '%s\n' "$RSYNC_SSH_KEY" | install -m 0600 /dev/stdin ~/.ssh/id_gitea_ci
cat >> ~/.ssh/config <<'EOF'
Host *
IdentityFile ~/.ssh/id_gitea_ci
StrictHostKeyChecking accept-new
EOF
ssh gitea_ci@"$API_HOST" hostname -f
ssh gitea_ci@"$WEB_HOST" hostname -f
- name: render config
env:
DEPLOY_HOST_FQDN: ${{ env.API_HOST }}
run: |
# Literal substitution so secrets containing shell metacharacters survive.
python3 - <<'PY'
import os, pathlib
tmpl = pathlib.Path("asset/config/config.toml.tmpl").read_text()
for key in ("DEPLOY_HOST_FQDN",):
tmpl = tmpl.replace("{{%s}}" % key, os.environ[key])
pathlib.Path("config.toml").write_text(tmpl)
PY
- name: ship artifacts
run: |
R="--rsync-path=sudo rsync --mkpath"
B=target/x86_64-unknown-linux-musl/release
rsync $R --chmod 0755 "$B/tireless-api" \
gitea_ci@"$API_HOST":/usr/local/bin/tireless-api
rsync $R --chmod 0755 "$B/tireless-worker" \
gitea_ci@"$API_HOST":/usr/local/bin/tireless-worker
rsync $R --chmod 0755 "$B/tireless" \
gitea_ci@"$API_HOST":/usr/local/bin/tireless
rsync $R --chmod 0640 config.toml \
gitea_ci@"$API_HOST":/etc/tireless/config.toml
rsync $R asset/systemd/tireless.sysusers.conf \
gitea_ci@"$API_HOST":/etc/sysusers.d/tireless.conf
for unit in tireless-api tireless-poller tireless-runner; do
rsync $R "asset/systemd/$unit.service" \
gitea_ci@"$API_HOST":"/etc/systemd/system/$unit.service"
done
rsync $R asset/firewalld/tireless-api.xml \
gitea_ci@"$API_HOST":/etc/firewalld/services/tireless-api.xml
- name: ship dashboard
run: |
# To the proxy, not to bob — that is where nginx serves it from.
# restorecon because a webroot not labelled httpd_sys_content_t
# gives nginx a 403 (architecture/reverse-proxies.md §4).
rsync --rsync-path="sudo rsync --mkpath" -a --delete \
--chmod=D755,F644 dashboard/dist/ \
gitea_ci@"$WEB_HOST":"$WEB_ROOT/"
ssh gitea_ci@"$WEB_HOST" "sudo restorecon -R $WEB_ROOT"
- name: apply system state
run: |
ssh gitea_ci@"$API_HOST" bash -euo pipefail <<EOF
sudo systemd-sysusers
sudo restorecon -R /usr/local/bin/tireless-api /usr/local/bin/tireless-worker \
/usr/local/bin/tireless /etc/tireless /var/lib/tireless
# firewalld only learns a freshly-shipped service after a reload
# (architecture/deployment-gitea-actions.md §6).
sudo firewall-cmd --reload
zone=\$(sudo firewall-cmd --get-default-zone)
sudo firewall-cmd --zone=\$zone --query-service=tireless-api \
|| { sudo firewall-cmd --permanent --zone=\$zone --add-service=tireless-api; \
sudo firewall-cmd --zone=\$zone --add-service=tireless-api; }
sudo systemctl daemon-reload
sudo systemctl restart tireless-api.service
sudo systemctl restart tireless-poller.service
# The runner is restarted last and deliberately: it may be mid-agent-run,
# and TimeoutStopSec=120 will not wait out a job with an hour-long
# ceiling. A killed run is not lost — its claim lease expires and the
# job returns to the pool (design.md §4.2) — but it does cost the tokens
# already spent. That is the accepted trade for a simple deploy; see
# design.md §10 for why it bites hardest when tireless deploys itself.
#
# It is expected to fail until the interactive Claude Code login has
# been completed as the service account (script/infra-setup.sh step 1);
# a runner with no credentials refuses to start rather than pretending.
sudo systemctl restart tireless-runner.service || \
echo "runner did not start — check 'tireless preflight' and the login"
EOF
- name: health probe
run: |
# Probe from the proxy, over the mesh — the path a user actually takes.
# A loopback probe on bob would pass even if firewalld were closed.
ssh gitea_ci@"$WEB_HOST" \
"curl -fsS http://$API_HOST:$API_PORT/v1/ready"
for unit in tireless-api tireless-poller; do
ssh gitea_ci@"$API_HOST" "systemctl is-active \$unit.service"
done
- name: startup journal
if: always()
run: |
ssh gitea_ci@"$API_HOST" \
"journalctl -u tireless-api -u tireless-poller -u tireless-runner \
--since '5 minutes ago' --no-pager" || true