Files
glchat/scripts/desktop-release.sh
T
grendervill 498201b413 feat(server): инстанс отдаёт манифест автообновления desktop-обёртки
Обёртка с первого инкремента Фазы 6 запрашивает
`GET /updates/{target}/{arch}/{current_version}`, но на инстансе этого пути не
было: запрос попадал в SPA-заглушку, клиент получал HTML с кодом 200 и писал
в лог «error decoding response body».

Что сделано:

- `internal/server/updates.go` — статика автообновления: каталог `UPDATES_DIR`
  (в контейнере `/app/updates:ro`), раскладка `<target>/<arch>/<version>.json` +
  артефакт и `.sig`; версия без расширения отображается на `<version>.json`,
  а если его нет — на `latest.json` (обёртка приходит со своей установленной
  версией, «новее ли релиз» решает клиент сравнением semver). Для имён
  с расширением артефакта запасного варианта нет: иначе вместо архива клиент
  скачал бы манифест и подпись не сошлась бы. Сегменты пути проверяются по
  символам, выход за каталог невозможен; `/updates/` добавлен в reservedPrefixes,
  поэтому отсутствующий манифест — честный 404, а не HTML-шелл;
- `scripts/desktop-release.sh` и цели `make desktop-release` /
  `make desktop-release-upload HOST=…`: берут собранный `glchat.app.tar.gz` + `.sig`,
  определяют target/arch по имени артефакта, копируют их в каталог инстанса и
  собирают манифест (версия из `tauri.conf.json`), объединяя платформы одной
  версии; есть `--dry-run` и загрузка на инстанс по SSH;
- `deploy/install.sh` и шаблоны compose: каталог `${GLCHAT_UPDATES_DIR}`
  (`/opt/glchat/updates`) создаётся установщиком и монтируется в контейнер
  только для чтения; в `.env` добавлен `GLCHAT_UPDATES_DIR`.

Владельцу внешнего прокси добавлять ничего не нужно: `/updates/...` уходит в
catch-all `handle` и доходит до приложения, как статика клиента.

Проверено на стенде: `curl https://gl.mhspx.su/updates/darwin/aarch64/0.1.0`
→ 200 `application/json; charset=utf-8`, артефакт → 200 `application/gzip`,
отсутствующие платформа и версия → 404 (не HTML), `readyz` → 200, обёртка
в логе пишет «обновлений нет» вместо ошибки разбора. Тесты: 6 новых в
`internal/server/updates_test.go`, манифест разбирается типом самого плагина
обновлений (`updates::tests::манифест_разбирается_плагином_обновлений`).
2026-09-22 23:17:22 +03:00

298 lines
14 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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. копирует артефакт и подпись в <каталог>/<target>/<arch>/;
# 3. пишет манифест <каталог>/<target>/<arch>/<version>.json и его копию
# latest.json — её инстанс отдаёт в ответ на
# GET /updates/{target}/{arch}/{current_version} (обёртка приходит со
# своей установленной версией, а «новее ли релиз» решает клиент).
#
# Приватный ключ подписи не нужен: подпись уже лежит рядом с артефактом (её
# делает `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 через sudo по SSH
--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%/}"
# --- сбор артефактов ------------------------------------------------------
# Версия релиза: единственный источник правды — 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
}
# Подготовленный каталог раскладки: <target>/<arch>/<файлы>. В него же
# собирается манифест, поэтому локальная запись и загрузка по SSH идут одним
# и тем же кодом.
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
: >"${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"
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"
chmod 644 "${install_dir}/${artifact_name}" "${install_dir}/${artifact_name}.sig" \
"${install_dir}/${VERSION}.json" "${install_dir}/latest.json"
ok "разложено в ${install_dir}"
done <"${STAGE}/layout.txt"
cat <<EOF
Готово. Проверка (подставьте target/arch из вывода выше):
curl -s ${BASE_URL}/updates/<target>/<arch>/${VERSION} | jq .
Обёртка проверяет манифест сама (трей → «Проверить обновления…»); каталог
смонтирован в контейнер только для чтения, перезапуск приложения не нужен.
EOF
exit 0
fi
# --- доставка на удалённый инстанс ---------------------------------------
# Каталог обновлений принадлежит root (его создаёт установщик), поэтому на
# удалённой стороне rsync запускается через sudo: пароль спросит ssh -t.
have rsync || die "нужен rsync для --remote"
info "загружаем на ${REMOTE}:${UPDATES_DIR} (sudo спросит пароль)"
rsync -az --rsync-path="sudo rsync" -e "ssh -t" "${STAGE}/" "${REMOTE}:${UPDATES_DIR}/" ||
die "не удалось загрузить артефакты на ${REMOTE}"
ssh -t "$REMOTE" "sudo find '${UPDATES_DIR}' -type f -exec chmod 644 {} +"
ok "обновления на ${REMOTE}:${UPDATES_DIR}"
cat <<EOF
Проверка:
curl -s ${BASE_URL}/updates/<target>/<arch>/${VERSION} | jq .
EOF