Files
tireless/.gitea/workflows/deploy.yaml
rob thijssen 8067cde288
All checks were successful
deploy / deploy (push) Successful in 5m39s
fix(deploy): expand $unit locally in the health probe
Run 7 shipped everything, brought api and poller up, served /v1/ready through
the proxy — and failed on the last step with exit 4.

  ssh gitea_ci@"$API_HOST" "systemctl is-active \$unit.service"

The escaped \$unit is a leftover from when this ran inside a heredoc. Over a
plain ssh it sends a literal $unit for the *remote* shell to expand, against a
variable that only exists in the workflow's shell — so it ran
`systemctl is-active .service`, which is exit 4, unit not found. It was checking
nothing, and would have kept reporting healthy no matter what the units did.

Also pass -n to both probe sshs. ssh reads stdin by default, and that is a trap
waiting for whoever next pipes something into this step: the first ssh eats the
rest of the script and the remaining checks silently never run. (It bit the test
harness for this fix, which is how it was noticed.)

And say in a comment why tireless-runner is not probed: it refuses to start
until the interactive agent login exists, so probing it would make every deploy
red for something the deploy cannot fix.

Verified by extracting this step's body from the YAML and running it as a file,
the way Actions does:

  {"config":"ok","database":"not_implemented","forge":"not_implemented"}
  tireless-api: active
  tireless-poller: active

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:59:49 +03:00

216 lines
10 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: |
# An array, not a string. `R="--rsync-path=sudo rsync --mkpath"` used
# as `rsync $R` word-splits into three arguments — `--rsync-path=sudo`
# plus a stray `rsync` that rsync reads as a source path — and the
# remote end runs `sudo --server`, which sudo rejects.
#
# --mkpath because rsync will not create a missing destination
# directory for a single-file copy, and Fedora ships neither
# /etc/sysusers.d nor /etc/firewalld/services
# (architecture/deployment-gitea-actions.md §6).
# -p so --chmod actually applies. Without it, --chmod only affects
# files rsync transfers, so a redeploy whose config is byte-identical
# leaves whatever mode the file already had — including a wrong one.
R=(-p --rsync-path="sudo rsync --mkpath")
B=target/x86_64-unknown-linux-musl/release
rsync "${R[@]}" --chmod=F755 "$B/tireless-api" \
gitea_ci@"$API_HOST":/usr/local/bin/tireless-api
rsync "${R[@]}" --chmod=F755 "$B/tireless-worker" \
gitea_ci@"$API_HOST":/usr/local/bin/tireless-worker
rsync "${R[@]}" --chmod=F755 "$B/tireless" \
gitea_ci@"$API_HOST":/usr/local/bin/tireless
# 0644 root:root, not 0640: the services run as `tireless`, which is
# not in the root group, so 0640 root:root is unreadable to them —
# and `--chown root:tireless` cannot be used here because on a fresh
# host the group does not exist until systemd-sysusers runs, later in
# this same deploy.
#
# World-readable is acceptable because this file carries no secrets by
# design: tokens are named by environment variable rather than written
# down, and there is a test asserting no api-key field can appear in it.
# The file that does carry secrets is /etc/tireless/tireless.env, which
# stays 0640 root:tireless and is installed by hand, never by CI.
rsync "${R[@]}" --chmod=F644 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
# /var/lib/tireless is absent here on purpose: it does not exist yet on
# a fresh host, and the units declare StateDirectory=tireless, so
# systemd creates, owns and labels it at first start.
sudo restorecon -R /usr/local/bin/tireless-api /usr/local/bin/tireless-worker \
/usr/local/bin/tireless /etc/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.
# -n on every probe ssh: without it ssh reads stdin, and a future
# edit that pipes anything into this step would have the first ssh
# swallow the rest of the script.
ssh -n gitea_ci@"$WEB_HOST" \
"curl -fsS http://$API_HOST:$API_PORT/v1/ready"
echo
# $unit unescaped: it must expand HERE, in the workflow's shell. The
# escaped form is a leftover from when this ran inside a heredoc, and
# over a plain ssh it sends a literal `$unit` for the remote shell to
# expand against nothing — `systemctl is-active .service`, exit 4.
for unit in tireless-api tireless-poller; do
printf '%s: ' "$unit"
ssh -n gitea_ci@"$API_HOST" "systemctl is-active $unit.service"
done
# tireless-runner is deliberately not probed: it refuses to start
# until the interactive agent login exists on the host, which is a
# manual step. Adding it here would make every deploy red for a
# reason the deploy cannot fix.
- 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