Files
matrix/cinny/lotus_deploy.sh
T
Lotus CIandClaude Opus 5.5 8aea38fc67
Lint / No secrets in webhook configs (pull_request) Successful in 24s
Lint / Landing page is rendered (matrix (pull_request) Successful in 18s
Lint / Python (ruff) (pull_request) Successful in 10s
Lint / Python deps (pip-audit) (pull_request) Successful in 1m0s
Lint / Secret scan (gitleaks) (pull_request) Successful in 8s
Lint / Shell (shellcheck) (push) Successful in 15s
Lint / JS (eslint) (push) Successful in 16s
Lint / No secrets in webhook configs (push) Successful in 10s
Lint / Landing page is rendered (matrix (push) Successful in 9s
Lint / Python (ruff) (push) Successful in 10s
Lint / Python deps (pip-audit) (push) Successful in 1m17s
Lint / Shell (shellcheck) (pull_request) Successful in 13s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Secret scan (gitleaks) (push) Successful in 5s
feat(cinny-deploy): fail and alert when a deployed page references missing files
After copying, check every script/stylesheet the app and call pages
reference (src/href in both index.html files) exists in the webroot. If
any is missing, log it, post an alert with the upstream checker's Matrix
credentials (/etc/cinny-monitor.env, best effort) and exit 1. This would
have caught the 2026-10-20 call-page outage at deploy time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 02:28:52 -04:00

241 lines
11 KiB
Bash
Executable File

#!/bin/bash
set -e
REPO="/opt/lotus-cinny"
WEBROOT="/var/www/html"
LOCKFILE="/tmp/lotus-deploy.lock"
LOGFILE="/var/log/lotus-deploy.log"
# Prevent concurrent deploys. A trigger that arrives while a deploy holds the
# lock is NOT dropped any more: it leaves a marker, and the running deploy
# re-runs itself once when it finishes. (Previously it was skipped outright —
# the poll loop retargets origin/lotus only while it is still polling, so a
# push that landed during `npm ci && npm run build` was never deployed until
# the next unrelated push. Bit us on cinny 81a6d9c9.)
PENDING="$LOCKFILE.pending"
exec 200>"$LOCKFILE"
if ! flock -n 200; then
touch "$PENDING"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy already in progress — queued a follow-up run." >> "$LOGFILE"
exit 0
fi
rm -f "$PENDING"
exec >> "$LOGFILE" 2>&1
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ===== Deploy triggered ====="
# Load secrets (auth tokens etc — not in git)
if [ -f /etc/lotus-deploy.env ]; then
set -a
# This env file only exists on the deploy host at runtime, so shellcheck
# can't follow it. The directive must sit DIRECTLY above the `source` —
# on a compound `set -a; source …` line it binds to `set -a` and the
# SC1091 finding still fails CI.
# shellcheck source=/dev/null
source /etc/lotus-deploy.env
set +a
fi
cd "$REPO"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Fetching origin/lotus..."
git fetch --all
COMMIT_SHA=$(git rev-parse origin/lotus)
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Commit: $COMMIT_SHA"
# ── CI gate ─────────────────────────────────────────────────────────────────
# Wait for the web build+test CI to pass before deploying. We gate ONLY on the
# "Build & Quality Checks" commit-status context (npm build + unit tests) — NOT
# the whole workflow run. This decouples the web deploy from the unrelated
# "Trigger Desktop Build" job and the slow downstream Tauri desktop builds that
# share the act_runner: web CI can sit queued behind a 30-min desktop build, so
# we keep waiting while the context is pending/absent, and only abort on an
# explicit failure or the (generous) cap. The previous version gated on the
# overall workflow run with a 15-min cap, so a web CI queued behind a desktop
# build timed out -> "result: timeout" -> deploy aborted -> the site stayed
# frozen on an old build for days.
if [ -n "${GITEA_API_TOKEN:-}" ]; then
GITEA_API="https://code.lotusguild.org/api/v1"
REPO_PATH="LotusGuild/cinny"
GATE_CONTEXT="Build & Quality Checks"
MAX_WAIT=2700 # 45 min — web CI can queue behind long Tauri desktop builds
POLL_INTERVAL=15
elapsed=0
ci_result=""
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Waiting for CI '$GATE_CONTEXT' on $COMMIT_SHA..."
while [ "$elapsed" -lt "$MAX_WAIT" ]; do
# Follow origin/lotus. A newer push supersedes ours — with CI
# cancel-in-progress its run is CANCELLED (reported error/failure), and a
# newer deploy is skipped by our flock — so polling the latched (now
# cancelled) SHA would abort and strand the newest commit undeployed.
# Re-resolve HEAD each iteration and retarget the gate if it advanced,
# giving the fresh commit a full MAX_WAIT window.
git fetch --quiet origin lotus 2>/dev/null || true
newest=$(git rev-parse origin/lotus 2>/dev/null || echo "$COMMIT_SHA")
if [ "$newest" != "$COMMIT_SHA" ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] origin/lotus advanced ${COMMIT_SHA:0:8} -> ${newest:0:8}; retargeting CI gate."
COMMIT_SHA="$newest"
elapsed=0
fi
state=$(curl -s -H "Authorization: token $GITEA_API_TOKEN" \
"$GITEA_API/repos/$REPO_PATH/commits/$COMMIT_SHA/status" \
| GATE="$GATE_CONTEXT" python3 -c "
import json, os, sys
try:
d = json.load(sys.stdin)
except Exception:
print('pending'); sys.exit(0)
gate = os.environ.get('GATE', '')
for s in d.get('statuses', []):
if gate in (s.get('context') or ''):
print(s.get('status') or 'pending'); break
else:
print('pending')
" 2>/dev/null || echo pending)
case "$state" in
success) ci_result=success; break ;;
failure|error)
# Before aborting: a superseded run reports its status as
# error/failure (cancel-in-progress). If a fetch blip on the
# retarget iteration left us on a stale SHA, re-check HEAD — if it
# advanced, follow it and keep waiting rather than aborting on a
# commit that a newer push already replaced. Only a genuine
# failure of the CURRENT HEAD aborts the deploy.
git fetch --quiet origin lotus 2>/dev/null || true
head_now=$(git rev-parse origin/lotus 2>/dev/null || echo "$COMMIT_SHA")
if [ "$head_now" != "$COMMIT_SHA" ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] gated ${COMMIT_SHA:0:8} reported ${state}, but origin/lotus advanced to ${head_now:0:8}; retargeting."
COMMIT_SHA="$head_now"
elapsed=0
else
ci_result="$state"; break
fi
;;
esac
echo "[$(date '+%Y-%m-%d %H:%M:%S')] CI not yet passed (${elapsed}s elapsed, '$GATE_CONTEXT': ${state}), waiting..."
sleep "$POLL_INTERVAL"
elapsed=$((elapsed + POLL_INTERVAL))
done
if [ "$ci_result" != "success" ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] CI did not pass (result: ${ci_result:-timeout}). Aborting deploy."
exit 1
fi
echo "[$(date '+%Y-%m-%d %H:%M:%S')] CI '$GATE_CONTEXT' passed. Proceeding with deploy."
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARNING: GITEA_API_TOKEN not set, deploying without CI gate."
fi
# Reset to the exact commit we gated on (which the poll loop keeps in step with
# origin/lotus), not a bare origin/lotus that may have advanced past the gate
# after the loop exited — so we deploy precisely what passed CI.
git reset --hard "$COMMIT_SHA"
# Tag this build with the exact commit so Sentry can link errors to source
export VITE_APP_VERSION=$COMMIT_SHA
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Building commit $VITE_APP_VERSION..."
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Installing dependencies..."
npm ci --ignore-scripts
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Building..."
NODE_OPTIONS=--max_old_space_size=4096 npm run build
# The Element Call widget (the @lotusguild/element-call-embedded fork) is emitted
# into dist/public/element-call by the build itself — no manual copy is needed.
# (The old `cp node_modules/@element-hq/element-call-embedded/dist/.` step was a
# deploy-killer: the package was forked to @lotusguild, so under `set -e` that
# now-missing path aborted every deploy.) Verify the bundle actually landed
# before publishing rather than blindly copying.
if [ ! -f "$REPO/dist/public/element-call/index.html" ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: dist/public/element-call/ missing after build (check @lotusguild/element-call-embedded pin). Aborting."
exit 1
fi
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploying to $WEBROOT..."
# Exclude config.json: the production runtime config (homeserver list,
# allowCustomHomeservers, etc.) is owned by the matrix repo and deployed to
# /var/www/html/config.json by lxc106-cinny.sh. The build ships a DEV default
# (allowCustomHomeservers:true); rsyncing it would clobber the production config
# on every deploy. Keep the app bundle and the runtime config separate.
# [matrix #23] Keep the previous builds' hashed assets for a while: a browser
# still holding an older index.html (or service-worker precache) asks for its
# assets/index-<old hash>.js, and deleting them on every deploy broke the app
# for those users until their cached shell expired. Prune after 14 days.
# The asset excludes are ANCHORED (leading /): an unanchored `assets/` also
# matched public/element-call/assets/, so the call page's index.html was
# updated while its hashed scripts never were, and calls failed to load.
# config.json stays unanchored on purpose: public/element-call/config.json is
# the production call config too.
rsync -a --delete --exclude config.json \
--exclude /assets/ --exclude /public/element-call/assets/ dist/ "$WEBROOT/"
for dir in assets public/element-call/assets; do
mkdir -p "$WEBROOT/$dir"
rsync -a "dist/$dir/" "$WEBROOT/$dir/"
find "$WEBROOT/$dir" -type f -mtime +14 -delete
done
# Every script/stylesheet the app and call pages reference must exist in the
# webroot. 2026-10-20: the call page's index.html pointed at scripts the deploy
# never copied and calls failed to load; this check would have caught it.
missing_assets() { # <index.html> <its directory>
grep -oE '(src|href)="[^"]+\.(js|css)"' "$1" | sed -E 's/^(src|href)="//; s/"$//' |
while read -r ref; do
case "$ref" in
http://*|https://*|//*) continue ;;
/*) path="$WEBROOT$ref" ;;
*) path="$2/${ref#./}" ;;
esac
[ -f "$path" ] || echo "$ref"
done
}
deploy_alert() { # best effort: the upstream checker's Matrix credentials
[ -r /etc/cinny-monitor.env ] || return 0
(
set -a
# shellcheck source=/dev/null
source /etc/cinny-monitor.env
set +a
_ALERT="$1" python3 - <<'PYEOF'
import json, os, time, urllib.parse, urllib.request
server = os.environ.get("MATRIX_SERVER", "").rstrip("/")
room, token = os.environ.get("MATRIX_ROOM", ""), os.environ.get("MATRIX_TOKEN", "")
if server and room and token:
url = f"{server}/_matrix/client/v3/rooms/{urllib.parse.quote(room, safe='')}/send/m.room.message/deploy{int(time.time() * 1000)}"
req = urllib.request.Request(url, method="PUT", data=json.dumps({"msgtype": "m.text", "body": os.environ["_ALERT"]}).encode(),
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"})
try:
urllib.request.urlopen(req, timeout=10)
except Exception as exc:
print(f"alert failed: {exc}")
PYEOF
) || true
}
MISSING=$(missing_assets "$WEBROOT/index.html" "$WEBROOT"; missing_assets "$WEBROOT/public/element-call/index.html" "$WEBROOT/public/element-call")
if [ -n "$MISSING" ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: deployed pages reference missing files:"
echo "$MISSING"
deploy_alert "⚠️ Lotus Chat deploy ($VITE_APP_VERSION): the live pages reference files that are not on the server, so the app or calls may not load: $(echo "$MISSING" | tr '\n' ' ')"
exit 1
fi
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ===== Deploy complete ($VITE_APP_VERSION) ====="
# A trigger arrived while we were deploying: release the lock and run once more
# so the newest origin/lotus gets deployed (it re-fetches and re-gates on CI).
if [ -f "$PENDING" ]; then
rm -f "$PENDING"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Queued trigger found — re-running deploy for the newest commit."
flock -u 200
exec "$0" "$@"
fi
# Inject runtime secrets that are never stored in git. If the production
# config.json carries the "gifApiKey": "" placeholder, fill it from the env.
if [ -n "${GIPHY_API_KEY:-}" ]; then
sed -i "s|\"gifApiKey\": \"\"|\"gifApiKey\": \"$GIPHY_API_KEY\"|" "$WEBROOT/config.json"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Injected GIPHY_API_KEY into config.json"
fi