#!/usr/bin/env bash # desktop-release.sh — раскладывает собранные артефакты desktop-обёртки по # каталогу обновлений инстанса и генерирует манифест автообновления # (docs/client-tauri.md §4, desktop/README.md). # # Что делает: # 1. берёт артефакт обновления (`glchat.app.tar.gz` и его `.sig` для macOS, # `.nsis.zip`/`.msi` для Windows, `.AppImage.tar.gz` для Linux); # 2. копирует артефакт и подпись в <каталог>///; # 3. пишет манифест <каталог>///.json и его копию # latest.json — её инстанс отдаёт в ответ на # GET /updates/{target}/{arch}/{current_version} (обёртка приходит со # своей установленной версией, а «новее ли релиз» решает клиент); # 4. убирает манифесты прошлых релизов (`<старая версия>.json`): обёртка # спрашивает манифест своей версии, и оставшийся файл перекрыл бы новый # релиз — клиент со старой версией не увидел бы обновления вовсе. # # Версия релиза берётся из desktop/src-tauri/tauri.conf.json, а та — из файла # VERSION в корне репозитория (единый источник версии, см. desktop/README.md). # # Приватный ключ подписи не нужен: подпись уже лежит рядом с артефактом (её # делает `make desktop-build`), сервер только отдаёт файлы, а проверяет # подпись клиент вшитым публичным ключом. # # Примеры: # # macOS-артефакт из target/release/bundle/macos → /opt/glchat/updates # sudo bash scripts/desktop-release.sh # # разложить в свой каталог и посмотреть план # bash scripts/desktop-release.sh --dir /tmp/updates --dry-run # # загрузить на стенд (sudo спросит пароль в терминале) # bash scripts/desktop-release.sh --remote alex@192.168.1.247 # # Windows/Linux-артефакты указываются явно # bash scripts/desktop-release.sh --artifact bundle/nsis/glchat_0.1.0_x64-setup.nsis.zip set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TAURI_DIR="${REPO_DIR}/desktop/src-tauri" BUNDLE_DIR="${TAURI_DIR}/target/release/bundle" # Каталог обновлений на инстансе: тот же путь, что в UPDATES_DIR приложения # (в контейнере смонтирован в /app/updates только для чтения). UPDATES_DIR="${GLCHAT_UPDATES_DIR:-/opt/glchat/updates}" # Публичный адрес инстанса: из него собираются ссылки на артефакты в манифесте. BASE_URL="${GLCHAT_BASE_URL:-https://gl.mhspx.su}" VERSION="" TARGET="" ARCH="" NOTES="" REMOTE="" DRY_RUN=0 ARTIFACTS=() info() { printf ' %s\n' "$*"; } ok() { printf ' ✓ %s\n' "$*"; } warn() { printf ' ! %s\n' "$*" >&2; } die() { printf 'ошибка: %s\n' "$*" >&2 exit 1 } usage() { cat <<'EOF' desktop-release.sh — раскладка артефактов обновления desktop-обёртки на инстансе. Использование: bash scripts/desktop-release.sh [флаги] Флаги: --artifact FILE файл артефакта (можно несколько); по умолчанию берётся первый найденный артефакт текущей ОС в каталоге бандла --version V версия релиза (по умолчанию — из desktop/src-tauri/tauri.conf.json) --target T платформа обновления: darwin|windows|linux (по умолчанию — по имени файла) --arch A архитектура: aarch64|x86_64|i686|armv7 (по умолчанию — по имени файла) --dir DIR каталог обновлений (по умолчанию $GLCHAT_UPDATES_DIR или /opt/glchat/updates) --base-url URL публичный адрес инстанса для ссылок (по умолчанию https://gl.mhspx.su) --notes TEXT текст «что нового» в манифесте --remote HOST разложить в --dir на удалённом хосте: rsync по SSH (через sudo, если каталог не записываем; опции ssh — GLCHAT_SSH_OPTS) --dry-run показать план, ничего не записывать -h, --help эта справка EOF } while [ $# -gt 0 ]; do case "$1" in --artifact) [ $# -ge 2 ] || die "--artifact без значения" ARTIFACTS+=("$2") shift 2 ;; --version) VERSION="${2:-}" [ -n "$VERSION" ] || die "--version без значения" shift 2 ;; --target) TARGET="${2:-}" shift 2 ;; --arch) ARCH="${2:-}" shift 2 ;; --dir) UPDATES_DIR="${2:-}" [ -n "$UPDATES_DIR" ] || die "--dir без значения" shift 2 ;; --base-url) BASE_URL="${2:-}" [ -n "$BASE_URL" ] || die "--base-url без значения" shift 2 ;; --notes) NOTES="${2:-}" shift 2 ;; --remote) REMOTE="${2:-}" [ -n "$REMOTE" ] || die "--remote без значения" shift 2 ;; --dry-run) DRY_RUN=1 shift ;; -h | --help) usage exit 0 ;; *) die "неизвестный флаг: $1 (см. --help)" ;; esac done have() { command -v "$1" >/dev/null 2>&1; } have jq || die "нужен jq (генерация манифеста)" BASE_URL="${BASE_URL%/}" # newer_version → true, если a новее b. Сравниваем по числам (0.1.10 # новее 0.1.9 — обычное сравнение строк тут ошибается), недостающие части # считаем нулями. newer_version() { jq -n --arg a "$1" --arg b "$2" ' def parts: split(".") | map(tonumber? // 0) | . + [0, 0, 0] | .[0:3]; (($a | parts) > ($b | parts))' } # drop_stale_manifests <каталог> — убирает манифесты прошлых релизов, оставляя # только манифест публикуемой версии и latest.json. # # Зачем: обёртка спрашивает `/updates/{target}/{arch}/{своя_версия}`, сервер # отдаёт `<версия>.json`, а если его нет — latest.json. Файл прошлого релиза # (например 0.1.0.json) перекрывал бы новый: клиент со старой версией получал бы # «обновлений нет» и автообновление не приходило бы никому. drop_stale_manifests() { find "$1" -maxdepth 1 -type f -name '*.json' \ ! -name "${VERSION}.json" ! -name latest.json -print -delete } # --- сбор артефактов ------------------------------------------------------ # Версия релиза: единственный источник правды — tauri.conf.json # (docs/client-tauri.md §3: версия синхронизирована с сервером). if [ -z "$VERSION" ]; then VERSION="$(jq -r '.version' "${TAURI_DIR}/tauri.conf.json")" fi [ -n "$VERSION" ] && [ "$VERSION" != "null" ] || die "не удалось определить версию релиза" if [ "${#ARTIFACTS[@]}" -eq 0 ]; then shopt -s nullglob found=("${BUNDLE_DIR}"/macos/*.app.tar.gz "${BUNDLE_DIR}"/nsis/*.nsis.zip \ "${BUNDLE_DIR}"/msi/*.msi "${BUNDLE_DIR}"/appimage/*.AppImage.tar.gz) shopt -u nullglob [ "${#found[@]}" -gt 0 ] || die "в ${BUNDLE_DIR} нет артефактов обновления: сначала соберите бандл (make desktop-build)" ARTIFACTS=("${found[0]}") if [ "${#found[@]}" -gt 1 ]; then warn "найдено несколько артефактов, берём ${found[0]}; остальные укажите через --artifact" fi fi # platform_from_name разбирает имя артефакта Tauri: платформа и архитектура # берутся из имени, если не заданы флагами (glchat_0.1.0_x64-setup.nsis.zip). platform_from_name() { case "$1" in *.app.tar.gz) printf 'darwin' ;; *.msi | *.nsis.zip) printf 'windows' ;; *.AppImage.tar.gz) printf 'linux' ;; *) die "не удалось определить платформу по имени $1: укажите --target" ;; esac } arch_from_name() { case "$1" in *aarch64* | *arm64*) printf 'aarch64'; return ;; *x86_64* | *x64*) printf 'x86_64'; return ;; *i686* | *i386* | *x86*) printf 'i686'; return ;; *armv7*) printf 'armv7'; return ;; esac # Имя macOS-бандла архитектуру не содержит (glchat.app.tar.gz): берём её у # машины, которая собрала бандл. Явный --arch всегда важнее. case "$(uname -m)" in arm64 | aarch64) printf 'aarch64' ;; x86_64 | amd64) printf 'x86_64' ;; i686 | i386) printf 'i686' ;; armv7l) printf 'armv7' ;; *) die "не удалось определить архитектуру по имени $1: укажите --arch" ;; esac } # Подготовленный каталог раскладки: //<файлы>. В него же # собирается манифест, поэтому локальная запись и загрузка по SSH идут одним # и тем же кодом. STAGE="$(mktemp -d)" trap 'rm -rf "$STAGE"' EXIT # mktemp создаёт каталог с режимом 0700, а rsync -a переносит атрибуты корня # источника на корень назначения: каталог обновлений на инстансе становился # 0700 и приложение (другой пользователь) переставало читать манифесты — # /updates/… отвечал 404, хотя файлы лежали на месте. chmod 755 "$STAGE" : >"${STAGE}/layout.txt" PLATFORMS_FILE="${STAGE}/platforms.json" printf '{}' >"$PLATFORMS_FILE" DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" for artifact in "${ARTIFACTS[@]}"; do [ -f "$artifact" ] || die "нет файла ${artifact}" artifact_name="$(basename "$artifact")" # У .sig-файла артефакт ищется рядом: имя без .sig. case "$artifact_name" in *.sig) artifact_name="${artifact_name%.sig}" ;; esac artifact_dir="$(cd "$(dirname "$artifact")" && pwd)" artifact_path="${artifact_dir}/${artifact_name}" signature_path="${artifact_path}.sig" target="${TARGET:-$(platform_from_name "$artifact_name")}" arch="${ARCH:-$(arch_from_name "$artifact_name")}" [ -f "$artifact_path" ] || die "нет артефакта ${artifact_path}" [ -f "$signature_path" ] || die "нет подписи ${signature_path}: бандл собран без ключа обновлений (desktop/README.md)" install_dir="${UPDATES_DIR}/${target}/${arch}" # В манифесте — только содержимое подписи (одна строка minisign). signature="$(tr -d '\n' <"$signature_path")" url="${BASE_URL}/updates/${target}/${arch}/${artifact_name}" platform_key="${target}-${arch}" info "артефакт: ${artifact_path}" info "подпись: ${signature_path}" info "назначение: ${install_dir}/${artifact_name}" mkdir -p "${STAGE}/${target}/${arch}" cp "$artifact_path" "${STAGE}/${target}/${arch}/${artifact_name}" cp "$signature_path" "${STAGE}/${target}/${arch}/${artifact_name}.sig" printf '%s %s %s\n' "$target" "$arch" "$artifact_name" >>"${STAGE}/layout.txt" # Манифест версии: по записи на платформу. Несколько платформ одной версии # (macOS + Windows + Linux) складываются в один манифест, а не затирают # друг друга. jq --arg key "$platform_key" --arg url "$url" --arg signature "$signature" \ --arg version "$VERSION" --arg notes "$NOTES" --arg date "$DATE" \ '.version = $version | .notes = (if $notes == "" then (.notes // ("Релиз glchat " + $version)) else $notes end) | .pub_date = $date | .platforms = ((.platforms // {}) + {($key): {signature: $signature, url: $url}})' \ "$PLATFORMS_FILE" >"${PLATFORMS_FILE}.tmp" mv "${PLATFORMS_FILE}.tmp" "$PLATFORMS_FILE" done while read -r target arch artifact_name; do install_dir="${UPDATES_DIR}/${target}/${arch}" mkdir -p "${STAGE}/${target}/${arch}" cp "$PLATFORMS_FILE" "${STAGE}/${target}/${arch}/${VERSION}.json" cp "$PLATFORMS_FILE" "${STAGE}/${target}/${arch}/latest.json" info "манифест: ${install_dir}/${VERSION}.json (+ latest.json)" done <"${STAGE}/layout.txt" if [ "$DRY_RUN" = "1" ]; then info "dry-run: файлы не записаны (${UPDATES_DIR})" exit 0 fi # --- раскладка ------------------------------------------------------------- if [ -z "$REMOTE" ]; then while read -r target arch artifact_name; do install_dir="${UPDATES_DIR}/${target}/${arch}" mkdir -p "$install_dir" # Понижение версии ничего не сломает (клиент отвергает даунгрейд), но # публиковать его бессмысленно — предупреждаем. if [ -f "${install_dir}/latest.json" ]; then published="$(jq -r '.version // empty' "${install_dir}/latest.json" 2>/dev/null || true)" if [ -n "$published" ] && [ "$published" != "$VERSION" ] && [ "$(newer_version "$published" "$VERSION")" = "true" ]; then warn "в ${install_dir} уже опубликована версия ${published} — она новее ${VERSION}: обёртки её не увидят" fi fi cp -f "${STAGE}/${target}/${arch}/${artifact_name}" "${install_dir}/${artifact_name}" cp -f "${STAGE}/${target}/${arch}/${artifact_name}.sig" "${install_dir}/${artifact_name}.sig" # Манифест этой версии мог уже существовать (другая платформа той же # версии): записи платформ объединяются. if [ -f "${install_dir}/${VERSION}.json" ]; then jq -s '.[0] * .[1]' "${install_dir}/${VERSION}.json" "$PLATFORMS_FILE" >"${install_dir}/${VERSION}.json.tmp" mv "${install_dir}/${VERSION}.json.tmp" "${install_dir}/${VERSION}.json" else cp -f "$PLATFORMS_FILE" "${install_dir}/${VERSION}.json" fi # latest.json указывает на последнюю опубликованную версию для этой # платформы: именно её отдаёт инстанс на запрос обёртки. cp -f "$PLATFORMS_FILE" "${install_dir}/latest.json" drop_stale_manifests "$install_dir" | while read -r stale; do info "убран манифест прошлого релиза: ${stale}" done chmod 644 "${install_dir}/${artifact_name}" "${install_dir}/${artifact_name}.sig" \ "${install_dir}/${VERSION}.json" "${install_dir}/latest.json" # Каталоги — 755: приложение читает их под другим пользователем, а # манифест прошлого релиза мог прийти с чужого стенда с режимом 700. find "$install_dir" -type d -exec chmod 755 {} + ok "разложено в ${install_dir}" done <"${STAGE}/layout.txt" cat <//${VERSION} | jq . Обёртка проверяет манифест сама (трей → «Проверить обновления…»); каталог смонтирован в контейнер только для чтения, перезапуск приложения не нужен. EOF exit 0 fi # --- доставка на удалённый инстанс --------------------------------------- # Каталог обновлений обычно принадлежит root (его создаёт установщик) — тогда # rsync на удалённой стороне идёт через sudo и пароль спрашивает ssh -t. Но # каталог может быть и пользовательским (например, на стенде): если он # записываем, sudo не нужен вовсе, и загрузка работает без терминала (CI, # `ssh` без TTY). Дополнительные опции ssh — в GLCHAT_SSH_OPTS. have rsync || die "нужен rsync для --remote" # Опции ssh задаются строкой (её разбиение по словам здесь намеренное) — так # скрипт работает и на bash 3.2 из macOS, где пустой массив под `set -u` падает. # shellcheck disable=SC2086 SSH_EXTRA="${GLCHAT_SSH_OPTS:-}" if ssh ${SSH_EXTRA} -o BatchMode=yes "$REMOTE" "test -w '${UPDATES_DIR}'" >/dev/null 2>&1; then info "загружаем на ${REMOTE}:${UPDATES_DIR} (каталог записываем — без sudo)" # shellcheck disable=SC2086 rsync -az -e "ssh ${SSH_EXTRA}" "${STAGE}/" "${REMOTE}:${UPDATES_DIR}/" || die "не удалось загрузить артефакты на ${REMOTE}" # shellcheck disable=SC2086 ssh ${SSH_EXTRA} "$REMOTE" "find '${UPDATES_DIR}' -type d -exec chmod 755 {} +" ssh ${SSH_EXTRA} "$REMOTE" "find '${UPDATES_DIR}' -type f -exec chmod 644 {} +" # shellcheck disable=SC2086 ssh ${SSH_EXTRA} "$REMOTE" "find '${UPDATES_DIR}' -type f -name '*.json' ! -name '${VERSION}.json' ! -name latest.json -print -delete" else info "загружаем на ${REMOTE}:${UPDATES_DIR} (sudo спросит пароль)" # shellcheck disable=SC2086 rsync -az --rsync-path="sudo rsync" -e "ssh -t ${SSH_EXTRA}" "${STAGE}/" "${REMOTE}:${UPDATES_DIR}/" || die "не удалось загрузить артефакты на ${REMOTE}" # shellcheck disable=SC2086 ssh -t ${SSH_EXTRA} "$REMOTE" "sudo find '${UPDATES_DIR}' -type d -exec chmod 755 {} +" ssh -t ${SSH_EXTRA} "$REMOTE" "sudo find '${UPDATES_DIR}' -type f -exec chmod 644 {} +" # Манифесты прошлых релизов на удалённой стороне убираются по тому же # правилу, что и локально: иначе старый <версия>.json перекрывает новый релиз. # shellcheck disable=SC2086 ssh -t ${SSH_EXTRA} "$REMOTE" "sudo find '${UPDATES_DIR}' -type f -name '*.json' ! -name '${VERSION}.json' ! -name latest.json -print -delete" fi ok "обновления на ${REMOTE}:${UPDATES_DIR}" cat <//${VERSION} | jq . EOF