ci, install: update workflows, packaging scripts, and installer suite
Nightly Build / build (push) Successful in 6m43s

Nightly/release workflows: matrix tweaks, artifact path fixes.
Package-macos: streamline dmg build, codesigning improvements.
Install scripts: rewrite install.sh and install-nightly.sh with
robust error handling, add uninstall.sh, overhaul update.sh.
This commit is contained in:
2026-07-22 15:11:17 +01:00
parent 4dddc7d2ab
commit 8407a949fc
8 changed files with 396 additions and 120 deletions
+11 -3
View File
@@ -52,7 +52,15 @@ jobs:
- name: Deploy to builds.skaldagent.net - name: Deploy to builds.skaldagent.net
run: | run: |
cd "${GITHUB_WORKSPACE:-.}" cd "${GITHUB_WORKSPACE:-.}"
mkdir -p /var/www/builds.skaldagent.net/nightly DEST=/var/www/builds.skaldagent.net/nightly
cp dist/*.tar.gz /var/www/builds.skaldagent.net/nightly/ mkdir -p "$DEST"
# Nightly reuses a fixed filename, so publish atomically: copy to a
# temp name on the same filesystem, then rename over the target. A
# concurrent download never sees a half-written tarball.
for f in dist/*.tar.gz; do
name="$(basename "$f")"
cp "$f" "$DEST/.$name.tmp"
mv -f "$DEST/.$name.tmp" "$DEST/$name"
done
echo "[nightly] Deployed:" echo "[nightly] Deployed:"
ls -lh /var/www/builds.skaldagent.net/nightly/ ls -lh "$DEST/"
+14 -3
View File
@@ -85,11 +85,22 @@ jobs:
VERSION="${{ steps.extract-version.outputs.version }}" VERSION="${{ steps.extract-version.outputs.version }}"
TARGET="/var/www/builds.skaldagent.net/releases/${VERSION}" TARGET="/var/www/builds.skaldagent.net/releases/${VERSION}"
mkdir -p "$TARGET" mkdir -p "$TARGET"
cp dist/*.tar.gz "$TARGET/" # Publish each tarball atomically (temp name + rename) so a client can
# never fetch a half-written file.
for f in dist/*.tar.gz; do
name="$(basename "$f")"
cp "$f" "$TARGET/.$name.tmp"
mv -f "$TARGET/.$name.tmp" "$TARGET/$name"
done
echo "[release] Deployed $VERSION:" echo "[release] Deployed $VERSION:"
ls -lh "$TARGET/" ls -lh "$TARGET/"
- name: Update latest version pointer - name: Update latest version pointer
run: | run: |
echo "${{ steps.extract-version.outputs.version }}" > /var/www/builds.skaldagent.net/releases/LATEST VERSION="${{ steps.extract-version.outputs.version }}"
echo "[release] Updated releases/LATEST → ${{ steps.extract-version.outputs.version }}" DEST=/var/www/builds.skaldagent.net/releases
# Flip LATEST atomically — install.sh/update.sh read it to decide
# whether to upgrade, so it must never be observed empty or partial.
printf '%s\n' "$VERSION" > "$DEST/.LATEST.tmp"
mv -f "$DEST/.LATEST.tmp" "$DEST/LATEST"
echo "[release] Updated releases/LATEST → $VERSION"
+17 -10
View File
@@ -90,19 +90,26 @@ fi
echo "[package-macos] Uploading to ${REMOTE_HOST}..." echo "[package-macos] Uploading to ${REMOTE_HOST}..."
if [ "$MODE" = "release" ]; then if [ "$MODE" = "release" ]; then
# Create remote directory and copy tarball TARBALL="skald-circle-${VERSION}-darwin-arm64.tar.gz"
ssh "$REMOTE_HOST" "mkdir -p ${REMOTE_BASE}/releases/${VERSION}" RDIR="${REMOTE_BASE}/releases/${VERSION}"
scp dist/skald-circle-${VERSION}-darwin-arm64.tar.gz \ ssh "$REMOTE_HOST" "mkdir -p ${RDIR}"
"${REMOTE_HOST}:${REMOTE_BASE}/releases/${VERSION}/"
# Update LATEST pointer # Publish atomically: scp to a temp name, then rename over the target so a
echo "$VERSION" | ssh "$REMOTE_HOST" "cat > ${REMOTE_BASE}/releases/LATEST" # concurrent download never sees a half-transferred tarball.
scp "dist/${TARBALL}" "${REMOTE_HOST}:${RDIR}/.${TARBALL}.tmp"
ssh "$REMOTE_HOST" "mv -f ${RDIR}/.${TARBALL}.tmp ${RDIR}/${TARBALL}"
# Flip LATEST atomically (write temp + rename) — clients read it to decide
# whether to upgrade, so it must never be observed empty or partial.
ssh "$REMOTE_HOST" "printf '%s\n' '${VERSION}' > ${REMOTE_BASE}/releases/.LATEST.tmp && mv -f ${REMOTE_BASE}/releases/.LATEST.tmp ${REMOTE_BASE}/releases/LATEST"
echo "[package-macos] ✅ Release ${VERSION} deployed + LATEST updated." echo "[package-macos] ✅ Release ${VERSION} deployed + LATEST updated."
else else
# Nightly — copy into nightly/ directory # Nightly — atomic publish into nightly/ (fixed filename, reused each run).
ssh "$REMOTE_HOST" "mkdir -p ${REMOTE_BASE}/nightly" TARBALL="skald-circle-nightly-darwin-arm64.tar.gz"
scp dist/skald-circle-nightly-darwin-arm64.tar.gz \ NDIR="${REMOTE_BASE}/nightly"
"${REMOTE_HOST}:${REMOTE_BASE}/nightly/" ssh "$REMOTE_HOST" "mkdir -p ${NDIR}"
scp "dist/${TARBALL}" "${REMOTE_HOST}:${NDIR}/.${TARBALL}.tmp"
ssh "$REMOTE_HOST" "mv -f ${NDIR}/.${TARBALL}.tmp ${NDIR}/${TARBALL}"
echo "[package-macos] ✅ Nightly deployed." echo "[package-macos] ✅ Nightly deployed."
fi fi
+1 -1
View File
@@ -5,7 +5,7 @@
# branch. Runs in the repo root after checkout. # branch. Runs in the repo root after checkout.
# #
# Usage: # Usage:
# ./scripts/verify-version.sh \ # ./ci/verify-version.sh \
# --builds-dir /var/www/builds.skaldagent.net # --builds-dir /var/www/builds.skaldagent.net
# #
# Exit codes: # Exit codes:
+66
View File
@@ -84,6 +84,49 @@ prompt_yes_no() {
esac esac
} }
# Like prompt_yes_no but defaults to NO — for destructive confirmations.
prompt_yes_no_default_no() {
local msg="${1:-Continue? [y/N]}"
local ans=""
if [ "$IS_INTERACTIVE" = true ]; then
printf "%s" "$msg" >&2
read -r ans || ans=""
elif (: </dev/tty) 2>/dev/null; then
printf "%s" "$msg" >/dev/tty
IFS= read -r ans </dev/tty || ans=""
fi
case "$(printf "%s" "$ans" | tr '[:upper:]' '[:lower:]' | tr -d ' ')" in
"y"|"yes") return 0 ;;
*) return 1 ;;
esac
}
# ── Stop a running instance (for reinstall over an existing install) ──────────
# Stops the service and waits, bounded, for the process to exit — so the
# extraction never overwrites a live binary (ETXTBSY on Linux).
stop_existing_service() {
case "$OS" in
linux)
command -v systemctl >/dev/null 2>&1 && \
systemctl --user stop skald-circle.service 2>/dev/null || true
;;
darwin)
command -v launchctl >/dev/null 2>&1 && \
launchctl unload "$HOME/Library/LaunchAgents/com.skald.circle.plist" 2>/dev/null || true
;;
esac
if command -v pgrep >/dev/null 2>&1; then
local i=0
while pgrep -f "${INSTALL_DIR}/bin/skald" >/dev/null 2>&1; do
i=$((i + 1))
[ "$i" -gt 20 ] && break
sleep 1
done
else
sleep 2
fi
}
# ── Docker install helper ───────────────────────────────────────────────────── # ── Docker install helper ─────────────────────────────────────────────────────
install_docker() { install_docker() {
if [ "$OS" = "linux" ]; then if [ "$OS" = "linux" ]; then
@@ -237,6 +280,26 @@ ask_install_docker
# ── Optional dependency check ───────────────────────────────────────────────── # ── Optional dependency check ─────────────────────────────────────────────────
check_optional_deps check_optional_deps
# ── Existing installation? ────────────────────────────────────────────────────
# This installer targets a fresh install; the supported in-place upgrade path is
# update.sh (keeps your data, restarts the service safely). If an install is
# already here, point the user there and only reinstall over it on request.
if [ -x "$INSTALL_DIR/bin/skald" ]; then
echo ""
warn "An existing installation was found at ${INSTALL_DIR}."
echo " To upgrade in place (keeping your database and config), use:"
echo " ${INSTALL_DIR}/update.sh"
echo ""
if prompt_yes_no_default_no " Reinstall over it instead? Data is kept, the service restarts. [y/N] "; then
info "⏹️ Stopping the running instance before reinstalling …"
stop_existing_service
else
info "Aborted — run ${INSTALL_DIR}/update.sh to upgrade."
exit 0
fi
echo ""
fi
# ── Download & extract ──────────────────────────────────────────────────────── # ── Download & extract ────────────────────────────────────────────────────────
info "↓ Downloading Skald Circle (${DISPLAY_VERSION}) …" info "↓ Downloading Skald Circle (${DISPLAY_VERSION}) …"
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
@@ -354,6 +417,9 @@ elif [ "$OS" = "darwin" ]; then
</plist> </plist>
PLIST PLIST
# Idempotent: unload any previously-loaded agent first, so a re-install
# reloads the freshly written plist instead of failing on "already loaded".
launchctl unload "$PLIST" 2>/dev/null || true
launchctl load "$PLIST" launchctl load "$PLIST"
info "✔ Agent installed and started" info "✔ Agent installed and started"
+66
View File
@@ -87,6 +87,49 @@ prompt_yes_no() {
esac esac
} }
# Like prompt_yes_no but defaults to NO — for destructive confirmations.
prompt_yes_no_default_no() {
local msg="${1:-Continue? [y/N]}"
local ans=""
if [ "$IS_INTERACTIVE" = true ]; then
printf "%s" "$msg" >&2
read -r ans || ans=""
elif (: </dev/tty) 2>/dev/null; then
printf "%s" "$msg" >/dev/tty
IFS= read -r ans </dev/tty || ans=""
fi
case "$(printf "%s" "$ans" | tr '[:upper:]' '[:lower:]' | tr -d ' ')" in
"y"|"yes") return 0 ;;
*) return 1 ;;
esac
}
# ── Stop a running instance (for reinstall over an existing install) ──────────
# Stops the service and waits, bounded, for the process to exit — so the
# extraction never overwrites a live binary (ETXTBSY on Linux).
stop_existing_service() {
case "$OS" in
linux)
command -v systemctl >/dev/null 2>&1 && \
systemctl --user stop skald-circle.service 2>/dev/null || true
;;
darwin)
command -v launchctl >/dev/null 2>&1 && \
launchctl unload "$HOME/Library/LaunchAgents/com.skald.circle.plist" 2>/dev/null || true
;;
esac
if command -v pgrep >/dev/null 2>&1; then
local i=0
while pgrep -f "${INSTALL_DIR}/bin/skald" >/dev/null 2>&1; do
i=$((i + 1))
[ "$i" -gt 20 ] && break
sleep 1
done
else
sleep 2
fi
}
# ── Docker install helper ───────────────────────────────────────────────────── # ── Docker install helper ─────────────────────────────────────────────────────
install_docker() { install_docker() {
if [ "$OS" = "linux" ]; then if [ "$OS" = "linux" ]; then
@@ -242,6 +285,26 @@ ask_install_docker
# ── Optional dependency check ───────────────────────────────────────────────── # ── Optional dependency check ─────────────────────────────────────────────────
check_optional_deps check_optional_deps
# ── Existing installation? ────────────────────────────────────────────────────
# This installer targets a fresh install; the supported in-place upgrade path is
# update.sh (keeps your data, restarts the service safely). If an install is
# already here, point the user there and only reinstall over it on request.
if [ -x "$INSTALL_DIR/bin/skald" ]; then
echo ""
warn "An existing installation was found at ${INSTALL_DIR}."
echo " To upgrade in place (keeping your database and config), use:"
echo " ${INSTALL_DIR}/update.sh"
echo ""
if prompt_yes_no_default_no " Reinstall over it instead? Data is kept, the service restarts. [y/N] "; then
info "⏹️ Stopping the running instance before reinstalling …"
stop_existing_service
else
info "Aborted — run ${INSTALL_DIR}/update.sh to upgrade."
exit 0
fi
echo ""
fi
# ── Download & extract ──────────────────────────────────────────────────────── # ── Download & extract ────────────────────────────────────────────────────────
info "↓ Downloading Skald Circle ${VERSION}" info "↓ Downloading Skald Circle ${VERSION}"
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
@@ -359,6 +422,9 @@ elif [ "$OS" = "darwin" ]; then
</plist> </plist>
PLIST PLIST
# Idempotent: unload any previously-loaded agent first, so a re-install
# reloads the freshly written plist instead of failing on "already loaded".
launchctl unload "$PLIST" 2>/dev/null || true
launchctl load "$PLIST" launchctl load "$PLIST"
info "✔ Agent installed and started" info "✔ Agent installed and started"
+39 -2
View File
@@ -46,11 +46,25 @@ echo " This will permanently delete Skald Circle and all its data:"
echo " ${INSTALL_DIR}" echo " ${INSTALL_DIR}"
echo "" echo ""
if [ -t 0 ]; then # Confirm before a destructive delete. Read from the terminal even when stdin is
printf "%s " "Are you sure? Type 'yes' to continue: " # piped (curl | sh); if there's no terminal at all, require SKALD_YES=1 so an
# install is never wiped with no confirmation.
if [ "${SKALD_YES:-}" = "1" ]; then
:
elif [ -t 0 ]; then
printf "%s " "Are you sure? Type 'yes' to continue:"
read -r CONFIRM read -r CONFIRM
[ "$CONFIRM" = "yes" ] || { echo "Aborted."; exit 0; } [ "$CONFIRM" = "yes" ] || { echo "Aborted."; exit 0; }
echo "" echo ""
elif (: </dev/tty) 2>/dev/null; then
printf "%s " "Are you sure? Type 'yes' to continue:" >/dev/tty
IFS= read -r CONFIRM </dev/tty
[ "$CONFIRM" = "yes" ] || { echo "Aborted."; exit 0; }
echo ""
else
err "No terminal available for confirmation."
err "Re-run with SKALD_YES=1 to uninstall non-interactively."
exit 1
fi fi
# ── Stop & remove daemon ────────────────────────────────────────────────────── # ── Stop & remove daemon ──────────────────────────────────────────────────────
@@ -94,6 +108,29 @@ case "$OS" in
;; ;;
esac esac
# ── Remove Docker sandboxes ───────────────────────────────────────────────────
# Each user has a container named skald-{userid} that bind-mounts files under the
# install dir. The daemon is stopped above, so the server won't recreate them
# mid-cleanup; removing them here also frees the (sometimes root-owned) mount
# files that would otherwise force the sudo fallback on the rm below.
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
CONTAINERS="$(docker ps -aq --filter 'name=skald-' 2>/dev/null || true)"
if [ -n "$CONTAINERS" ]; then
info "🐳 Removing Skald Docker containers …"
# shellcheck disable=SC2086 # word-splitting is intentional (multiple IDs)
docker rm -f $CONTAINERS >/dev/null 2>&1 || true
fi
IMAGES="$(docker images -q skald-runtime 2>/dev/null || true)"
if [ -n "$IMAGES" ]; then
info "🐳 Removing Skald runtime image …"
# shellcheck disable=SC2086
docker rmi -f $IMAGES >/dev/null 2>&1 || true
fi
elif command -v docker >/dev/null 2>&1; then
warn "Docker daemon not reachable — skipping container cleanup."
warn "Remove leftovers later with: docker rm -f \$(docker ps -aq --filter name=skald-)"
fi
# ── Remove installation directory ───────────────────────────────────────────── # ── Remove installation directory ─────────────────────────────────────────────
if [ -d "$INSTALL_DIR" ]; then if [ -d "$INSTALL_DIR" ]; then
info "🗑️ Removing installation directory …" info "🗑️ Removing installation directory …"
+182 -101
View File
@@ -8,7 +8,20 @@
# whether to pull from the release or nightly channel. On release, checks # whether to pull from the release or nightly channel. On release, checks
# the remote LATEST version first and skips the download if already current. # the remote LATEST version first and skips the download if already current.
# #
# Stops the service before extracting, then restarts it afterwards. # Robustness notes (why the flow looks the way it does):
# * The tarball is downloaded and validated in a *staging* directory BEFORE
# the running service is touched — a broken download never takes the app
# down.
# * The service is stopped and we WAIT until the process is actually gone
# before extracting: overwriting a live binary in place fails with ETXTBSY
# (Linux) or on a running Mach-O (macOS), which would abort the update with
# the service left down.
# * The whole flow runs inside main(), invoked on the very last line, so the
# shell has parsed the entire script into memory before `tar` overwrites
# update.sh with its own new copy (the tarball ships this script). Without
# this, the shell would read garbage for the tail and never restart.
# * A trap restarts the service if the update fails after the stop, so a
# failed update never leaves the box down.
set -eu set -eu
@@ -28,7 +41,7 @@ if [ ! -f "$CHANNEL_FILE" ]; then
exit 1 exit 1
fi fi
CHANNEL="$(cat "$CHANNEL_FILE" | tr -d '[:space:]')" CHANNEL="$(tr -d '[:space:]' < "$CHANNEL_FILE")"
# ── Colours (if terminal) ───────────────────────────────────────────────────── # ── Colours (if terminal) ─────────────────────────────────────────────────────
if [ -t 1 ]; then if [ -t 1 ]; then
@@ -74,46 +87,13 @@ esac
command -v curl >/dev/null 2>&1 || { err "curl is required but not installed."; exit 1; } command -v curl >/dev/null 2>&1 || { err "curl is required but not installed."; exit 1; }
# ── Determine download URL ────────────────────────────────────────────────────
BASE_URL="https://builds.skaldagent.net" BASE_URL="https://builds.skaldagent.net"
case "$CHANNEL" in # ── Global state (referenced by the EXIT trap) ────────────────────────────────
release) TMP_TARBALL=""
LATEST="$(curl -fsSL "${BASE_URL}/releases/LATEST" | head -1 | tr -d '[:space:]')" STAGING=""
if [ -z "$LATEST" ]; then STOPPED=0 # set once the service has been stopped
err "Could not fetch latest release version from ${BASE_URL}/releases/LATEST" STARTED=0 # set once it has been (re)started
exit 1
fi
VERSION_FILE="${INSTALL_DIR}/.release-version"
if [ -f "$VERSION_FILE" ]; then
CURRENT="$(cat "$VERSION_FILE" | tr -d '[:space:]')"
if [ "$CURRENT" = "$LATEST" ]; then
info "✔ Already up to date (${CURRENT})."
exit 0
fi
info "🚀 Update available: ${CURRENT}${LATEST}"
else
info "🚀 Installing latest release: ${LATEST}"
fi
VERSION="$LATEST"
TARBALL_URL="${BASE_URL}/releases/${VERSION}/skald-circle-${VERSION}-${OS}-${ARCH}.tar.gz"
DISPLAY_VERSION="$VERSION"
;;
nightly)
TARBALL_URL="${BASE_URL}/nightly/skald-circle-nightly-${OS}-${ARCH}.tar.gz"
DISPLAY_VERSION="nightly"
info "🚀 Updating to latest nightly build …"
;;
*)
err "Unknown channel in ${CHANNEL_FILE}: '${CHANNEL}'"
err "Expected 'release' or 'nightly'."
exit 1
;;
esac
# ── Stop service ────────────────────────────────────────────────────────────── # ── Stop service ──────────────────────────────────────────────────────────────
stop_service() { stop_service() {
@@ -137,6 +117,31 @@ stop_service() {
esac esac
} }
# ── Wait until the server process is actually gone ────────────────────────────
# systemctl stop is synchronous, but launchctl unload kills the process group
# asynchronously — so we poll for the real binary before overwriting it.
wait_until_stopped() {
# Match the server binary by path prefix. Deliberately unanchored: the
# server runs with no args today, but we'd rather over-match (a harmless
# extra wait) than miss a still-running process and race the extraction.
# `skald-setup` only runs at first-run setup, never during an update.
pat="${INSTALL_DIR}/bin/skald"
if command -v pgrep >/dev/null 2>&1; then
i=0
while pgrep -f "$pat" >/dev/null 2>&1; do
i=$((i + 1))
if [ "$i" -gt 20 ]; then
warn "Server still running after ~20s; proceeding anyway."
return 0
fi
sleep 1
done
else
# No pgrep: give the service manager a moment to tear the process down.
sleep 3
fi
}
# ── Start service ───────────────────────────────────────────────────────────── # ── Start service ─────────────────────────────────────────────────────────────
start_service() { start_service() {
case "$OS" in case "$OS" in
@@ -155,72 +160,148 @@ start_service() {
esac esac
} }
# ── Cleanup + safety net ──────────────────────────────────────────────────────
# Runs on every exit. Removes temp files and, if the update died after the
# service was stopped but before it came back up, makes a best-effort restart so
# the box is never left down.
cleanup() {
[ -n "${TMP_TARBALL:-}" ] && rm -f "$TMP_TARBALL" 2>/dev/null || true
[ -n "${STAGING:-}" ] && rm -rf "$STAGING" 2>/dev/null || true
if [ "$STOPPED" = "1" ] && [ "$STARTED" != "1" ]; then
warn "Update failed after the service was stopped — attempting to restart it …"
start_service || true
fi
}
trap cleanup EXIT
# ── Main ────────────────────────────────────────────────────────────────────── # ── Main ──────────────────────────────────────────────────────────────────────
banner "╔══════════════════════════════════════════╗" main() {
banner "║ Skald Circle — Updater (${DISPLAY_VERSION}) ║" # ── Resolve download URL + version (may exit early if already current) ─────
banner "╚══════════════════════════════════════════╝" case "$CHANNEL" in
echo "" release)
echo " Channel : ${CHANNEL}" LATEST="$(curl -fsSL "${BASE_URL}/releases/LATEST" | head -1 | tr -d '[:space:]')"
echo " Platform : ${OS}/${ARCH}" if [ -z "$LATEST" ]; then
echo " Install : ${INSTALL_DIR}" err "Could not fetch latest release version from ${BASE_URL}/releases/LATEST"
echo "" exit 1
fi
stop_service VERSION_FILE="${INSTALL_DIR}/.release-version"
if [ -f "$VERSION_FILE" ]; then
CURRENT="$(tr -d '[:space:]' < "$VERSION_FILE")"
if [ "$CURRENT" = "$LATEST" ]; then
info "✔ Already up to date (${CURRENT})."
exit 0
fi
info "🚀 Update available: ${CURRENT}${LATEST}"
else
info "🚀 Installing latest release: ${LATEST}"
fi
# Download & extract VERSION="$LATEST"
TMP_TARBALL="$(mktemp -t skald-update.XXXXXX.tar.gz)" TARBALL_URL="${BASE_URL}/releases/${VERSION}/skald-circle-${VERSION}-${OS}-${ARCH}.tar.gz"
trap 'rm -f "$TMP_TARBALL"' EXIT DISPLAY_VERSION="$VERSION"
;;
info "↓ Downloading Skald Circle (${DISPLAY_VERSION}) …" nightly)
curl -fsSL -o "$TMP_TARBALL" "$TARBALL_URL" TARBALL_URL="${BASE_URL}/nightly/skald-circle-nightly-${OS}-${ARCH}.tar.gz"
DISPLAY_VERSION="nightly"
info "🚀 Updating to latest nightly build …"
;;
info "📦 Extracting …" *)
tar xzf "$TMP_TARBALL" -C "$INSTALL_DIR" --strip-components=1 err "Unknown channel in ${CHANNEL_FILE}: '${CHANNEL}'"
err "Expected 'release' or 'nightly'."
exit 1
;;
esac
if [ ! -x "$INSTALL_DIR/bin/skald" ]; then banner "╔══════════════════════════════════════════╗"
err "Extraction failedskald binary not found." banner "║ Skald Circle — Updater (${DISPLAY_VERSION}) ║"
exit 1 banner "╚══════════════════════════════════════════╝"
fi echo ""
echo " Channel : ${CHANNEL}"
# Update version file for release channel echo " Platform : ${OS}/${ARCH}"
if [ "$CHANNEL" = "release" ]; then echo " Install : ${INSTALL_DIR}"
echo "$VERSION" > "$INSTALL_DIR/.release-version"
fi
# Rebuild Python venv (best effort — new deps may have appeared)
info "🔧 Rebuilding Python virtual environment …"
VENV_DIR="${INSTALL_DIR}/.venv"
REQUIREMENTS="${INSTALL_DIR}/requirements.txt"
rm -rf "$VENV_DIR"
if command -v uv >/dev/null 2>&1; then
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
&& info "✔ Python venv ready (uv)" \
|| warn "Python venv setup failed — Python MCP servers will be unavailable."
elif command -v python3 >/dev/null 2>&1; then
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
&& info "✔ Python venv ready (pip)" \
|| warn "Python venv setup failed — Python MCP servers will be unavailable."
else
warn "python3 not found — Python MCP servers will be unavailable."
fi
start_service
# ── Hint about optional deps ──────────────────────────────────────────────────
if [ -f "${INSTALL_DIR}/requirements-optional.txt" ]; then
info "💡 Optional GPU/ML dependencies available:"
info " ${INSTALL_DIR}/requirements-optional.txt"
echo " Install them manually if you use the Orpheus TTS plugin:"
echo " cd ${INSTALL_DIR} && .venv/bin/pip install -r requirements-optional.txt"
echo "" echo ""
fi
# ── Done ────────────────────────────────────────────────────────────────────── # ── Download + validate BEFORE touching the running service ────────────────
echo "" # A broken download or a bad archive must never take the app down: we only
info "✅ Skald Circle updated to ${DISPLAY_VERSION}!" # stop the service once we hold a known-good tarball.
echo "" TMP_TARBALL="$(mktemp -t skald-update.XXXXXX.tar.gz)"
echo " Status: $( [ "$OS" = "linux" ] && echo "systemctl --user status skald-circle" || echo "launchctl list com.skald.circle" )"
echo " Logs: $( [ "$OS" = "linux" ] && echo "journalctl --user -u skald-circle -f" || echo "tail -f ${INSTALL_DIR}/logs/stdout.log" )" info "↓ Downloading Skald Circle (${DISPLAY_VERSION}) …"
echo " Update: ${INSTALL_DIR}/update.sh" curl -fsSL -o "$TMP_TARBALL" "$TARBALL_URL"
echo ""
info "🔎 Verifying archive …"
STAGING="$(mktemp -d -t skald-update-staging.XXXXXX)"
tar xzf "$TMP_TARBALL" -C "$STAGING" --strip-components=1
if [ ! -x "$STAGING/bin/skald" ]; then
err "Downloaded archive is invalid — skald binary not found."
err "The running server was left untouched."
exit 1
fi
# ── Stop the service and wait for the process to actually exit ─────────────
stop_service
STOPPED=1
wait_until_stopped
# ── Install (binary is no longer busy) ─────────────────────────────────────
info "📦 Installing update …"
tar xzf "$TMP_TARBALL" -C "$INSTALL_DIR" --strip-components=1
if [ ! -x "$INSTALL_DIR/bin/skald" ]; then
err "Extraction failed — skald binary not found."
exit 1
fi
# Update version file for release channel
if [ "$CHANNEL" = "release" ]; then
echo "$VERSION" > "$INSTALL_DIR/.release-version"
fi
# ── Rebuild Python venv (best effort — new deps may have appeared) ─────────
info "🔧 Rebuilding Python virtual environment …"
VENV_DIR="${INSTALL_DIR}/.venv"
REQUIREMENTS="${INSTALL_DIR}/requirements.txt"
rm -rf "$VENV_DIR"
if command -v uv >/dev/null 2>&1; then
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
&& info "✔ Python venv ready (uv)" \
|| warn "Python venv setup failed — Python MCP servers will be unavailable."
elif command -v python3 >/dev/null 2>&1; then
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
&& info "✔ Python venv ready (pip)" \
|| warn "Python venv setup failed — Python MCP servers will be unavailable."
else
warn "python3 not found — Python MCP servers will be unavailable."
fi
# ── Restart ────────────────────────────────────────────────────────────────
start_service
STARTED=1
# ── Hint about optional deps ───────────────────────────────────────────────
if [ -f "${INSTALL_DIR}/requirements-optional.txt" ]; then
info "💡 Optional GPU/ML dependencies available:"
info " ${INSTALL_DIR}/requirements-optional.txt"
echo " Install them manually if you use the Orpheus TTS plugin:"
echo " cd ${INSTALL_DIR} && .venv/bin/pip install -r requirements-optional.txt"
echo ""
fi
# ── Done ───────────────────────────────────────────────────────────────────
echo ""
info "✅ Skald Circle updated to ${DISPLAY_VERSION}!"
echo ""
echo " Status: $( [ "$OS" = "linux" ] && echo "systemctl --user status skald-circle" || echo "launchctl list com.skald.circle" )"
echo " Logs: $( [ "$OS" = "linux" ] && echo "journalctl --user -u skald-circle -f" || echo "tail -f ${INSTALL_DIR}/logs/stdout.log" )"
echo " Update: ${INSTALL_DIR}/update.sh"
echo ""
}
# Invoked on the very last line: by the time `tar` overwrites this file on disk
# (the tarball ships update.sh), the shell has already parsed the whole script,
# so the restart tail always runs. Do not add executable code below this line.
main "$@"