diff --git a/.gitea/workflows/deploy-web.yml b/.gitea/workflows/deploy-web.yml new file mode 100644 index 0000000..4bbf992 --- /dev/null +++ b/.gitea/workflows/deploy-web.yml @@ -0,0 +1,155 @@ +name: Deploy web Studio + +# Builds the web Studio (tools/build_web.py -> dist/web) and publishes it to +# https://isokit.buildfor.life/ on the buildfor.life web server (s1): the +# same Caddy, deploy user and two-directory swap as the blog and cnxvinyl.lol. +# One-time server setup (Caddy vhost, web root): deploy/Caddyfile. +# +# Secrets (repo or org level), the same ones the blog and cnx_vinyl use: +# VPS_HOST, VPS_USER, VPS_SSH_KEY required +# VPS_PORT optional, default 22 +# VPS_KNOWN_HOSTS optional pinned host key line(s); +# without it the key is scanned per run + +on: + push: + branches: [master] + paths: + - "crates/**" + - "studio/**" + - "viewer/**" + - "web/**" + - "examples/demo/**" + - "tools/build_web.py" + - "Cargo.toml" + - "Cargo.lock" + - ".gitea/workflows/deploy-web.yml" + workflow_dispatch: + +# A newer push supersedes a running deploy. Safe to cancel mid-run: rsync +# writes the non-live directory and the rename swap happens last. +concurrency: + group: deploy-isokit-web + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + env: + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_USER: ${{ secrets.VPS_USER }} + VPS_PORT: ${{ secrets.VPS_PORT }} + SITE_DIR: /var/www/isokit.buildfor.life + SITE_URL: https://isokit.buildfor.life + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install curl, python3, rsync if missing + run: | + missing="" + for c in curl python3 rsync; do + command -v "$c" > /dev/null || missing="$missing $c" + done + if [ -n "$missing" ]; then + sudo="" + [ "$(id -u)" = 0 ] || sudo=sudo + # shellcheck disable=SC2086 + $sudo apt-get update && $sudo apt-get install -y $missing + fi + + - name: Install Rust (rustup, wasm32 target) and the wasm-bindgen CLI + # build_web.py wants rustup's cargo and the wasm-bindgen CLI at the + # version in Cargo.lock; the CLI comes prebuilt from its GitHub release. + run: | + cargo_home=${CARGO_HOME:-$HOME/.cargo} + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | + sh -s -- -y --profile minimal --default-toolchain stable --target wasm32-unknown-unknown --no-modify-path + "$cargo_home/bin/rustup" target add wasm32-unknown-unknown + echo "$cargo_home/bin" >> "$GITHUB_PATH" + ver=$(grep -A1 '^name = "wasm-bindgen"$' Cargo.lock | sed -n 's/^version = "\(.*\)"$/\1/p') + [ -n "$ver" ] || { echo "::error::no wasm-bindgen version in Cargo.lock"; exit 1; } + tmp=$(mktemp -d) + curl -sSfL "https://github.com/wasm-bindgen/wasm-bindgen/releases/download/$ver/wasm-bindgen-$ver-x86_64-unknown-linux-musl.tar.gz" | + tar xz -C "$tmp" + install -m 755 "$tmp/wasm-bindgen-$ver-x86_64-unknown-linux-musl/wasm-bindgen" "$cargo_home/bin/" + "$cargo_home/bin/rustc" --version + "$cargo_home/bin/wasm-bindgen" --version + + - name: Build dist/web + run: | + python3 tools/build_web.py + echo "$GITHUB_SHA" > dist/web/version.txt + + - name: Set up SSH + env: + VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }} + run: | + if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ] || [ -z "$VPS_SSH_KEY" ]; then + echo "::error::VPS_HOST, VPS_USER and VPS_SSH_KEY must be set as Actions secrets (the blog's / cnx_vinyl's values)" + exit 1 + fi + port=${VPS_PORT:-22} + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$VPS_SSH_KEY" | tr -d '\r' > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + # Fail here, not at the server, when the secret is not a usable key, + # and log its (public) fingerprint for comparison with + # `ssh-keygen -lf ~deploy/.ssh/authorized_keys` on the server. + if ! ssh-keygen -y -P "" -f ~/.ssh/deploy_key > ~/.ssh/deploy_key.pub 2> /dev/null; then + echo "::error::VPS_SSH_KEY does not load as an unencrypted private key; paste the whole OpenSSH-format key including the BEGIN/END lines" + exit 1 + fi + echo "key in VPS_SSH_KEY: $(ssh-keygen -lf ~/.ssh/deploy_key.pub)" + if [ -n "$VPS_KNOWN_HOSTS" ]; then + printf '%s\n' "$VPS_KNOWN_HOSTS" >> ~/.ssh/known_hosts + else + for i in 1 2 3; do + ssh-keyscan -T 10 -p "$port" "$VPS_HOST" > ~/.ssh/keyscan.out && [ -s ~/.ssh/keyscan.out ] && break + echo "ssh-keyscan attempt $i failed; retrying in 15 s" >&2 + sleep 15 + done + if [ ! -s ~/.ssh/keyscan.out ]; then + echo "::error::ssh-keyscan could not reach $VPS_HOST:$port (server down or runner IP blocked?)" + exit 1 + fi + cat ~/.ssh/keyscan.out >> ~/.ssh/known_hosts + fi + { + echo "Host webhost" + echo " HostName $VPS_HOST" + echo " User $VPS_USER" + echo " Port $port" + echo " IdentityFile ~/.ssh/deploy_key" + echo " IdentitiesOnly yes" + echo " BatchMode yes" + } > ~/.ssh/config + + - name: Deploy (rsync to the idle copy, then rename swap) + # Two persistent directories alternate as live and previous: rsync + # only ever writes the one that is not live, and the swap is three + # same-filesystem renames, so Caddy never serves a half-written tree. + # The previous build stays on disk for rollback. + run: | + # SC2029: the paths are meant to expand on the runner side + # shellcheck disable=SC2029 + ssh webhost "mkdir -p '$SITE_DIR' '$SITE_DIR-prev' && rm -rf '$SITE_DIR-tmp'" + rsync -az --delete dist/web/ "webhost:$SITE_DIR-prev/" + # shellcheck disable=SC2029 + ssh webhost "mv '$SITE_DIR' '$SITE_DIR-tmp' && mv '$SITE_DIR-prev' '$SITE_DIR' && mv '$SITE_DIR-tmp' '$SITE_DIR-prev'" + # shellcheck disable=SC2029 + live=$(ssh webhost "cat '$SITE_DIR/version.txt'") + echo "live on server: $live" + [ "$live" = "$GITHUB_SHA" ] || { echo "::error::server holds $live, expected $GITHUB_SHA"; exit 1; } + + - name: Check the public URL + # Warning only: DNS, Cloudflare or a login can make this fail while + # the files on the server are already correct. + run: | + got=$(curl -sS --max-time 20 "$SITE_URL/version.txt?nocache=$GITHUB_SHA" || true) + if [ "$got" = "$GITHUB_SHA" ]; then + echo "$SITE_URL serves $GITHUB_SHA" + else + echo "::warning::$SITE_URL/version.txt returned '${got:0:80}', expected $GITHUB_SHA" + fi diff --git a/README.md b/README.md index 167019f..a49fd8a 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,9 @@ python tools/build_web.py # -> dist/web/ (static files, ``` `dist/web/` runs from any static web server (http or https, not `file://`). +A live build is at https://isokit.buildfor.life/: every push to `master` that +touches the engine or the front end rebuilds and deploys it +(`.gitea/workflows/deploy-web.yml`; server setup in `deploy/Caddyfile`). The first visit opens a copy of the demo. Everything the desktop Studio does works the same, because the page runs the same engine and answers the same requests. In addition: @@ -144,6 +147,7 @@ viewer/ isokit-view.js/.css: pan, zoom, levels, rotation, tooltip studio/ the Studio GUI (served by `isokit studio`, and by the web Studio) web/ web Studio shell: WASM backend for studio.js, projects, folders, zip, code editor examples/demo demo project +deploy/Caddyfile the web Studio's vhost on the buildfor.life web server (s1) ``` The layers are independent. A Rust program can draw with `isokit-core` alone. diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..94e5cde --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,25 @@ +# isokit.buildfor.life: the web Studio on the buildfor.life web server (s1). +# Static web root /var/www/isokit.buildfor.life, filled by +# .gitea/workflows/deploy-web.yml (tools/build_web.py -> dist/web). +# +# Append this block to /etc/caddy/Caddyfile on s1, then +# runuser -u caddy -- caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile +# systemctl reload caddy +# This file mirrors what s1 runs; keep the two in step. +# +# DNS: *.buildfor.life is a proxied wildcard at Cloudflare that reaches s1, +# so the name needs no record of its own. Caddy gets its certificate itself. + +isokit.buildfor.life { + header X-Frame-Options "SAMEORIGIN" + # Testing deployment: keep it out of search engines. + header X-Robots-Tag "noindex, nofollow" + # Revalidate everything: isokit_wasm.js and its .wasm load without a + # version query, and a stale copy at Cloudflare would not match the + # new glue code after a deploy. Unchanged files still come back as 304. + header Cache-Control "no-cache" + + root * /var/www/isokit.buildfor.life + encode zstd gzip + file_server +}