2026-06-30 18:10:10 -04:00
#!/bin/bash
set -e
REPO = "/opt/lotus-cinny"
WEBROOT = "/var/www/html"
LOCKFILE = "/tmp/lotus-deploy.lock"
LOGFILE = "/var/log/lotus-deploy.log"
2026-09-14 19:25:28 -04:00
# 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"
2026-06-30 18:10:10 -04:00
exec 200>" $LOCKFILE "
2026-09-14 19:25:28 -04:00
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 "
2026-06-30 18:10:10 -04:00
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
2026-07-01 22:19:39 -04:00
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
2026-06-30 18:10:10 -04:00
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
2026-07-19 22:09:30 -04:00
# 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
2026-06-30 18:10:10 -04:00
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 ;;
2026-07-19 22:09:30 -04:00
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
;;
2026-06-30 18:10:10 -04:00
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
2026-07-19 22:09:30 -04:00
# 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 "
2026-06-30 18:10:10 -04:00
# 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.
2026-10-18 01:26:02 -04:00
# [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.
2026-10-20 00:19:47 -04:00
# 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
2026-06-30 18:10:10 -04:00
2026-10-20 02:28:52 -04:00
# 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
2026-06-30 18:10:10 -04:00
echo "[ $( date '+%Y-%m-%d %H:%M:%S' ) ] ===== Deploy complete ( $VITE_APP_VERSION ) ====="
2026-09-14 19:25:28 -04:00
# 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
2026-06-30 18:10:10 -04:00
# 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