Files
rustingface/.gitea/workflows/deploy.yml
rob thijssen 29e4b08947
Some checks failed
deploy / build-web (push) Successful in 1m36s
deploy / build (push) Successful in 6m26s
deploy / deploy (push) Successful in 18s
deploy / deploy-web (push) Failing after 13s
fix(web): pnpm 11 renamed the build allowlist to allowBuilds
The runner is pnpm 11.24.0; this workstation is 10.30.3. The key was
renamed across that major: pnpm 10 reads `onlyBuiltDependencies` (a
list), pnpm 11 reads `allowBuilds` (a package -> bool map). Each ignores
the other's key, which is why the runner kept reporting ignored builds
while every local check passed.

The diagnostic step found it in one run, after three failed guesses.
`pnpm config get onlyBuiltDependencies` on the runner returned the list
correctly, which was the misleading part -- the value resolved fine, it
simply is not the key pnpm 11 consults. `pnpm approve-builds @swc/core
esbuild` then wrote the pnpm 11 form and named it outright.

Declares both keys so neither version is left out, rather than pinning
the fleet to one. Verified by reproducing the failure locally under
pnpm 11 (npx pnpm@11.24.0), then confirming install, lint and build pass
under both 11.24.0 and 10.30.3.

Drops the package.json `pnpm` field entirely: pnpm 11 warns it is no
longer read for anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 13:32:23 +03:00

366 lines
15 KiB
YAML

