REGISTRY_BUILD_TOKEN (+ optional REGISTRY_BUILD_USERNAME) lets a build run from any host with the token in its environment, no ~/.cloudron.json builder config required. The narrow credential (app password scoped to the registry app) is the least-privileged way to hand a host build rights; with no token and no builder config the script prints both bootstrap routes and exits instead of guessing.
179 lines
8.5 KiB
Bash
Executable File
179 lines
8.5 KiB
Bash
Executable File
#!/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 <tag>` 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 <utcdate>-<gitsha>
|
|
# ./release.sh pin <tag> write <tag> 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 <previous-tag> && ./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 app's
|
|
# BuildKit does the work (no docker on the caller). What is required is a
|
|
# registry login for this user on this host — env token or builder config.
|
|
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"
|
|
|
|
local logged_in=0
|
|
if cloudron builder info 2>/dev/null | grep -viq "not logged in"; then logged_in=1; fi
|
|
|
|
local authargs=()
|
|
if [ -n "${REGISTRY_BUILD_TOKEN:-}" ]; then
|
|
# Narrow path: a credential scoped to the registry app only, kept in the
|
|
# caller's env — nothing is written to disk here.
|
|
authargs=(--registry-url https://registry.inference.coop
|
|
--registry-username "${REGISTRY_BUILD_USERNAME:-registry-bot}"
|
|
--registry-password "$REGISTRY_BUILD_TOKEN")
|
|
elif [ "$logged_in" = 1 ]; then
|
|
authargs=() # builder config in ~/.cloudron.json is enough
|
|
else
|
|
cat >&2 <<'MSG'
|
|
release.sh: this host has no registry login, so it cannot start a remote build.
|
|
Bootstrap one of two ways:
|
|
a) narrow: mint an app password in the dashboard as `registry-bot`, scoped to
|
|
the registry app id (c257ba3c-6737-4375-ad51-ea19586bf3f0), then on this host
|
|
put REGISTRY_BUILD_TOKEN=<app password> into the environment
|
|
(e.g. append it to ~/.hermes/.env). Nothing else to configure.
|
|
b) broad: `cloudron login my.inference.coop` runs a device flow — a human with
|
|
dashboard access confirms a code — then `cloudron builder login`. This
|
|
grants a full CLI session, not just build rights.
|
|
MSG
|
|
exit 1
|
|
fi
|
|
|
|
printf 'building %s:%s (base %s)\n' "$REGISTRY_REPO" "$tag" "$BASE_IMAGE"
|
|
cloudron builder "${authargs[@]}" 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 <tag>"
|
|
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
|