Files
tireless/.gitea/workflows/deploy.yaml
rob thijssen 835e3e98f2 fix(deploy): put ingress on the proxy, and make the lint script runnable
Three artefacts disagreed about where nginx runs. design.md §6.2 and the vhost
both said the hanzalova proxy; the API bound 127.0.0.1 and the workflow rsynced
the dashboard to bob. That combination deploys green and then serves nothing,
since a proxy on another host cannot reach bob's loopback.

Resolve it the way design.md already stated: nginx on the proxy, dashboard
shipped there, API bound 0.0.0.0 behind firewalld and the mesh. The health probe
now runs from the proxy over the mesh rather than from bob's loopback, so it
fails when firewalld is closed instead of passing regardless. infra-setup.sh
grows a proxy grant scoped to static files alone, and the nginx vhost install as
a manual step — it needs a certificate, and nothing was telling the operator to
install it at all.

npm run lint had never run: eslint 9 needs a flat config and there was none. Add
it, ignoring the ts-rs generated bindings, and run it in CI so it stays true.

Untrack dashboard/tsconfig.tsbuildinfo, a build artifact that would have put a
spurious diff in every pull request tireless opens.

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

166 lines
6.2 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).
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:
build:
runs-on: fedora-43-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
# Static build so a runner newer than the target cannot produce a binary
# the target's glibc rejects (§6 glibc skew).
- 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
- uses: actions/upload-artifact@v3
with:
name: tireless
path: |
target/x86_64-unknown-linux-musl/release/tireless-api
target/x86_64-unknown-linux-musl/release/tireless-worker
target/x86_64-unknown-linux-musl/release/tireless
dashboard/dist/
asset/
deploy:
needs: build
runs-on: fedora-43
steps:
- uses: actions/download-artifact@v3
with:
name: tireless
- name: authorise
run: |
install -d -m 0700 ~/.ssh
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
chmod 0600 ~/.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"
rsync $R --chmod 0755 target/x86_64-unknown-linux-musl/release/tireless-api \
gitea_ci@"$API_HOST":/usr/local/bin/tireless-api
rsync $R --chmod 0755 target/x86_64-unknown-linux-musl/release/tireless-worker \
gitea_ci@"$API_HOST":/usr/local/bin/tireless-worker
rsync $R --chmod 0755 target/x86_64-unknown-linux-musl/release/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.
R="--rsync-path=sudo rsync --mkpath"
rsync $R -a --delete 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 (§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.
sudo systemctl restart tireless-runner.service
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 tireless-runner; 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"