Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: auto-deploy lith on push to main

- webhook.sh: smart deploy — git pull, check changed files, only
restart lith when backend/functions change. static-only pushes
deploy instantly with zero downtime.
- server.mjs: async deploy endpoint with GitHub HMAC signature
verification, branch filtering, and duplicate-deploy guard.
- GitHub webhook configured for push events on main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+144 -8
+44 -8
lith/server.mjs
··· 335 335 } 336 336 337 337 // --- Deploy webhook (POST /lith/deploy?secret=...) --- 338 - import { execSync } from "child_process"; 338 + import { execFile } from "child_process"; 339 + import { createHmac, timingSafeEqual } from "crypto"; 339 340 const DEPLOY_SECRET = process.env.DEPLOY_SECRET || ""; 341 + let deployInProgress = false; 342 + 343 + function verifyDeploy(req) { 344 + // GitHub HMAC signature (webhook secret) 345 + const sig = req.headers["x-hub-signature-256"]; 346 + if (sig && DEPLOY_SECRET) { 347 + const hmac = createHmac("sha256", DEPLOY_SECRET) 348 + .update(JSON.stringify(req.body)) 349 + .digest("hex"); 350 + const expected = `sha256=${hmac}`; 351 + if (sig.length === expected.length && 352 + timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { 353 + return true; 354 + } 355 + } 356 + // Fallback: query param or header (manual triggers) 357 + const plain = req.query.secret || req.headers["x-deploy-secret"]; 358 + return plain === DEPLOY_SECRET; 359 + } 340 360 341 361 app.post("/lith/deploy", (req, res) => { 342 - const secret = req.query.secret || req.headers["x-deploy-secret"]; 343 - if (!DEPLOY_SECRET || secret !== DEPLOY_SECRET) { 362 + if (!DEPLOY_SECRET || !verifyDeploy(req)) { 344 363 return res.status(401).send("Unauthorized"); 345 364 } 346 - try { 347 - const out = execSync("/opt/ac/lith/webhook.sh", { timeout: 30000, encoding: "utf-8" }); 348 - res.send(out); 349 - } catch (err) { 350 - res.status(500).send(err.message); 365 + 366 + // Only deploy main branch pushes (GitHub sends ref in payload) 367 + const ref = req.body?.ref; 368 + if (ref && ref !== "refs/heads/main") { 369 + return res.send(`Ignored non-main push: ${ref}`); 370 + } 371 + 372 + if (deployInProgress) { 373 + return res.status(429).send("Deploy already in progress"); 351 374 } 375 + 376 + deployInProgress = true; 377 + res.send("Deploy started"); 378 + 379 + // Run async — don't block the event loop or the HTTP response 380 + execFile("/opt/ac/lith/webhook.sh", { timeout: 120000 }, (err, stdout, stderr) => { 381 + deployInProgress = false; 382 + if (err) { 383 + console.error("[deploy] failed:", err.message, stderr); 384 + } else { 385 + console.log("[deploy]", stdout); 386 + } 387 + }); 352 388 }); 353 389 354 390 // --- Routes ---
+100
lith/webhook.sh
··· 1 + #!/usr/bin/env bash 2 + # webhook.sh — Smart auto-deploy for lith 3 + # Called by POST /lith/deploy (from GitHub push webhook) 4 + # 5 + # Strategy: 6 + # 1. git pull 7 + # 2. Check which files changed 8 + # 3. If only static frontend files changed → done (Caddy serves from disk) 9 + # 4. If backend functions or lith/ changed → npm install if needed, restart lith 10 + # 5. If Caddyfile changed → reload caddy 11 + # 12 + # This means most frontend pushes deploy instantly with zero downtime. 13 + 14 + set -euo pipefail 15 + 16 + REMOTE_DIR="/opt/ac" 17 + LOG_TAG="[lith-deploy]" 18 + 19 + log() { echo "$LOG_TAG $*"; } 20 + 21 + cd "$REMOTE_DIR" 22 + 23 + # Record HEAD before pull 24 + OLD_HEAD=$(git rev-parse HEAD) 25 + 26 + # Pull latest 27 + log "pulling..." 28 + git fetch origin main --quiet 29 + git reset --hard origin/main --quiet 30 + 31 + NEW_HEAD=$(git rev-parse HEAD) 32 + 33 + if [ "$OLD_HEAD" = "$NEW_HEAD" ]; then 34 + log "already up to date ($NEW_HEAD)" 35 + exit 0 36 + fi 37 + 38 + # Write commit ref for version endpoint 39 + echo "$NEW_HEAD" > system/public/.commit-ref 40 + 41 + # Get list of changed files 42 + CHANGED=$(git diff --name-only "$OLD_HEAD" "$NEW_HEAD") 43 + log "updated $OLD_HEAD -> $NEW_HEAD" 44 + log "changed files:" 45 + echo "$CHANGED" | sed 's/^/ /' 46 + 47 + NEED_RESTART=false 48 + NEED_CADDY_RELOAD=false 49 + NEED_NPM_INSTALL=false 50 + 51 + while IFS= read -r file; do 52 + case "$file" in 53 + lith/server.mjs|lith/package.json) 54 + NEED_RESTART=true 55 + ;; 56 + lith/Caddyfile) 57 + NEED_CADDY_RELOAD=true 58 + ;; 59 + lith/package-lock.json) 60 + NEED_NPM_INSTALL=true 61 + NEED_RESTART=true 62 + ;; 63 + system/netlify/functions/*) 64 + NEED_RESTART=true 65 + ;; 66 + system/package.json|system/package-lock.json) 67 + NEED_NPM_INSTALL=true 68 + NEED_RESTART=true 69 + ;; 70 + shared/*) 71 + NEED_RESTART=true 72 + ;; 73 + system/public/*) 74 + # Static files — Caddy serves directly from disk, no action needed 75 + ;; 76 + *) 77 + # Other files (docs, tests, etc.) — no action needed 78 + ;; 79 + esac 80 + done <<< "$CHANGED" 81 + 82 + if $NEED_NPM_INSTALL; then 83 + log "installing dependencies..." 84 + cd "$REMOTE_DIR/system" && npm install --omit=dev --quiet 2>&1 | tail -1 85 + cd "$REMOTE_DIR/lith" && npm install --omit=dev --quiet 2>&1 | tail -1 86 + fi 87 + 88 + if $NEED_CADDY_RELOAD; then 89 + log "reloading caddy..." 90 + systemctl reload caddy 91 + fi 92 + 93 + if $NEED_RESTART; then 94 + log "restarting lith (backend changes detected)..." 95 + systemctl restart lith 96 + else 97 + log "static-only deploy — no restart needed" 98 + fi 99 + 100 + log "done"