diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 9a96a52..142f1d1 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -42,10 +42,69 @@ jobs: # `runner-fedora-44`, so it carries node + pnpm too and one job can build # both halves. runs-on: rust + outputs: + rust: ${{ steps.changes.outputs.rust }} steps: - uses: actions/checkout@v4 + with: + # The change detection below diffs against the previous head, which a + # depth-1 clone does not contain. + fetch-depth: 0 + + - name: what changed + id: changes + # The Rust half is the expensive half — a cold musl release build plus + # the gate is most of the wall clock — and a frontend commit cannot + # change a byte of it. Deciding here rather than with `on.push.paths` + # because that filter skips the *whole* workflow, and the site still + # has to build and ship. + # + # Anything that is not clearly frontend-only counts as Rust. The cost of + # a false positive is a slow deploy; the cost of a false negative is a + # binary on bob that does not match this commit, which nothing would + # report. Two entries are less obvious than they look: + # + # - `asset/` because deploy-api ships the systemd units, the firewalld + # service and the rendered config template from it. + # - `web/src/api/generated/` because the drift gate below only runs + # when `cargo test` has regenerated those files. A hand-edit of them + # is exactly what that gate exists to catch, so it must not be able + # to arrive as a "frontend-only" change. + # + # `.gitea/` is in the list so that editing this file exercises the whole + # path it describes. + run: | + set -euo pipefail + base="${{ github.event.before }}" + reason="" + if [ "${{ github.event_name }}" != "push" ]; then + reason="not a push" + elif [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ]; then + reason="no previous head — new branch or force push" + elif ! git cat-file -e "${base}^{commit}" 2>/dev/null; then + reason="previous head ${base} is not in this clone" + fi + + if [ -n "$reason" ]; then + echo "building everything: ${reason}" + echo "rust=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + changed=$(git diff --name-only "$base" "${{ github.sha }}") + echo "changed since ${base}:" + echo "$changed" | sed 's/^/ /' + + if echo "$changed" | grep -qE '^(crates/|asset/|script/|\.gitea/|\.cargo/|\.sqlx/|web/src/api/generated/|Cargo\.(toml|lock)$|rust-toolchain\.toml$|rustfmt\.toml$)'; then + echo "rust=true" >> "$GITHUB_OUTPUT" + echo "→ building and deploying the API" + else + echo "rust=false" >> "$GITHUB_OUTPUT" + echo "→ frontend only; skipping the Rust build and the API deploy" + fi - name: rust gate + if: steps.changes.outputs.rust == 'true' # Format, lint-as-error and the full test suite before anything is # built, so a broken commit never reaches a host. SQLX_OFFLINE makes the # compile-time-checked queries read the committed .sqlx cache instead of @@ -59,6 +118,7 @@ jobs: cargo test --all - name: build api and cli + if: steps.changes.outputs.rust == 'true' env: SQLX_OFFLINE: "true" run: | @@ -72,6 +132,7 @@ jobs: file target/x86_64-unknown-linux-musl/release/blackbeard-api - name: generated types are current + if: steps.changes.outputs.rust == 'true' # ts-rs writes web/src/api/generated/ from the Rust entities crate. # `cargo test` above regenerates them; a diff here means someone edited # a DTO and committed the Rust without the TypeScript, which would @@ -102,6 +163,10 @@ jobs: - uses: actions/upload-artifact@v3 with: name: blackbeard + # The binaries are absent on a frontend-only run. `warn` rather than + # `error` for exactly that case — `deploy-api` is skipped alongside, + # so nothing downstream goes looking for them. + if-no-files-found: warn path: | target/x86_64-unknown-linux-musl/release/blackbeard-api target/x86_64-unknown-linux-musl/release/blackbeard @@ -110,6 +175,10 @@ jobs: deploy-api: needs: build + # Skipped when nothing that reaches bob changed. The binary already there is + # still the right one — this deploy is idempotent and its absence changes + # nothing about what is running. + if: needs.build.outputs.rust == 'true' # `infra`, not `fedora-43`: the targets are mesh-only .internal names and # the fedora runners have no route to the WireGuard mesh. Same reason as # lair/quantus and lair/mail. diff --git a/CLAUDE.md b/CLAUDE.md index e5fe137..cbd0e27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -308,6 +308,19 @@ OPNsense LAN interface (`reverse-proxies.md` §2). Verify the vhost with `--resolve blackbeard.observer:443:127.0.0.1`, which still exercises the `:443` stream router, SNI, the vhost, the cross-site hop and the API. +**A frontend-only push skips the Rust build and the API deploy.** The `what +changed` step in `deploy.yaml` diffs against `github.event.before` and sets one +output; the gate, the musl build, the ts-rs drift check and the whole +`deploy-api` job hang off it. Two entries in that path list are not obvious and +must not be trimmed: `asset/`, because `deploy-api` ships the systemd units, the +firewalld service and the rendered config from it; and +`web/src/api/generated/`, because the drift gate only runs when `cargo test` has +regenerated those files, so a hand-edit of them must not be able to arrive +labelled "frontend only" — which is precisely the change that gate exists to +catch. Anything unrecognised counts as Rust: a false positive costs a slow +deploy, a false negative leaves a binary on bob that does not match the commit +and nothing reports it. + **Runner labels.** `fedora-*` images have **no cargo** — Rust builds go on `rust`, which is based on `runner-fedora-44` and so carries node + pnpm too. Never `corepack enable`; pnpm is already on PATH (`gitea-runners.md` §4).