#!/usr/bin/env bash # release.sh — build, pin, deploy and verify the LibreChat overlay image. # # Why this script exists: two traps on this app have each silently eaten a # deploy of this overlay, and both look like success. # 1. `cloudron update --image ` is IGNORED when CloudronManifest.json is # present in the cwd — the CLI deploys manifest.dockerImage instead, while # still printing the --image value. So the built tag MUST be written into # the manifest first (that is what `pin` does). # 2. The builder cache can serve stale COPY layers even when file content # changed. Builds here always pass --no-cache. # # Usage (from the repo root): # ./release.sh build build + push the image, tag - # ./release.sh pin write into CloudronManifest.json, commit, push # ./release.sh deploy cloudron update --force, then verify # ./release.sh release build -> pin -> deploy -> verify (the whole thing) # ./release.sh verify live checks only (safe, no changes) # ./release.sh volume FILE... push volume-resident files, assert md5 parity # # `build` needs docker + a registry login, so it runs on whoever's machine has # them — deliberately NOT on the agent VM, which holds no build capability. # `pin`, `deploy`, `verify` and `volume` only need CLOUDRON_TOKEN. # # Rollback: `./release.sh pin && ./release.sh deploy`. set -euo pipefail APP=${APP:-4023ccbf-d57a-4500-97df-14de9cf3516e} REGISTRY_REPO=${REGISTRY_REPO:-registry.inference.coop/co-op/librechat-custom} SERVER=${SERVER:-my.inference.coop} BASE_IMAGE=${BASE_IMAGE:-cloudron/ai.librechat.cloudronapp:202610150224320000} MANIFEST=CloudronManifest.json die() { printf 'release.sh: %s\n' "$*" >&2; exit 1; } ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; } bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAILED=1; } need_token() { : "${CLOUDRON_TOKEN:?export CLOUDRON_TOKEN first (set -a; . ~/.hermes/.env; set +a)}"; } cr() { cloudron --server "$SERVER" --token "$CLOUDRON_TOKEN" "$@"; } manifest_tag() { python3 -c "import json;print(json.load(open('$MANIFEST'))['dockerImage'].rsplit(':',1)[-1])"; } cmd_build() { # Builds are REMOTE: the CLI uploads the build context and the registry's # BuildKit does the work (no docker on the caller). What is required is a # registry login for this user on this host — see the check below. local tag="${TAG:-$(date -u +%Y%m%d)-$(git rev-parse --short HEAD)}" [ -z "$(git status --porcelain)" ] || die "working tree is dirty — commit first, the tag carries the git sha" if ! cloudron builder info >/dev/null 2>&1; then die "no registry build config on this host (this is the only blocker to ./release.sh build)" fi if grep -qi "not logged in" <<<"$(cloudron builder info 2>&1 || true)"; then cat >&2 <<'MSG' release.sh: this host has no registry login, so it cannot start a remote build. Bootstrap it one of two ways: a) narrow: in the Cloudron dashboard, mint an app password for registry-bot scoped to the registry app (c257ba3c-6737-4375-ad51-ea19586bf3f0), put it on this host, then write into ~/.cloudron.json: {"builder": {"kind":"registry","type":"remote","url":"https://registry.inference.coop", "username":"registry-bot","token":""}} b) broad: `cloudron login my.inference.coop` (device flow — a human with dashboard access confirms a code), then `cloudron builder login`. MSG exit 1 fi printf 'building %s:%s (base %s)\n' "$REGISTRY_REPO" "$tag" "$BASE_IMAGE" cloudron builder build --no-cache --repository "$REGISTRY_REPO" --tag "$tag" || die "build failed" printf '\nbuilt tag: %s\nnext: ./release.sh pin %s\n' "$tag" "$tag" } cmd_pin() { local tag="${1:-}" ; [ -n "$tag" ] || die "usage: release.sh pin " local br; br=$(git rev-parse --abbrev-ref HEAD) if [ "$br" != "master" ] && [ "${ALLOW_BRANCH:-0}" != "1" ]; then die "on branch '$br' — the pin commit goes to master, so merge your change first (or set ALLOW_BRANCH=1 if you really mean it)" fi python3 - "$tag" <<'PY' import json, sys tag = sys.argv[1] m = json.load(open('CloudronManifest.json')) old = m['dockerImage'] m['dockerImage'] = old.rsplit(':', 1)[0] + ':' + tag json.dump(m, open('CloudronManifest.json', 'w'), indent=4) open('CloudronManifest.json', 'a').write('\n') print(f'dockerImage: {old} -> {m["dockerImage"]}') PY git add "$MANIFEST" git commit -q -m "Pin manifest to $tag" git push -q ok "pinned + pushed $tag" } cmd_deploy() { need_token local want; want=$(manifest_tag) printf 'deploying manifest (image tag %s)\n' "$want" cr update --app "$APP" --force cmd_verify } cmd_verify() { need_token FAILED=0 local api_tag repo_tag css_repo css_live api_tag=$(curl -s -H "Authorization: Bearer $CLOUDRON_TOKEN" "https://$SERVER/api/v1/apps/$APP" \ | python3 -c "import json,sys;print(json.load(sys.stdin)['manifest']['dockerImage'].rsplit(':',1)[-1])") repo_tag=$(manifest_tag) [ "$api_tag" = "$repo_tag" ] && ok "running image tag == repo pin ($api_tag)" \ || bad "running image tag $api_tag != repo pin $repo_tag (did you pin before deploying?)" css_repo=$(md5sum assets/custom.css | cut -d' ' -f1) css_live=$(cr exec --app "$APP" -- md5sum /app/code/librechat/client/dist/assets/custom.css 2>/dev/null | cut -d' ' -f1) [ "$css_repo" = "$css_live" ] && ok "container custom.css == repo (no stale build cache)" \ || bad "container custom.css $css_live != repo $css_repo (stale COPY layer — rebuild --no-cache)" local html; html=$(curl -s "https://chat.inference.coop/login") grep -qE 'assets/custom\.css' <<<"$html" && ok "served HTML links custom.css" || bad "served HTML missing custom.css link" grep -qE 'assets/custom2\.js' <<<"$html" && ok "served HTML links custom2.js" || bad "served HTML missing custom2.js link" curl -s "https://chat.inference.coop/" | grep -qE 'x-coop-build' && ok "x-coop-build marker present" \ || bad "no x-coop-build marker (stale service-worker clients will not self-heal)" # The scheduler task must point at a file that exists in the RUNNING image. # Catches a manifest repoint deployed before its image landed (2026-10-25). local sched_cmd sched_cmd=$(python3 -c " import json app=json.load(open('$MANIFEST')) t=app.get('addons',{}).get('scheduler',{}).get('librechat_key_sync') print(t['command'] if t else '')") if [ -n "$sched_cmd" ]; then cmd_path=$(grep -oE 'node [^ ]+' <<<"$sched_cmd" | awk '{print $2}') if cr exec --app "$APP" -- test -f "$cmd_path" 2>/dev/null; then ok "scheduler command path exists in the image ($cmd_path)" else bad "scheduler points at $cmd_path which is NOT in the running image — the seed will fail until the image lands" fi fi printf '\nkey-sync task log lines from the app log:\n' cr logs --app "$APP" --lines 4000 2>/dev/null | grep -E "sync-keys.js revision|creds check|portal feed|done:|FATAL" | tail -8 | sed 's/^/ /' printf ' compare the revision hash above with: md5sum sync-keys.js -> %s\n' "$(md5sum sync-keys.js | cut -c1-8)" [ "$FAILED" = 0 ] && printf '\nall checks passed\n' || { printf '\nCHECKS FAILED\n' >&2; exit 1; } } cmd_volume() { need_token [ "$#" -ge 1 ] || die "usage: release.sh volume FILE [FILE...]" FAILED=0 for f in "$@"; do [ -f "$f" ] || die "no such file: $f" local base; base=$(basename "$f") base64 -w0 "$f" | cr exec --app "$APP" -- sh -c "cat > /tmp/vpush && base64 -d /tmp/vpush > /app/data/$base && rm -f /tmp/vpush" local local_md5 live_md5 local_md5=$(md5sum "$f" | cut -d' ' -f1) live_md5=$(cr exec --app "$APP" -- md5sum "/app/data/$base" | cut -d' ' -f1) [ "$local_md5" = "$live_md5" ] && ok "volume $base == repo ($local_md5)" || bad "volume $base $live_md5 != repo $local_md5" done [ "$FAILED" = 0 ] || exit 1 } case "${1:-}" in build) shift; cmd_build "$@" ;; pin) shift; cmd_pin "$@" ;; deploy) shift; cmd_deploy "$@" ;; verify) shift; cmd_verify "$@" ;; volume) shift; cmd_volume "$@" ;; release) shift; cmd_build "$@"; t=$(manifest_tag); cmd_deploy ;; *) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' ; exit 1 ;; esac