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 <