Files
bolt/deploy/update.sh
T
jocay a8d6dd4b87 deploy: 站点探测不再强制 IPv4/IPv6 并加超时
git.jocayn.qzz.io 在 Cloudflare 后面同时有 IPv4/IPv6 地址,部分网络(如 nas)IPv4 不通、
IPv6 可用;原来用 -4 强制 IPv4 且没有连接超时,会重试到卡死。改为由 curl 自动选择协议族,
探测 8 秒连接超时、15 秒总时长且不重试,下载加 15 秒连接超时。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:57:56 +08:00

265 lines
9.1 KiB
Bash

#!/bin/sh
# bolt 在线更新脚本(POSIX sh,需 root;Linux 使用 systemd,macOS 使用 launchd)
#
# 只替换二进制并重启服务,保留现有配置(/etc/bolt.env 或 launchd plist);
# 新版本启动失败时自动回滚到旧版本。
#
# 用法(在目标设备上):
# IPv4: curl -fsSL https://git.jocayn.qzz.io/jocay/bolt/raw/branch/main/deploy/update.sh | sudo sh
# IPv6: curl -fsSL https://git.draws.qzz.io/jocay/bolt/raw/branch/main/deploy/update.sh | sudo sh
# 传参: ... | sudo sh -s -- --version v0.2.0
#
# 选项:
# --version <tag> 安装指定版本(默认 Release 上的最新版本)
# --binary <src> 使用本地二进制或 http(s) URL,跳过 Release 下载
# --force 版本相同或更旧也照样安装
#
# 环境变量:
# BOLT_GIT_HOST 指定下载站点(如 https://git.jocayn.qzz.io),默认先试 IPv4 站点再试 IPv6 站点
# BOLT_DL_TOKEN 仓库为私有时的下载令牌
set -eu
REPO=jocay/bolt
HOST4=https://git.jocayn.qzz.io
HOST6=https://git.draws.qzz.io
BIN_PATH=/usr/local/bin/bolt
LABEL=io.jocay.bolt
VERSION="${BOLT_VERSION:-}"
BINARY_SRC=""
FORCE=0
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="${2:-}"; shift 2 ;;
--binary) BINARY_SRC="${2:-}"; shift 2 ;;
--force) FORCE=1; shift ;;
*) echo "未知参数: $1"; echo "用法见脚本头部注释"; exit 1 ;;
esac
done
say() { printf '\033[1;32m[bolt]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[bolt]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[bolt] 错误:\033[0m %s\n' "$*"; exit 1; }
[ "$(id -u)" -eq 0 ] || die "需要 root 权限(请用 sudo 运行)"
[ -x "$BIN_PATH" ] || die "未找到已安装的 $BIN_PATH,请先用 install.sh 安装"
# ---------- 平台 ----------
OS=$(uname -s)
ARCH=$(uname -m)
case "$OS" in
Linux) PLATFORM=linux ;;
Darwin)
PLATFORM=darwin
# Rosetta 下 uname 可能返回 x86_64,优先识别实际的 Apple Silicon 硬件
if [ "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" = 1 ]; then
ARCH=arm64
fi
;;
*) die "不支持的操作系统: $OS" ;;
esac
case "$ARCH" in
x86_64|amd64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) die "不支持的架构: $ARCH" ;;
esac
# ---------- 下载工具 ----------
# fetch <url> <输出文件> [probe]:probe 用于探测站点,连接最多等 8 秒、总共 15 秒且不重试
fetch() {
if [ "${3:-}" = probe ]; then
c_opt="--connect-timeout 8 -m 15"; w_opt="-T 15 -t 1"
else
c_opt="--connect-timeout 15 --retry 3"; w_opt="-T 60 -t 3"
fi
if command -v curl >/dev/null 2>&1; then
if [ -n "${BOLT_DL_TOKEN:-}" ]; then
curl -fsSL $c_opt -H "Authorization: token $BOLT_DL_TOKEN" -o "$2" "$1"
else
curl -fsSL $c_opt -o "$2" "$1"
fi
elif command -v wget >/dev/null 2>&1; then
if [ -n "${BOLT_DL_TOKEN:-}" ]; then
wget -q $w_opt --header="Authorization: token $BOLT_DL_TOKEN" -O "$2" "$1"
else
wget -q $w_opt -O "$2" "$1"
fi
else
die "需要 curl 或 wget"
fi
}
# 探测可用站点:先 IPv4 站点再 IPv6 站点(不强制协议族,由 curl 自动选择可用的 IPv4 / IPv6 地址)
pick_host() {
if [ -n "${BOLT_GIT_HOST:-}" ]; then
HOST="${BOLT_GIT_HOST%/}"
return
fi
for h in "$HOST4" "$HOST6"; do
if fetch "$h/api/v1/version" "$TMP/probe" probe 2>/dev/null; then
HOST=$h
return
fi
done
die "无法连接 $HOST4 或 $HOST6,可用 BOLT_GIT_HOST 指定站点"
}
# ver_cmp a b:输出 1 / 0 / -1
ver_cmp() {
awk -v a="$1" -v b="$2" 'BEGIN {
n = split(a, x, "."); m = split(b, y, "."); k = n > m ? n : m
for (i = 1; i <= k; i++) {
if (x[i] + 0 > y[i] + 0) { print 1; exit }
if (x[i] + 0 < y[i] + 0) { print -1; exit }
}
print 0
}'
}
bin_version() { "$1" --help 2>/dev/null | head -1 | awk '{print $2}'; }
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
CUR=$(bin_version "$BIN_PATH")
[ -n "$CUR" ] || CUR=unknown
say "当前版本: $CUR 平台: $PLATFORM/$ARCH"
# ---------- 1. 获取新二进制 ----------
NEW_BIN="$TMP/bolt"
if [ -n "$BINARY_SRC" ]; then
if [ -f "$BINARY_SRC" ]; then
cp "$BINARY_SRC" "$NEW_BIN"
elif echo "$BINARY_SRC" | grep -q '^https\?://'; then
say "下载: $BINARY_SRC"
fetch "$BINARY_SRC" "$NEW_BIN" || die "下载失败"
else
die "二进制来源不存在: $BINARY_SRC"
fi
else
pick_host
say "下载站点: $HOST"
if [ -z "$VERSION" ]; then
fetch "$HOST/api/v1/repos/$REPO/releases/latest" "$TMP/latest.json" || die "查询最新版本失败"
VERSION=$(sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p' "$TMP/latest.json")
[ -n "$VERSION" ] || die "无法解析最新版本号"
EXPLICIT=0
else
EXPLICIT=1
fi
TARGET=${VERSION#v}
if [ "$FORCE" -eq 0 ] && [ "$CUR" != unknown ]; then
c=$(ver_cmp "$TARGET" "$CUR")
if [ "$c" = 0 ]; then
say "已是最新版本 $CUR,无需更新(--force 可强制重装)"
exit 0
fi
if [ "$c" = -1 ] && [ "$EXPLICIT" -eq 0 ]; then
say "Release 上最新的是 $VERSION,比已安装的 $CUR 旧,不降级(需要降级请用 --version 指定)"
exit 0
fi
fi
BASE="$HOST/$REPO/releases/download/$VERSION"
say "下载: $BASE/bolt-$PLATFORM-$ARCH"
fetch "$BASE/bolt-$PLATFORM-$ARCH" "$NEW_BIN" || die "下载失败,请确认 Release $VERSION 有 bolt-$PLATFORM-$ARCH 附件"
# 有 SHA256SUMS 附件时校验
if fetch "$BASE/SHA256SUMS" "$TMP/SHA256SUMS" 2>/dev/null; then
want=$(awk -v f="bolt-$PLATFORM-$ARCH" '$2 == f || $2 == "*" f {print $1}' "$TMP/SHA256SUMS")
if command -v sha256sum >/dev/null 2>&1; then
got=$(sha256sum "$NEW_BIN" | awk '{print $1}')
else
got=$(shasum -a 256 "$NEW_BIN" | awk '{print $1}')
fi
[ -n "$want" ] && [ "$want" = "$got" ] || die "SHA-256 校验失败(期望 $want,实际 $got)"
say "SHA-256 校验通过"
fi
fi
chmod 0755 "$NEW_BIN"
if [ "$PLATFORM" = darwin ]; then
# Apple Silicon 要求可执行文件带签名;没有签名时补 ad-hoc 签名
codesign -v "$NEW_BIN" 2>/dev/null || codesign -s - -f "$NEW_BIN" >/dev/null 2>&1 || true
fi
NEW_VER=$(bin_version "$NEW_BIN")
[ -n "$NEW_VER" ] || die "新二进制无法在本机运行"
if [ "$FORCE" -eq 0 ] && [ "$NEW_VER" = "$CUR" ]; then
say "已是版本 $CUR,无需更新(--force 可强制重装)"
exit 0
fi
say "新版本: $NEW_VER"
# ---------- 2. 备份并原子替换 ----------
cp -p "$BIN_PATH" "$BIN_PATH.bak"
cp "$NEW_BIN" "$BIN_PATH.new"
chmod 0755 "$BIN_PATH.new"
mv -f "$BIN_PATH.new" "$BIN_PATH"
say "已替换 $BIN_PATH(旧版本备份为 $BIN_PATH.bak)"
# ---------- 3. 重启服务(独立进程执行,失败自动回滚) ----------
if [ "$PLATFORM" = linux ] && command -v systemctl >/dev/null 2>&1 && systemctl cat bolt >/dev/null 2>&1; then
RESTART="systemctl restart bolt"
MAINPID="systemctl show -p MainPID --value bolt"
ACTIVE="systemctl is-active --quiet bolt"
elif [ "$PLATFORM" = darwin ] && launchctl print "system/$LABEL" >/dev/null 2>&1; then
RESTART="launchctl kickstart -k system/$LABEL"
MAINPID="launchctl print system/$LABEL | awk '/^[[:space:]]*pid = /{print \$3; exit}'"
ACTIVE="launchctl print system/$LABEL | grep -q 'state = running'"
else
say "未发现已注册的 bolt 服务,只替换了二进制(stdio 模式下由 MCP 客户端重新拉起即可)"
exit 0
fi
# 重启必须由独立进程执行:如果本脚本是通过 bolt 自己运行的,重启 bolt 会连带结束本脚本
WORK=$(mktemp -d /tmp/bolt-update.XXXXXX)
STATUS="$WORK/status"
cat > "$WORK/restart.sh" <<EOF
#!/bin/sh
healthy() {
$ACTIVE || return 1
p1=\$($MAINPID)
sleep 3
$ACTIVE || return 1
p2=\$($MAINPID)
[ -n "\$p1" ] && [ "\$p1" != 0 ] && [ "\$p1" = "\$p2" ]
}
sleep 1
$RESTART
sleep 2
if healthy; then
echo ok > "$STATUS"
else
cp -p "$BIN_PATH.bak" "$BIN_PATH.rollback" && mv -f "$BIN_PATH.rollback" "$BIN_PATH"
$RESTART
echo rolled-back > "$STATUS"
fi
EOF
if [ "$PLATFORM" = linux ] && command -v systemd-run >/dev/null 2>&1; then
# 临时服务不在 bolt 的 cgroup 里,停止 bolt 时不会被一起杀掉
systemd-run --quiet --collect --unit="bolt-update-$$" /bin/sh "$WORK/restart.sh"
else
(trap '' HUP; sh "$WORK/restart.sh" </dev/null >/dev/null 2>&1 &)
fi
say "正在重启服务…"
i=0
while [ $i -lt 30 ]; do
if [ -f "$STATUS" ]; then
break
fi
sleep 1
i=$((i + 1))
done
case "$(cat "$STATUS" 2>/dev/null || echo timeout)" in
ok)
say "更新完成: $CUR -> $(bin_version "$BIN_PATH"),服务运行正常"
rm -rf "$WORK"
;;
rolled-back)
rm -rf "$WORK"
die "新版本服务启动失败,已回滚到 $CUR。查看日志: $([ "$PLATFORM" = linux ] && echo 'journalctl -u bolt -n 50' || echo '/var/log/bolt/stderr.log')"
;;
*)
warn "等待超时,重启结果写在 $STATUS(ok / rolled-back)"
;;
esac