name: deploy
on:
push: { branches: [main] }
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: false
env:
# --- infra truth: hosts, ports and paths live here, not in a manifest ---
SERVICE_HOST: bob.hanzalova.internal
# Bind the mesh address rather than a wildcard: the reverse proxy is on a
# different host, so loopback will not do, and a wildcard bind on a host that
# may later gain another interface would publish the registry there too.
LISTEN_ADDR: 10.6.0.193:20482
APP_PORT: "20482"
S3_ENDPOINT: http://caveman.kosherinata.internal:9000
S3_BUCKET: rustingface
UPSTREAM_ENABLED: "true"
AUTH_MODE: bearer
AUTH_ANONYMOUS: catalog
ALLOW_NEW_REPOS: "false"
PROXY_HOST: hanzalova.internal
WEBROOT: /var/www/rustingface
jobs:
build:
runs-on: rust
steps:
- uses: actions/checkout@v4
# The gate runs before anything is built for deployment, so a broken
# commit on main never reaches a host.
- name: format
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: test
run: cargo test --workspace --all-features
# Static musl: the runner is Fedora 44 and the target is Fedora 43, so a
# dynamically linked binary could reference a newer glibc than bob has.
- name: build
run: |
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl --bin rustingface
- uses: actions/upload-artifact@v3
with:
name: rustingface
path: target/x86_64-unknown-linux-musl/release/rustingface
build-web:
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
# The gate for the frontend, matching the Rust one: a type error or a
# lint failure must not reach a host either.
# The runner's pnpm is not the workstation's -- 11.x here against 10.x
# there at the time of writing -- and the build-script allowlist was
# renamed between those majors. Printing the version and the resolved
# settings is what turned three failed guesses into a one-line answer,
# so it stays.
- name: pnpm environment
working-directory: web
run: |
pnpm --version
pnpm config get --json onlyBuiltDependencies || true
pnpm config list || true
- name: install
working-directory: web
run: pnpm install --frozen-lockfile
- name: lint
working-directory: web
run: pnpm lint
- name: build
working-directory: web
# `pnpm build` runs `tsc -b` first, so type errors fail here.
run: pnpm build
- uses: actions/upload-artifact@v3
with:
name: rustingface-web
path: web/dist
deploy:
needs: build
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with:
name: rustingface
path: dist
# Without this, a missing secret first surfaces as ssh failing to parse an
# empty key, which names neither the secret nor the fix.
- name: check the required secrets are set
env:
RSYNC_SSH_KEY: ${{ secrets.RSYNC_SSH_KEY }}
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
CLIENT_TOKENS: ${{ secrets.CLIENT_TOKENS }}
run: |
missing=""
for name in RSYNC_SSH_KEY S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY CLIENT_TOKENS; do
[ -n "${!name:-}" ] || missing="$missing $name"
done
if [ -n "$missing" ]; then
echo "missing repo secret(s):$missing" >&2
echo "Set them in Settings > Actions > Secrets. HF_TOKEN is optional and" >&2
echo "only needed for gated repositories." >&2
exit 1
fi
echo "all required secrets are present"
- name: authenticate to the target
id: auth
run: |
install -d -m 0700 ~/.ssh
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
chmod 600 ~/.ssh/id_gitea_ci
cat > ~/.ssh/config <<CFG
Host $SERVICE_HOST
User gitea_ci
IdentityFile ~/.ssh/id_gitea_ci
IdentitiesOnly yes
StrictHostKeyChecking accept-new
CFG
ssh "$SERVICE_HOST" hostname -f
- name: render config
env:
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
CLIENT_TOKENS: ${{ secrets.CLIENT_TOKENS }}
run: |
# Literal substitution, not a shell expansion: a secret containing
# $, ` or \ must survive intact.
python3 - <<'PY'
import os, pathlib
template = pathlib.Path("asset/config/config.toml.tmpl").read_text()
for key in ("LISTEN_ADDR", "S3_ENDPOINT", "S3_BUCKET", "UPSTREAM_ENABLED",
"AUTH_MODE", "AUTH_ANONYMOUS", "ALLOW_NEW_REPOS"):
template = template.replace("{{%s}}" % key, os.environ[key])
pathlib.Path("dist/config.toml").write_text(template)
for name, var in (("s3-access-key", "S3_ACCESS_KEY_ID"),
("s3-secret-key", "S3_SECRET_ACCESS_KEY"),
("hf-token", "HF_TOKEN"),
("client-tokens", "CLIENT_TOKENS")):
pathlib.Path("dist", name).write_text(os.environ.get(var, ""))
PY
# Fail loudly rather than shipping a config with an unrendered
# placeholder that would only surface as a runtime parse error.
# Matches the placeholder *shape* rather than a bare "{{", so the
# template's own prose describing the syntax is not a false positive.
if grep -nE '\{\{[A-Z_][A-Z0-9_]*\}\}' dist/config.toml; then
echo "unrendered placeholder in the config" >&2
exit 1
fi
if [ ! -s dist/s3-access-key ] || [ ! -s dist/s3-secret-key ]; then
echo "S3 credentials are empty; set the S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY secrets" >&2
exit 1
fi
# Bearer auth with no tokens refuses to start, so catch it here
# rather than as a service that restart-loops after the files land.
if [ "$AUTH_MODE" = bearer ] && [ ! -s dist/client-tokens ]; then
echo "AUTH_MODE is bearer but the CLIENT_TOKENS secret is empty." >&2
echo "Set it to one accepted token per line; '#' comments are allowed." >&2
exit 1
fi
- name: create the service account and its config directory
run: |
rsync -az --rsync-path='sudo rsync' --mkpath \
asset/systemd/rustingface.sysusers.conf \
"$SERVICE_HOST:/etc/sysusers.d/rustingface.conf"
ssh "$SERVICE_HOST" '
set -euo pipefail
sudo systemd-sysusers
sudo install -d -o root -g rustingface -m 0750 /etc/rustingface'
- name: ship the binary, config and credentials
run: |
set -euo pipefail
rsync -az --rsync-path='sudo rsync' \
dist/rustingface "$SERVICE_HOST:/usr/local/bin/rustingface"
for f in config.toml s3-access-key s3-secret-key hf-token client-tokens; do
rsync -az --rsync-path='sudo rsync' --mkpath \
"dist/$f" "$SERVICE_HOST:/etc/rustingface/$f"
done
rsync -az --rsync-path='sudo rsync' --mkpath \
asset/systemd/rustingface.service \
"$SERVICE_HOST:/etc/systemd/system/rustingface.service"
ssh "$SERVICE_HOST" '
set -euo pipefail
sudo chmod 0755 /usr/local/bin/rustingface
sudo chown -R root:rustingface /etc/rustingface
sudo chmod 0640 /etc/rustingface/config.toml /etc/rustingface/s3-access-key /etc/rustingface/s3-secret-key /etc/rustingface/hf-token /etc/rustingface/client-tokens
sudo restorecon -R /usr/local/bin/rustingface /etc/rustingface'
- name: firewalld
run: |
set -euo pipefail
# rsync the XML first: firewalld only learns a freshly-shipped custom
# service after --reload, and querying it before that fails.
rsync -az --rsync-path='sudo rsync' --mkpath \
asset/firewalld/rustingface.xml \
"$SERVICE_HOST:/etc/firewalld/services/rustingface.xml"
ssh "$SERVICE_HOST" '
set -euo pipefail
sudo firewall-cmd --reload
zone=$(sudo firewall-cmd --get-default-zone)
if sudo firewall-cmd --zone="$zone" --query-service=rustingface; then
echo "rustingface already enabled in $zone"
else
sudo firewall-cmd --permanent --zone="$zone" --add-service=rustingface
sudo firewall-cmd --zone="$zone" --add-service=rustingface
fi'
- name: restart
run: |
ssh "$SERVICE_HOST" '
set -euo pipefail
sudo systemctl daemon-reload
sudo systemctl enable rustingface.service
sudo systemctl restart rustingface.service'
- name: health check
run: |
set -euo pipefail
ssh "$SERVICE_HOST" 'sudo systemctl is-active rustingface.service'
for attempt in $(seq 1 20); do
if ssh "$SERVICE_HOST" "curl -fsS http://$LISTEN_ADDR/healthz"; then
echo "healthy after $attempt attempt(s)"
exit 0
fi
sleep 2
done
echo "rustingface did not answer /healthz" >&2
exit 1
- name: doctor
run: |
# Proves the deployment can actually reach and write to the bucket,
# which a liveness probe deliberately does not.
ssh "$SERVICE_HOST" \
'sudo -u rustingface /usr/local/bin/rustingface --config /etc/rustingface/config.toml doctor'
# Capture the unit's startup journal even when a later step failed --
# that is the record worth having. Conditioned on the target actually
# being reachable: without it, a job that failed before authenticating
# ends with "Host key verification failed", which buries the real cause
# under an error about something else entirely.
- name: journal
if: always() && steps.auth.outcome == 'success'
run: ssh "$SERVICE_HOST" 'journalctl -u rustingface.service -n 200 --no-pager'
deploy-web:
# The frontend is only useful once the API it reads is live, and the vhost
# it is served from proxies to that API.
needs: [build-web, deploy]
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with:
name: rustingface-web
path: dist
- name: check the required secrets are set
env:
RSYNC_SSH_KEY: ${{ secrets.RSYNC_SSH_KEY }}
run: |
[ -n "${RSYNC_SSH_KEY:-}" ] || { echo "missing repo secret: RSYNC_SSH_KEY" >&2; exit 1; }
echo "all required secrets are present"
- name: authenticate to the proxy
id: auth
run: |
install -d -m 0700 ~/.ssh
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
chmod 600 ~/.ssh/id_gitea_ci
cat > ~/.ssh/config <<CFG
Host $PROXY_HOST
User gitea_ci
IdentityFile ~/.ssh/id_gitea_ci
IdentitiesOnly yes
StrictHostKeyChecking accept-new
CFG
ssh "$PROXY_HOST" hostname -f
- name: ship the built frontend
run: |
set -euo pipefail
[ -f dist/index.html ] || { echo "the build produced no index.html" >&2; exit 1; }
# --delete so a rename leaves no orphaned asset behind; the webroot
# holds nothing but this build's output.
rsync -az --delete --rsync-path='sudo rsync' \
dist/ "$PROXY_HOST:$WEBROOT/"
# Webroots must be httpd_sys_content_t or nginx answers 403.
ssh "$PROXY_HOST" "sudo restorecon -R $WEBROOT"
- name: reload nginx
run: |
set -euo pipefail
# `nginx -t` cannot catch a bind failure, but it does catch a config
# that would break every other vhost on this shared proxy.
ssh "$PROXY_HOST" 'sudo nginx -t'
ssh "$PROXY_HOST" 'sudo systemctl reload nginx'
- name: health check
run: |
set -euo pipefail
# Probed from the proxy, not from this runner. The runner is a plain
# Fedora container and does not carry the internal root CA, so a TLS
# probe from here would fail on trust rather than on anything real
# (verified: a fedora:43 container gets 000, the proxy gets 200).
# The proxy resolves rf.internal to itself, and a mesh name does not
# hit the public-name hairpin.
ssh "$PROXY_HOST" 'set -euo pipefail
# The app is served, and the API it reads is reachable through the
# same vhost. Both, because a working page over a dead API looks
# fine until somebody clicks Models.
curl -fsS https://rf.internal/ | grep -q "id=.root."
curl -fsS https://rf.internal/v1/status | grep -q bucket
curl -fsS https://rf.internal/healthz | grep -q ok
# A client route must fall back to the app rather than 404.
curl -fsS https://rf.internal/models | grep -q "id=.root."
# An API path must still reach the service, not the app. With
# bearer auth on this is a 401, which is itself the proof that the
# request reached the service rather than the static root.
code=$(curl -s -o /dev/null -w "%{http_code}" https://rf.internal/api/whoami-v2)
case "$code" in 200|401) ;; *) echo "api path returned $code" >&2; exit 1;; esac
# The public vhost, if its certificate exists yet. Checked over
# loopback with the SNI name, because the public name may not
# resolve from inside the mesh until the DNS record is published.
if sudo test -d /etc/letsencrypt/live/rustingface.com; then
curl -fsS --resolve rustingface.com:443:127.0.0.1 \
https://rustingface.com/ | grep -q "id=.root."
# The catalogue is the public face and must answer anonymously.
curl -fsS --resolve rustingface.com:443:127.0.0.1 \
https://rustingface.com/v1/status | grep -q bucket
# /metrics must not be public.
m=$(curl -s -o /dev/null -w "%{http_code}" --resolve rustingface.com:443:127.0.0.1 \
https://rustingface.com/metrics)
[ "$m" = 404 ] || { echo "/metrics is exposed publicly ($m)" >&2; exit 1; }
echo "public vhost answering, /metrics closed"
else
echo "rustingface.com has no certificate yet; skipping the public checks"
fi'
echo "frontend and API both answering through rf.internal"
- name: nginx log
if: always() && steps.auth.outcome == 'success'
run: ssh "$PROXY_HOST" 'sudo tail -n 50 /var/log/nginx/rf.internal.error.log'