Some checks failed
deploy / deploy (push) Failing after 7m11s
Run 5 shipped everything and started all three units, which then failed
identically:
configuration is invalid: /etc/tireless/config.toml: Permission denied
The config went out 0640 root:root, but the services run as `tireless`, which
is not in the root group. `--chown root:tireless` is not available as a fix:
on a fresh host that group does not exist until systemd-sysusers runs later in
the same deploy — the same ordering trap that produced the StateDirectory
change. So 0644 root:root, which is defensible precisely because this file
carries no secrets by design; the one that does, tireless.env, stays
0640 root:tireless and is installed by hand.
Testing that on the host turned up a second, quieter fault: `--chmod` only
applies to files rsync actually transfers, so a redeploy whose config is
byte-identical leaves the old mode in place. A mode fix would have appeared to
work on the deploy that introduced it and silently not applied afterwards. Add
-p to the shared options and switch to --chmod=F644/F755 so every push asserts
the mode it wants rather than hoping the content changed.
Also give tireless-runner an explicit start limit. Its preflight fails
permanently without an agent login, and Restart=on-failure with RestartSec=30s
never trips the default 5-starts-per-10s limiter, so it would have retried
forever. Three attempts in ten minutes, then failed, where systemctl status
says why.
Verified on bob: api and poller active, /v1/ready returns
{"config":"ok","database":"not_implemented","forge":"not_implemented"}, and
`tireless preflight` reports Subscription billing with the OpenCode lane
asserted non-Anthropic.
Refs #9
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
203 lines
9.2 KiB
YAML
203 lines
9.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).
|
|
#
|
|
# 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.
|
|
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
|