Files
ctms/scripts/update.sh
T
Cheng Zhou 24d5b7445f 滚动更新新增 Git 访问方式选择(HTTPS/SSH)及工作区变更预检
- install.sh:
  - 滚动更新流程新增「选择 Git 访问方式」步骤(HTTPS 登录 / SSH 访问)
  - SSH 方式支持指定私钥文件路径或粘贴私钥内容(临时文件用后即删)
  - 仓库地址输入回填当前 remote(同协议时)作为默认值
  - 选环境后立即检测工作区变更,提供暂存/放弃/取消处理
  - 更新结束后按 HEAD 是否变化决定自动恢复暂存或提示手动恢复
- scripts/update.sh:
  - 新增 --ssh-key 参数,SSH 协议下通过 GIT_SSH_COMMAND -i 指定私钥
  - 新增 --skip-dirty-check 参数,供 install.sh 预检后透传
- scripts/common.sh:
  - ctms_ask_line 提示语改输出到 stderr,避免被命令替换吞掉

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:26:54 +08:00

600 lines
20 KiB
Bash
Executable File
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
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh
source "$SCRIPT_DIR/common.sh"
# ── 全局变量 ─────────────────────────────────
TARGET_ENV=""
REPO_URL=""
GIT_BRANCH=""
SSH_KEY=""
ASSUME_YES=0
SKIP_BACKUP=0
SKIP_DIRTY_CHECK=0
FORCE_PULL=0
INSTALL_ARGS=()
# 项目身份验证标识文件(至少需要其中 2 个)
REQUIRED_PROJECT_FILES=(
"scripts/install.sh"
"docker-compose.yaml"
"backend/app/main.py"
"README.md"
)
# ── Banner ───────────────────────────────────
_banner() {
local b="${CC_PRIMARY}${C_BOLD}"
local a="${CC_INFO}"
local r="${C_RESET}"
printf '\n'
printf '%s╔══════════════════════════════════════════════╗%s\n' "$b" "$r"
printf '%s║%s ██╗ ██╗██████╗ ██████╗ █████╗ ████████╗ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ██║ ██║██████╔╝██║ ██║███████║ ██║ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ██║ ██║██╔═══╝ ██║ ██║██╔══██║ ██║ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ╚██████╔╝██║ ██████╔╝██║ ██║ ██║ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s 临床试验管理系统 · 在线更新程序 %s║%s\n' "$b" "${CC_MUTED}" "${CC_PRIMARY}" "$r"
printf '%s╚══════════════════════════════════════════════╝%s\n' "$b" "$r"
printf '\n'
}
# ── 帮助 ─────────────────────────────────────
usage() {
cat <<EOF
${C_BOLD}用法:${C_RESET}
bash scripts/update.sh ${C_CYAN}<环境>${C_RESET} [选项]
${C_BOLD}环境:${C_RESET}
${C_CYAN}dev${C_RESET} 本地开发环境
${C_CYAN}main${C_RESET} 内网测试 / 预发布环境
${C_CYAN}release${C_RESET} 生产环境
${C_BOLD}选项:${C_RESET}
${C_WHITE}--repo-url <url>${C_RESET} Git 仓库地址(HTTPS 或 SSH
不指定时会提示输入或使用当前远程地址
${C_WHITE}--branch <name>${C_RESET} 指定拉取的分支(默认为当前分支)
${C_WHITE}--ssh-key <path>${C_RESET} SSH 私钥文件路径(仅 SSH 仓库地址时生效)
不指定时使用本机默认 SSH 配置(~/.ssh/
${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行
${C_WHITE}--skip-backup${C_RESET} 跳过更新前自动备份 tag(不推荐)
${C_WHITE}--skip-dirty-check${C_RESET} 跳过工作目录变更检测(由 install.sh 预检后透传)
${C_WHITE}--force${C_RESET} 强制覆盖本地更改(git reset --hard
${C_WHITE}-h, --help${C_RESET} 显示此帮助信息
${C_BOLD}认证方式:${C_RESET}
${C_WHITE}1. SSH 密钥${C_RESET} 使用 SSH URL(如已配置密钥,自动使用)
${C_WHITE}2. Personal Token${C_RESET} 推荐使用 GitHub/Gitea Personal Access Token
${C_WHITE}3. 用户名密码${C_RESET} HTTPS 基础认证(不推荐,安全性较低)
可通过环境变量提供凭证(适合自动化):
GIT_USERNAME Git 用户名
GIT_PASSWORD Git 密码或 Personal Access Token
${C_BOLD}示例:${C_RESET}
# 交互式更新(会提示输入仓库地址)
bash scripts/update.sh dev
# 指定仓库地址更新
bash scripts/update.sh main --repo-url https://github.com/user/ctms-dev.git
# 全自动模式(从环境变量读取凭证)
export GIT_USERNAME="user"
export GIT_PASSWORD="ghp_xxxxxxxxxxxxx"
bash scripts/update.sh release --yes --repo-url https://github.com/user/ctms-dev.git
# 强制覆盖本地更改
bash scripts/update.sh dev --force
${C_BOLD}说明:${C_RESET}
更新流程:检查工作目录 → 验证仓库身份 → 拉取代码 → 调用安装脚本部署
默认会在拉取前创建备份 tag(格式:pre-update-YYYYMMDD-HHMMSS
EOF
}
# ── 参数解析 ─────────────────────────────────
parse_args() {
if [[ $# -eq 0 ]]; then
usage
exit 1
fi
TARGET_ENV="$1"
shift
case "$TARGET_ENV" in
dev|main|release) ;;
-h|--help) usage; exit 0 ;;
*)
usage
ctms_fail "未知环境 \"$TARGET_ENV\",有效值为 dev、main 或 release"
;;
esac
while [[ $# -gt 0 ]]; do
case "$1" in
--repo-url)
[[ $# -ge 2 ]] || ctms_fail "--repo-url 需要提供一个 URL 参数"
REPO_URL="$2"
shift 2
;;
--branch)
[[ $# -ge 2 ]] || ctms_fail "--branch 需要提供分支名"
GIT_BRANCH="$2"
shift 2
;;
--ssh-key)
[[ $# -ge 2 ]] || ctms_fail "--ssh-key 需要提供私钥文件路径"
SSH_KEY="$2"
shift 2
;;
--yes) ASSUME_YES=1; shift ;;
--skip-backup) SKIP_BACKUP=1; shift ;;
--skip-dirty-check) SKIP_DIRTY_CHECK=1; shift ;;
--force) FORCE_PULL=1; shift ;;
-h|--help) usage; exit 0 ;;
--skip-build|--skip-migrate|--verbose|--base-url)
# 透传给 install.sh 的参数
INSTALL_ARGS+=("$1")
if [[ "$1" == "--base-url" && $# -ge 2 ]]; then
INSTALL_ARGS+=("$2")
shift
fi
shift
;;
*)
usage
ctms_fail "未知参数: $1"
;;
esac
done
}
# ── Git 工具函数 ─────────────────────────────
check_git_installed() {
command -v git >/dev/null 2>&1 || ctms_fail "未检测到 git 命令,请先安装 Git"
}
check_is_git_repo() {
git rev-parse --git-dir >/dev/null 2>&1 || ctms_fail "当前目录不是 Git 仓库"
}
get_current_branch() {
git branch --show-current 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD"
}
get_current_remote_url() {
local branch="${1:-$(get_current_branch)}"
local remote
remote="$(git config "branch.$branch.remote" 2>/dev/null || echo "origin")"
git config "remote.$remote.url" 2>/dev/null || echo ""
}
has_uncommitted_changes() {
! git diff-index --quiet HEAD -- 2>/dev/null
}
has_untracked_files() {
[[ -n "$(git ls-files --others --exclude-standard 2>/dev/null)" ]]
}
# ── 项目身份验证 ─────────────────────────────
verify_project_identity() {
ctms_step "验证项目身份"
local found=0
local file
for file in "${REQUIRED_PROJECT_FILES[@]}"; do
if [[ -f "$CTMS_ROOT_DIR/$file" ]]; then
found=$((found + 1))
fi
done
if [[ $found -lt 2 ]]; then
ctms_fail "项目身份验证失败:未检测到足够的 CTMS 项目特征文件\n" \
" 需要至少 2 个特征文件,当前仅找到 $found 个\n" \
" 请确认仓库地址是否正确"
fi
# 额外检查 README 内容
if [[ -f "$CTMS_ROOT_DIR/README.md" ]]; then
if ! grep -q "CTMS" "$CTMS_ROOT_DIR/README.md" 2>/dev/null; then
ctms_warn "README.md 不包含 CTMS 关键字,可能拉取了错误的仓库"
fi
fi
ctms_ok "项目身份验证通过(检测到 $found 个特征文件)"
}
# ── 工作目录状态检查 ─────────────────────────
check_working_directory() {
if [[ "$SKIP_DIRTY_CHECK" -eq 1 ]]; then
return 0
fi
ctms_step "检查工作目录状态"
if [[ "$FORCE_PULL" -eq 1 ]]; then
ctms_warn "已启用 --force,将强制覆盖本地更改"
return 0
fi
local has_changes=0
if has_uncommitted_changes; then
ctms_warn "检测到未提交的更改"
has_changes=1
fi
if has_untracked_files; then
ctms_warn "检测到未跟踪的文件"
has_changes=1
fi
if [[ $has_changes -eq 0 ]]; then
ctms_ok "工作目录干净"
return 0
fi
if [[ "$ASSUME_YES" -eq 1 ]]; then
ctms_fail "检测到未提交的更改且已启用 --yes 模式\n" \
" 请先提交或使用 --force 参数强制覆盖"
fi
printf '\n %s%s%s 选择处理方式:%s\n' "${CC_WARN}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "${C_RESET}"
printf ' %s1)%s 暂存更改(git stash\n' "${C_BOLD}" "${C_RESET}"
printf ' %s2)%s 放弃更改(git reset --hard\n' "${C_BOLD}" "${C_RESET}"
printf ' %s3)%s 取消更新\n' "${C_BOLD}" "${C_RESET}"
printf ' 请输入选项 [1-3]: '
local choice
read -r choice || choice=""
case "$choice" in
1)
ctms_info "暂存本地更改..."
git stash push -m "auto-stash before update $(date +%Y%m%d-%H%M%S)" || ctms_fail "暂存失败"
ctms_ok "已暂存本地更改(可使用 'git stash pop' 恢复)"
;;
2)
ctms_warn "即将放弃所有本地更改..."
printf ' 确认输入 %syes%s 继续: ' "${C_BOLD}" "${C_RESET}"
local confirm
read -r confirm || confirm=""
[[ "$confirm" == "yes" ]] || ctms_fail "用户取消操作"
git reset --hard HEAD || ctms_fail "重置失败"
git clean -fd || true
ctms_ok "已放弃本地更改"
;;
3|*)
ctms_fail "用户取消更新"
;;
esac
}
# ── 仓库地址解析 ─────────────────────────────
resolve_repo_url() {
ctms_step "解析仓库地址"
# 如果未指定,尝试从当前 remote 获取
if [[ -z "$REPO_URL" ]]; then
REPO_URL="$(get_current_remote_url)"
if [[ -n "$REPO_URL" ]]; then
ctms_info "检测到当前远程地址: $REPO_URL"
fi
fi
# 如果仍为空且非交互模式,报错
if [[ -z "$REPO_URL" && "$ASSUME_YES" -eq 1 ]]; then
ctms_fail "未指定仓库地址且无法从当前 remote 获取\n" \
" 请使用 --repo-url 参数指定"
fi
# 交互式输入
if [[ -z "$REPO_URL" ]]; then
printf ' %s▸%s 请输入 Git 仓库地址: ' "${C_YELLOW}${C_BOLD}" "${C_RESET}"
read -r REPO_URL || true
fi
[[ -n "$REPO_URL" ]] || ctms_fail "仓库地址不能为空"
# 验证 URL 格式
if [[ ! "$REPO_URL" =~ ^(https?://|git@|ssh://) ]]; then
ctms_fail "仓库地址格式不正确\n" \
" 支持格式:https://..., git@..., ssh://..."
fi
ctms_ok "仓库地址: $REPO_URL"
}
# ── 远程引用选择 ─────────────────────────────
pick_remote_ref() {
# --branch 已指定则跳过
if [[ -n "$GIT_BRANCH" ]]; then
ctms_info "指定引用: $GIT_BRANCH"
return
fi
# --yes 模式无法交互,fallback 到当前分支
if [[ "$ASSUME_YES" -eq 1 ]]; then
GIT_BRANCH="$(get_current_branch)"
ctms_info "自动模式,使用当前分支: $GIT_BRANCH"
return
fi
ctms_step "选择远程引用"
local raw
raw="$(git ls-remote --refs "$REPO_URL" 2>/dev/null)" || {
GIT_BRANCH="$(ctms_ask_line "无法列出远程引用,请手动输入分支/标签/SHA" "$(get_current_branch)")"
return
}
local -a names types
local sha ref name
while IFS=$'\t' read -r sha ref; do
name="${ref#refs/heads/}"
names+=("$name"); types+=("branch")
done < <(printf '%s\n' "$raw" | grep $'\trefs/heads/')
while IFS=$'\t' read -r sha ref; do
name="${ref#refs/tags/}"
names+=("$name"); types+=("tag")
done < <(printf '%s\n' "$raw" | grep $'\trefs/tags/' | sort -rt/ -k3 | head -10)
local manual_idx=$(( ${#names[@]} + 1 ))
local choice i
while true; do
printf '\n'
for i in "${!names[@]}"; do
printf ' %s%2d)%s %s%-8s%s %s\n' \
"${C_BOLD}" $((i+1)) "${C_RESET}" \
"${CC_MUTED}" "${types[$i]}" "${C_RESET}" "${names[$i]}"
done
printf ' %s%2d)%s %s手动输入 SHA / 分支名 / 标签名%s\n\n' \
"${C_BOLD}" "$manual_idx" "${C_RESET}" "${CC_MUTED}" "${C_RESET}"
printf ' %s▸%s 请选择 (1-%d): ' "${C_YELLOW}${C_BOLD}" "${C_RESET}" "$manual_idx"
read -r choice || choice=""
if [[ "$choice" == "$manual_idx" || -z "$choice" ]]; then
GIT_BRANCH="$(ctms_ask_line "请输入分支名、标签或 SHA")"
break
elif [[ "$choice" =~ ^[0-9]+$ && "$choice" -ge 1 && "$choice" -le "${#names[@]}" ]]; then
GIT_BRANCH="${names[$((choice-1))]}"
ctms_ok "已选择 ${types[$((choice-1))]}: $GIT_BRANCH"
break
else
ctms_warn "无效选择,请重新输入"
fi
done
}
# ── 认证处理 ─────────────────────────────────
setup_git_credentials() {
# SSH 协议不需要额外配置
if [[ "$REPO_URL" =~ ^(git@|ssh://) ]]; then
if [[ -n "$SSH_KEY" ]]; then
[[ -f "$SSH_KEY" ]] || ctms_fail "指定的 SSH 私钥文件不存在: $SSH_KEY"
# 通过 GIT_SSH_COMMAND 指定私钥;IdentitiesOnly 确保只用这把钥匙
export GIT_SSH_COMMAND="ssh -i '$SSH_KEY' -o IdentitiesOnly=yes"
ctms_info "使用 SSH 协议,已指定私钥: $SSH_KEY"
else
ctms_info "使用 SSH 协议,使用本机默认 SSH 配置"
fi
return 0
fi
ctms_step "配置 Git 认证"
# 从环境变量读取凭证
local username="${GIT_USERNAME:-}"
local password="${GIT_PASSWORD:-}"
# 如果环境变量未设置且非自动模式,交互式输入
if [[ -z "$username" && "$ASSUME_YES" -eq 0 ]]; then
printf ' %s▸%s Git 用户名: ' "${C_YELLOW}${C_BOLD}" "${C_RESET}"
read -r username || username=""
fi
if [[ -z "$password" && "$ASSUME_YES" -eq 0 ]]; then
printf ' %s▸%s Git 密码/Token (输入不回显): ' "${C_YELLOW}${C_BOLD}" "${C_RESET}"
read -rs password || password=""
printf '\n'
fi
# 如果凭证为空,尝试使用系统凭证存储
if [[ -z "$username" || -z "$password" ]]; then
ctms_info "未提供凭证,将尝试使用系统凭证存储"
return 0
fi
# 配置临时凭证助手(仅本次会话有效)
export GIT_ASKPASS_USERNAME="$username"
export GIT_ASKPASS_PASSWORD="$password"
# 创建临时凭证助手脚本
local askpass_script="/tmp/git-askpass-$$.sh"
cat > "$askpass_script" <<'ASKPASS'
#!/bin/bash
case "$1" in
*Username*) echo "$GIT_ASKPASS_USERNAME" ;;
*Password*) echo "$GIT_ASKPASS_PASSWORD" ;;
esac
ASKPASS
chmod +x "$askpass_script"
export GIT_ASKPASS="$askpass_script"
ctms_ok "认证配置完成"
}
cleanup_git_credentials() {
if [[ -n "${GIT_ASKPASS:-}" && -f "$GIT_ASKPASS" ]]; then
rm -f "$GIT_ASKPASS" 2>/dev/null || true
fi
unset GIT_ASKPASS GIT_ASKPASS_USERNAME GIT_ASKPASS_PASSWORD
unset GIT_SSH_COMMAND
}
# ── 备份当前版本 ─────────────────────────────
create_backup_tag() {
if [[ "$SKIP_BACKUP" -eq 1 ]]; then
ctms_info "已跳过备份(--skip-backup"
return 0
fi
ctms_step "创建更新前备份"
local backup_tag="pre-update-$(date +%Y%m%d-%H%M%S)"
if git tag "$backup_tag" 2>/dev/null; then
ctms_ok "已创建备份 tag: $backup_tag"
ctms_info "如需回滚,执行: git reset --hard $backup_tag"
else
ctms_warn "备份 tag 创建失败(可能当前无提交)"
fi
}
# ── 拉取代码 ─────────────────────────────────
fetch_and_pull() {
ctms_step "拉取远程代码"
local remote="origin"
# 更新或添加 remote
if git remote | grep -q "^${remote}$"; then
git remote set-url "$remote" "$REPO_URL" || ctms_fail "更新远程地址失败"
else
git remote add "$remote" "$REPO_URL" || ctms_fail "添加远程仓库失败"
fi
# Fetch(含 tags
ctms_info "正在从远程仓库获取更新..."
if ! git fetch "$remote" --tags 2>&1 | while IFS= read -r line; do
printf ' %s\n' "$line"
done; then
cleanup_git_credentials
ctms_fail "拉取失败,请检查网络连接和仓库权限"
fi
# 支持分支、标签、SHA — 统一 reset --hard
if git rev-parse --verify "$remote/$GIT_BRANCH" >/dev/null 2>&1; then
ctms_info "切换到远程分支: $GIT_BRANCH"
git reset --hard "$remote/$GIT_BRANCH" || ctms_fail "重置失败"
elif git rev-parse --verify "$GIT_BRANCH" >/dev/null 2>&1; then
ctms_info "切换到引用: $GIT_BRANCH"
git reset --hard "$GIT_BRANCH" || ctms_fail "重置失败"
else
cleanup_git_credentials
ctms_fail "无法解析引用 '$GIT_BRANCH',请确认分支/标签/SHA 正确"
fi
cleanup_git_credentials
ctms_ok "代码拉取完成"
}
# ── 更新确认 ─────────────────────────────────
confirm_update() {
if [[ "$ASSUME_YES" -eq 1 ]]; then
ctms_info "已启用 --yes,跳过确认"
return 0
fi
ctms_step "确认更新参数"
local w=66
local row
row() {
local label="$1" value="$2" vcolor="${3:-$CC_TEXT}"
local padded
padded="$(ctms_pad_right "$label" 14)"
ctms_box_line "${CC_PRIMARY}${C_BOLD}" "$w" \
"${padded}${value}" \
"${CC_MUTED}${padded}${C_RESET}${vcolor}${value}${C_RESET}"
}
printf '\n'
ctms_box_top "${CC_PRIMARY}${C_BOLD}" "更新参数预览" "$w"
row "目标环境" "$TARGET_ENV" "${CC_INFO}${C_BOLD}"
row "仓库地址" "$REPO_URL" "${CC_INFO}"
row "目标引用" "$GIT_BRANCH" "${CC_TEXT}"
row "本地更改" "$([[ "$FORCE_PULL" -eq 1 ]] && echo "强制跳过检查" || echo "检测并确认")" "${CC_TEXT}"
row "备份策略" "$([[ "$SKIP_BACKUP" -eq 1 ]] && echo "跳过" || echo "自动创建 tag")" "${CC_TEXT}"
ctms_box_bottom "${CC_PRIMARY}${C_BOLD}" "$w"
printf '\n %s%s%s 确认以上参数并继续更新?输入 %syes%s 继续: ' \
"${CC_WARN}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "${C_BOLD}" "${C_RESET}"
local answer
read -r answer || answer=""
[[ "$answer" == "yes" ]] || ctms_fail "用户取消更新"
}
# ── 调用安装脚本 ─────────────────────────────
run_install_script() {
ctms_step "执行部署更新"
local install_script="$CTMS_ROOT_DIR/scripts/install.sh"
[[ -x "$install_script" ]] || ctms_fail "安装脚本不可执行: $install_script"
ctms_info "调用安装脚本完成容器更新..."
"$install_script" "$TARGET_ENV" "${INSTALL_ARGS[@]}"
}
# ── 完成提示 ─────────────────────────────────
show_success() {
local w=66
local current_commit
current_commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
printf '\n'
ctms_box_top "${CC_SUCCESS}${C_BOLD}" "$ICON_OK CTMS 更新成功" "$w"
ctms_box_line "${CC_SUCCESS}${C_BOLD}" "$w" \
"$(ctms_pad_right "当前版本" 14)${current_commit}" \
"${CC_MUTED}$(ctms_pad_right "当前版本" 14)${C_RESET}${CC_INFO}${current_commit}${C_RESET}"
ctms_box_line "${CC_SUCCESS}${C_BOLD}" "$w" \
"$(ctms_pad_right "环境" 14)${TARGET_ENV}" \
"${CC_MUTED}$(ctms_pad_right "环境" 14)${C_RESET}${CC_TEXT}${TARGET_ENV}${C_RESET}"
ctms_box_bottom "${CC_SUCCESS}${C_BOLD}" "$w"
printf '\n'
}
# ── 入口 ─────────────────────────────────────
main() {
_banner
parse_args "$@"
cd "$CTMS_ROOT_DIR"
check_git_installed
check_is_git_repo
check_working_directory
resolve_repo_url
setup_git_credentials
pick_remote_ref
confirm_update
# 先验证当前项目身份
verify_project_identity
# 创建备份
create_backup_tag
# 拉取代码
fetch_and_pull
# 拉取后再次验证(防止拉错仓库)
verify_project_identity
# 调用安装脚本完成部署
run_install_script
show_success
}
# 捕获退出信号,清理凭证
trap cleanup_git_credentials EXIT
main "$@"