CaiWuXiTong/scripts/deploy-from-mac.sh
renshen 960d418c8e 发布构建支持指定 Composer 下载源
国内网络访问 GitHub 受限时,composer install 会因 GitHub dist 拉取失败而中断
(api.github.com / codeload.github.com SSL_ERROR_SYSCALL)。

- deploy/Dockerfile 新增可选构建参数 COMPOSER_REPO_PACKAGIST,留空仍用官方源
- macOS 发布脚本默认传腾讯云镜像,可用 CAIWU_COMPOSER_REPO_PACKAGIST 覆盖
2026-09-30 14:07:55 +08:00

675 lines
27 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
#
# macOS 版正式发布:流程与 scripts/deploy-from-workstation.ps1 一致。
# 1. 检查分支与未提交改动;2. 本机构建前端;3. 构建并校验应用镜像;
# 4. 通过 SSH 隧道把新增镜像层推送到服务器本机 Registry;5. 服务器激活并健康检查;
# 6. 校验生产页面脚本与本次构建一致;7. 清理临时文件、隧道与构建缓存。
#
# 用法:bash scripts/deploy-from-mac.sh [选项]
# --server <地址> 默认 218.93.190.75
# --key <私钥路径> 使用 SSH 私钥(可选;不传则用已保存的密码或系统钥匙串)
# --update-password 重新输入并保存服务器密码
# --password-file <文件> 从文件读取服务器密码(文件只允许本人可读)
# --ssh-user <用户> 默认 root
# --remote-path <路径> 默认 /opt/caiwuxitong
# --app-url <地址> 默认 https://caiwu.jiansuntuzhi.cn:8442
# --bootstrap-server 首次初始化服务器
# --configure-dingtalk-callback
# --allow-non-main 允许非 main 分支(测试用)
# --allow-migrations 允许执行待办数据库迁移(需先按 AGENTS.md 核对)
# --skip-regctl 跳过 regctl,直接用 docker 直传镜像
# --keep-images <数量> 默认 2
# --app-version <版本> 默认 3.1.15
#
# 环境变量:CAIWU_COMPOSER_REPO_PACKAGIST 可覆盖 Composer 下载源
# (默认 https://mirrors.cloud.tencent.com/composer/;国内网络访问 GitHub 受限时使用,
# 设为空字符串则使用 Composer 官方源)
#
set -euo pipefail
server='218.93.190.75'
key_path=''
password_file=''
update_password=0
ssh_user='root'
remote_path='/opt/caiwuxitong'
app_url=''
builder_name='caiwu-builder'
buildkit_image='m.daocloud.io/docker.io/moby/buildkit:buildx-stable-1'
database_image='mariadb:11.4'
database_mirror_image='m.daocloud.io/docker.io/library/mariadb:11.4'
keep_images=2
build_cache_max_used_space='24GB'
build_cache_max_age_hours=720
bootstrap_server=0
configure_dingtalk_callback=0
allow_non_main=0
allow_migrations=0
skip_regctl=0
app_version='3.1.15'
# 国内网络访问 GitHub 受限时,composer 的 GitHub dist 会失败;默认走腾讯云镜像。
composer_repo_packagist="${CAIWU_COMPOSER_REPO_PACKAGIST-https://mirrors.cloud.tencent.com/composer/}"
while [ "$#" -gt 0 ]; do
case "$1" in
--server) server="$2"; shift ;;
--key) key_path="$2"; shift ;;
--password-file) password_file="$2"; shift ;;
--update-password) update_password=1 ;;
--ssh-user) ssh_user="$2"; shift ;;
--remote-path) remote_path="$2"; shift ;;
--app-url) app_url="$2"; shift ;;
--builder) builder_name="$2"; shift ;;
--buildkit-image) buildkit_image="$2"; shift ;;
--database-image) database_image="$2"; shift ;;
--database-mirror-image) database_mirror_image="$2"; shift ;;
--keep-images) keep_images="$2"; shift ;;
--build-cache-max-used-space) build_cache_max_used_space="$2"; shift ;;
--build-cache-max-age-hours) build_cache_max_age_hours="$2"; shift ;;
--app-version) app_version="$2"; shift ;;
--bootstrap-server) bootstrap_server=1 ;;
--configure-dingtalk-callback) configure_dingtalk_callback=1 ;;
--allow-non-main) allow_non_main=1 ;;
--allow-migrations) allow_migrations=1 ;;
--skip-regctl) skip_regctl=1 ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) echo "未知参数:$1" >&2; exit 2 ;;
esac
shift
done
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
# 本机私有密码文件(scripts/store-deploy-password.sh 保存的),存在就自动使用。
if [ -z "$password_file" ] && [ -f "$repo_root/runtime/private/deploy-password" ]; then
password_file="$repo_root/runtime/private/deploy-password"
fi
image_name='caiwu_akaunting'
target_platform='linux/amd64'
image_prefix='127.0.0.1:5000/caiwuxitong/app'
keychain_service='CaiWuXiTong-deploy'
keychain_account="$ssh_user@$server"
ssh_target="$ssh_user@$server"
regctl_version='v0.11.5'
temp_root=''
tunnel_pid=''
askpass_dir=''
ssh_control_path=''
remote_archive=''
bootstrap_remote=''
ssh_ready=0
ssh_options=()
tunnel_ssh_options=()
log() { printf '%s\n' "$*"; }
fail() { printf '%s\n' "$*" >&2; exit 1; }
sha256_file() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
sha256sum "$1" | awk '{print $1}'
fi
}
prune_build_cache() {
docker buildx prune --builder "$builder_name" --filter "until=${build_cache_max_age_hours}h" --force >/dev/null 2>&1 || true
local prune_help
prune_help="$(docker buildx prune --help 2>/dev/null || true)"
if printf '%s' "$prune_help" | grep -q -- '--max-used-space'; then
docker buildx prune --builder "$builder_name" --max-used-space "$build_cache_max_used_space" --force >/dev/null 2>&1 || true
elif printf '%s' "$prune_help" | grep -q -- '--keep-storage'; then
docker buildx prune --builder "$builder_name" --keep-storage "$build_cache_max_used_space" --force >/dev/null 2>&1 || true
fi
}
cleanup() {
local status=$?
set +e
if [ -n "$remote_archive" ] && [ "$ssh_ready" -eq 1 ]; then
ssh "${ssh_options[@]}" -o ConnectTimeout=5 "$ssh_target" "rm -f '$remote_archive'" >/dev/null 2>&1
fi
if [ -n "$bootstrap_remote" ] && [ "$ssh_ready" -eq 1 ]; then
ssh "${ssh_options[@]}" -o ConnectTimeout=5 "$ssh_target" "rm -f '$bootstrap_remote'" >/dev/null 2>&1
fi
if [ -n "$tunnel_pid" ]; then
kill "$tunnel_pid" >/dev/null 2>&1
fi
if [ -n "$temp_root" ] && [ -d "$temp_root" ]; then
rm -rf "$temp_root"
fi
if [ -n "$askpass_dir" ] && [ -d "$askpass_dir" ]; then
rm -rf "$askpass_dir"
fi
if [ -n "$ssh_control_path" ] && [ "$ssh_ready" -eq 1 ]; then
ssh "${ssh_options[@]}" -O exit "$ssh_target" >/dev/null 2>&1
rm -f "$ssh_control_path"
fi
prune_build_cache
docker system df >/dev/null 2>&1
exit "$status"
}
trap cleanup EXIT
# ---- 本机登录信息:私钥 > 已保存的钥匙串密码 > 首次输入 ----
prepare_ssh_auth() {
local options=(-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10)
if [ -n "$key_path" ]; then
[ -f "$key_path" ] || fail "找不到私钥文件:$key_path"
options+=(-i "$key_path" -o BatchMode=yes)
ssh_options=("${options[@]}")
return
fi
if ssh "${options[@]}" -o BatchMode=yes "$ssh_target" true >/dev/null 2>&1; then
log '使用本机已有的 SSH 密钥登录服务器。'
ssh_options=("${options[@]}")
return
fi
local password=''
if [ "$update_password" -eq 0 ] && [ -z "$password_file" ] && [ -z "${CAIWU_DEPLOY_PASSWORD:-}" ]; then
password="$(security find-generic-password -s "$keychain_service" -a "$keychain_account" -w 2>/dev/null || true)"
fi
if [ -z "$password" ] && [ -n "$password_file" ]; then
[ -f "$password_file" ] || fail "找不到密码文件:$password_file"
password="$(cat "$password_file")"
fi
if [ -z "$password" ] && [ -n "${CAIWU_DEPLOY_PASSWORD:-}" ]; then
password="$CAIWU_DEPLOY_PASSWORD"
fi
if [ -z "$password" ]; then
if [ "$update_password" -eq 1 ]; then
log '更新服务器登录信息。'
else
log '首次部署:请输入服务器密码,之后会自动使用本机钥匙串里保存的登录信息。'
fi
printf '服务器密码:' >&2
read -rs password
printf '\n' >&2
[ -n "$password" ] || fail '未输入服务器密码,已停止部署。'
# -T /usr/bin/security:把读取权限授予 security 工具本身,避免每次连接都弹钥匙串授权。
security add-generic-password -U -s "$keychain_service" -a "$keychain_account" \
-T /usr/bin/security -w "$password" >/dev/null 2>&1 \
|| log '提示:写入系统钥匙串未成功,本次仍会继续发布。'
else
log '正在使用本机已保存的部署登录信息。'
fi
# 密码只在启动时读取一次,写进一个仅本人可读的临时脚本供 ssh 使用,
# 避免每条 SSH 连接都去读钥匙串而反复弹授权窗口。
askpass_dir="$(mktemp -d)"
chmod 700 "$askpass_dir"
local askpass="$askpass_dir/askpass.sh"
printf '%s' "$password" > "$askpass_dir/password"
chmod 600 "$askpass_dir/password"
printf '%s\n' '#!/bin/sh' "cat '$askpass_dir/password'" > "$askpass"
chmod 700 "$askpass"
export SSH_ASKPASS="$askpass"
export SSH_ASKPASS_REQUIRE=force
export DISPLAY='caiwu-deploy'
options+=(-o BatchMode=no -o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1)
ssh_options=("${options[@]}")
}
scp_to_remote() {
if scp -O "${ssh_options[@]}" "$@" 2>/dev/null; then
return 0
fi
scp "${ssh_options[@]}" "$@"
}
get_regctl_path() {
local tool_root="$HOME/.cache/CaiWuXiTong/tools"
local asset expected_hash arch path
case "$(uname -m)" in
arm64)
asset='regctl-darwin-arm64'
expected_hash='f4d536d64d0c3cc1db7400902175a1c314675991d22e87e15c319501a2676d3f'
;;
x86_64)
asset='regctl-darwin-amd64'
expected_hash='c132fdddda68b9c7584ac19f3b40cd17f71916c2bca8182270ebe65b55198a12'
;;
*) echo "不支持的 macOS 架构:$(uname -m)" >&2; return 1 ;;
esac
path="$tool_root/regctl-$regctl_version"
mkdir -p "$tool_root"
if [ -f "$path" ] && [ "$(sha256_file "$path")" = "$expected_hash" ]; then
chmod +x "$path"
printf '%s' "$path"
return
fi
local download="$path.download"
if ! curl -fsSL --retry 3 --retry-delay 2 -o "$download" \
"https://github.com/regclient/regclient/releases/download/$regctl_version/$asset"; then
echo 'regctl 下载失败。' >&2
return 1
fi
if [ "$(sha256_file "$download")" != "$expected_hash" ]; then
rm -f "$download"
echo 'regctl 校验失败。' >&2
return 1
fi
mv "$download" "$path"
chmod +x "$path"
printf '%s' "$path"
}
pick_free_port() {
if command -v python3 >/dev/null 2>&1; then
python3 - <<'PY'
import socket
s = socket.socket()
s.bind(('127.0.0.1', 0))
print(s.getsockname()[1])
s.close()
PY
return
fi
local port
for port in $(jot -r 20 49152 65535 2>/dev/null || seq 49000 49020); do
if ! nc -z 127.0.0.1 "$port" >/dev/null 2>&1; then
printf '%s' "$port"
return
fi
done
echo '找不到可用的本机端口。' >&2
return 1
}
start_registry_tunnel() {
local local_port="$1"
ssh "${tunnel_ssh_options[@]}" \
-N -o ConnectTimeout=10 -o ExitOnForwardFailure=yes \
-o ServerAliveInterval=15 -o ServerAliveCountMax=20 \
-L "127.0.0.1:${local_port}:127.0.0.1:5000" "$ssh_target" &
tunnel_pid=$!
local attempt=1
while [ "$attempt" -le 20 ]; do
if ! kill -0 "$tunnel_pid" >/dev/null 2>&1; then
tunnel_pid=''
echo 'SSH 增量传输隧道启动失败。' >&2
return 1
fi
if curl --fail --silent --show-error --max-time 1 "http://127.0.0.1:${local_port}/v2/" >/dev/null 2>&1; then
return 0
fi
sleep 0.5
attempt=$((attempt + 1))
done
echo 'SSH 隧道已建立,但无法访问服务器本机 Registry。' >&2
return 1
}
stop_registry_tunnel() {
if [ -n "$tunnel_pid" ]; then
kill "$tunnel_pid" >/dev/null 2>&1 || true
wait "$tunnel_pid" 2>/dev/null || true
tunnel_pid=''
fi
}
ensure_qiniu_storage_runtime() {
local remote_environment="$remote_path/runtime/qiniu-storage.env"
if ssh "${ssh_options[@]}" "$ssh_target" \
"test -f '$remote_environment' && grep -q '^BUSINESS_STORAGE_DRIVER=qiniu$' '$remote_environment' && grep -q '^QINIU_ACCESS_KEY=.' '$remote_environment' && grep -q '^QINIU_SECRET_KEY=.' '$remote_environment'" >/dev/null 2>&1; then
log '服务器私有业务文件存储配置已存在。'
return
fi
local access_key="${QINIU_ACCESS_KEY:-}"
local secret_key="${QINIU_SECRET_KEY:-}"
if [ -z "$access_key" ] || [ -z "$secret_key" ]; then
local credential_file="$repo_root/runtime/qiniu-business-storage.env"
if [ -f "$credential_file" ]; then
access_key="$(grep -E '^[[:space:]]*QINIU_ACCESS_KEY[[:space:]]*=' "$credential_file" | head -1 | sed -E 's/^[^=]*=[[:space:]]*//; s/^["'\'']//; s/["'\'']$//')"
secret_key="$(grep -E '^[[:space:]]*QINIU_SECRET_KEY[[:space:]]*=' "$credential_file" | head -1 | sed -E 's/^[^=]*=[[:space:]]*//; s/^["'\'']//; s/["'\'']$//')"
fi
fi
if [ -z "$access_key" ] || [ -z "$secret_key" ]; then
fail '服务器尚未配置私有业务文件存储,且本机没有可用的七牛凭据。'
fi
printf 'BUSINESS_STORAGE_DRIVER=qiniu\nQINIU_ACCESS_KEY=%s\nQINIU_SECRET_KEY=%s\n' "$access_key" "$secret_key" \
| ssh "${ssh_options[@]}" "$ssh_target" \
"umask 077; cat > '$remote_environment.pending'; chmod 600 '$remote_environment.pending'; mv '$remote_environment.pending' '$remote_environment'" \
|| fail '写入服务器私有业务文件存储配置失败。'
log '服务器私有业务文件存储配置已安全写入。'
}
remove_old_project_images() {
local current_image="$1" candidate keep kept
kept="$current_image"
while IFS= read -r candidate; do
[ -n "$candidate" ] || continue
if [ "$candidate" = '<none>:<none>' ]; then
continue
fi
case " $kept " in *" $candidate "*) continue ;; esac
if [ "$(printf '%s\n' $kept | wc -l | tr -d ' ')" -ge "$keep_images" ]; then
break
fi
kept="$kept $candidate"
done <<EOF
$(docker image ls "$image_name" --format '{{.Repository}}:{{.Tag}}' | sort -u)
EOF
while IFS= read -r candidate; do
[ -n "$candidate" ] || continue
if [ "$candidate" = '<none>:<none>' ]; then
continue
fi
case " $kept " in
*" $candidate "*) ;;
*) docker image rm "$candidate" >/dev/null 2>&1 || true ;;
esac
done <<EOF
$(docker image ls "$image_name" --format '{{.Repository}}:{{.Tag}}' | sort -u)
EOF
}
cd "$repo_root"
branch="$(git branch --show-current | tr -d '[:space:]')"
if [ "$allow_non_main" -eq 0 ] && [ "$branch" != 'main' ]; then
fail "当前分支是 ${branch}。正式发布只允许 main;测试分支需显式添加 --allow-non-main。"
fi
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
fail '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
fi
if [ "$branch" = 'main' ] && git rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
fail '本地 main 与 origin/main 不一致,请先执行 git pull --ff-only 再发布。'
fi
fi
deploy_sha="$(git rev-parse HEAD | tr -d '[:space:]')"
image_tag="$(printf '%s' "$deploy_sha" | cut -c1-12)"
full_image="$image_name:$image_tag"
build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [ -z "$app_url" ]; then
if [ "$server" = '218.93.190.75' ]; then
app_url='https://caiwu.jiansuntuzhi.cn:8442'
else
app_url="https://$server"
fi
fi
docker version >/dev/null 2>&1 || fail 'Docker Desktop 未启动或不可用。'
prepare_ssh_auth
attempt=1
while [ "$attempt" -le 3 ]; do
if ssh "${ssh_options[@]}" "$ssh_target" true >/dev/null 2>&1; then
ssh_ready=1
break
fi
if [ "$attempt" -lt 3 ]; then
log "服务器登录失败,正在重试($attempt/3)。"
sleep 3
fi
attempt=$((attempt + 1))
done
[ "$ssh_ready" -eq 1 ] || fail '服务器登录失败或已取消,已停止部署。'
ssh_control_path="/tmp/caiwu-ssh-$$-$RANDOM.sock"
ssh_options+=(-o ControlMaster=auto -o ControlPersist=600 -o "ControlPath=$ssh_control_path")
tunnel_ssh_options=("${ssh_options[@]}" -o ControlPath=none)
remote_archive="$remote_path/image-$image_tag.tar.gz"
temp_root="$(mktemp -d "${TMPDIR:-/tmp}/caiwu-deploy-XXXXXX")"
local_tar="$temp_root/image.tar"
local_archive="$temp_root/image.tar.gz"
log "发布提交:${deploy_sha}(分支 ${branch})"
bash "$script_dir/build-frontend.sh"
if ! docker buildx inspect "$builder_name" >/dev/null 2>&1; then
docker buildx create \
--name "$builder_name" \
--driver docker-container \
--driver-opt "image=$buildkit_image" \
--buildkitd-config "$repo_root/deploy/buildkitd.toml" \
--use >/dev/null || fail '创建项目专属 BuildKit 构建器失败。'
fi
docker buildx inspect "$builder_name" --bootstrap >/dev/null || fail '启动 BuildKit 构建器失败。'
build_image() {
docker buildx build \
--builder "$builder_name" \
--platform "$target_platform" \
--load \
--tag "$full_image" \
--build-context 'frontend=.caiwu-build/public' \
--build-arg "VCS_REF=$deploy_sha" \
--build-arg "BUILD_DATE=$build_date" \
--build-arg "APP_VERSION=$app_version" \
--build-arg "COMPOSER_REPO_PACKAGIST=$composer_repo_packagist" \
--label 'com.caiwu.project=CaiWuXiTong' \
--file deploy/Dockerfile \
.
}
build_image || fail '本机镜像构建失败。服务器未发生任何变更。'
docker run --rm --network none --user www-data --entrypoint php "$full_image" artisan --version >/dev/null \
|| fail '发布镜像无法引导 Laravel。服务器未发生任何变更。'
for pair in \
"resources/assets/js/components/business/AccountBills.vue:resources/assets/js/components/business/AccountBills.vue" \
".caiwu-build/public/js/business/pages.min.js:public/js/business/pages.min.js" \
".caiwu-build/public/mix-manifest.json:public/mix-manifest.json"; do
local_file="${pair%%:*}"
image_file="${pair##*:}"
expected_hash="$(sha256_file "$repo_root/$local_file")"
image_hash="$(docker run --rm --network none --user www-data --entrypoint sha256sum "$full_image" "$image_file" | awk '{print $1}')"
if [ "$image_hash" != "$expected_hash" ]; then
fail "发布镜像源码或前端资源不匹配:${image_file}。服务器未发生任何变更。"
fi
done
previous_sha="$(ssh "${ssh_options[@]}" "$ssh_target" "cat '$remote_path/.deployed-sha' 2>/dev/null || true" | tr -d '[:space:]')"
if [ -n "$previous_sha" ]; then
if git cat-file -e "${previous_sha}^{commit}" 2>/dev/null; then
previous_sha_exists=1
else
previous_sha_exists=0
fi
if [ "$previous_sha_exists" -eq 0 ] && [ "$allow_migrations" -eq 0 ]; then
fail "本地缺少服务器当前提交 ${previous_sha},无法安全比较数据库迁移。请先 git fetch 并核对全部待执行迁移,按 AGENTS.md 完成备份后使用 --allow-migrations。"
fi
if [ "$previous_sha_exists" -eq 1 ]; then
migration_changes="$(git diff --diff-filter=ACMRTUXB --name-only "$previous_sha..$deploy_sha" -- database/migrations ':(glob)modules/*/Database/Migrations/*.php')"
if [ -n "$migration_changes" ] && [ "$allow_migrations" -eq 0 ]; then
fail "检测到数据库迁移:$(printf '%s' "$migration_changes" | tr '\n' ' ')。由 Codex 核对全部待执行迁移;本任务镜像迁移完成备份后可直接添加 --allow-migrations,其他迁移按 AGENTS.md 执行。"
fi
fi
fi
if [ "$bootstrap_server" -eq 1 ]; then
bootstrap_remote="/tmp/caiwu-bootstrap-$image_tag.sh"
scp_to_remote "$repo_root/deploy/bootstrap-server.sh" "${ssh_target}:$bootstrap_remote" || fail '上传服务器初始化脚本失败。'
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$bootstrap_remote' && '$bootstrap_remote' '$app_url' '$remote_path' && rm -f '$bootstrap_remote'" \
|| fail '服务器初始化失败。'
bootstrap_remote=''
fi
ssh "${ssh_options[@]}" "$ssh_target" "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$remote_path'" \
|| fail '服务器 Docker 未就绪;首次发布请添加 --bootstrap-server。'
ensure_qiniu_storage_runtime
migration_flag=0
if [ "$allow_migrations" -eq 1 ]; then
migration_flag=1
fi
scp_to_remote \
"$repo_root/compose.jiangling.yml" \
"$repo_root/deploy/activate-release.sh" \
"$repo_root/deploy/activate-incremental-release.sh" \
"$repo_root/deploy/config-db-init.sh" \
"$repo_root/deploy/update-runtime-url.sh" \
"$repo_root/deploy/configure-dingtalk-callback.sh" \
"$repo_root/deploy/host-nginx-8442-dingtalk.conf" \
"$repo_root/deploy/setup-incremental-registry.sh" \
"$repo_root/deploy/registry-config.yml" \
"${ssh_target}:$remote_path/" || fail '上传运行配置失败。'
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/update-runtime-url.sh' && '$remote_path/update-runtime-url.sh' '$app_url' '$remote_path'" \
|| fail '更新服务器访问地址失败。'
if [ "$bootstrap_server" -eq 1 ]; then
if ! docker image inspect "$database_image" >/dev/null 2>&1; then
if ! docker image inspect "$database_mirror_image" >/dev/null 2>&1; then
docker pull "$database_mirror_image" || fail '下载数据库运行镜像失败。'
fi
docker tag "$database_mirror_image" "$database_image" || fail '标记数据库运行镜像失败。'
fi
docker save --output "$local_tar" "$full_image" "$database_image" || fail '导出应用与数据库运行镜像失败。'
gzip -1 -c "$local_tar" > "$local_archive"
rm -f "$local_tar"
archive_size="$(du -m "$local_archive" | awk '{print $1}')"
log "首次初始化全量包:${archive_size} MiB"
scp_to_remote "$local_archive" "${ssh_target}:$remote_archive" || fail '上传镜像失败。'
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/'*.sh && chmod 644 '$remote_path/config-db-init.sh' && '$remote_path/activate-release.sh' '$remote_archive' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' && '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml'" \
|| fail '服务器激活、健康检查或增量仓库初始化失败。'
else
ssh "${ssh_options[@]}" -o ConnectTimeout=10 -o ConnectionAttempts=1 "$ssh_target" \
"chmod 700 '$remote_path/'*.sh && chmod 644 '$remote_path/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml')" \
|| fail '服务器增量镜像仓库初始化失败。'
docker buildx build \
--builder "$builder_name" \
--platform "$target_platform" \
--output "type=oci,dest=$local_tar" \
--tag "$full_image" \
--build-context 'frontend=.caiwu-build/public' \
--build-arg "VCS_REF=$deploy_sha" \
--build-arg "BUILD_DATE=$build_date" \
--build-arg "APP_VERSION=$app_version" \
--build-arg "COMPOSER_REPO_PACKAGIST=$composer_repo_packagist" \
--label 'com.caiwu.project=CaiWuXiTong' \
--file deploy/Dockerfile \
. || fail '导出稳定分层的 OCI 应用镜像失败。'
server_registry_image="$image_prefix:$image_tag"
before_bytes="$(ssh "${ssh_options[@]}" "$ssh_target" "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1" | tr -dc '0-9')"
: "${before_bytes:=0}"
upload_start="$(date +%s)"
upload_ok=0
if [ "$skip_regctl" -eq 0 ]; then
if regctl_path="$(get_regctl_path)"; then
if local_registry_port="$(pick_free_port)" && start_registry_tunnel "$local_registry_port"; then
registry_image="127.0.0.1:${local_registry_port}/caiwuxitong/app:$image_tag"
upload_attempt=1
while [ "$upload_attempt" -le 2 ]; do
if [ "$upload_attempt" -gt 1 ]; then
stop_registry_tunnel
local_registry_port="$(pick_free_port)" || break
start_registry_tunnel "$local_registry_port" || break
registry_image="127.0.0.1:${local_registry_port}/caiwuxitong/app:$image_tag"
fi
if "$regctl_path" --host "reg=127.0.0.1:${local_registry_port},tls=disabled" image import "$registry_image" "$local_tar"; then
upload_ok=1
break
fi
if [ "$upload_attempt" -eq 1 ]; then
log '增量镜像隧道在上传期间中断,正在重新建立隧道并重试一次。'
fi
upload_attempt=$((upload_attempt + 1))
done
fi
else
log 'regctl 无法准备(本机安全软件可能拦截了未签名程序)。'
fi
else
log '已指定 --skip-regctl:跳过 regctl,直接用 docker 直传镜像。'
fi
stop_registry_tunnel
if [ "$upload_ok" -eq 0 ]; then
if [ "$skip_regctl" -eq 0 ]; then
log '改用 docker 直传方式上传镜像(与 regctl 增量导入等效,只是传输完整镜像包)。'
fi
rm -f "$local_tar"
docker save --output "$local_tar" "$full_image" || fail '导出应用镜像失败。'
gzip -1 -c "$local_tar" > "$local_archive"
rm -f "$local_tar"
log "docker 直传镜像包:$(du -m "$local_archive" | awk '{print $1}') MiB"
scp_to_remote "$local_archive" "${ssh_target}:$remote_archive" || fail '上传镜像失败。'
ssh "${ssh_options[@]}" "$ssh_target" \
"gunzip -cf '$remote_archive' | docker load && docker tag '$full_image' '$server_registry_image' && docker push '$server_registry_image'" \
|| fail '服务器加载镜像或推入本机 Registry 失败。'
upload_ok=1
fi
[ "$upload_ok" -eq 1 ] || fail '增量镜像层上传失败。'
upload_seconds=$(( $(date +%s) - upload_start ))
after_bytes="$(ssh "${ssh_options[@]}" "$ssh_target" "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1" | tr -dc '0-9')"
: "${after_bytes:=0}"
incremental_mib="$(awk -v a="$after_bytes" -v b="$before_bytes" 'BEGIN { v = (a - b) / 1048576; if (v < 0) v = 0; printf "%.2f", v }')"
log "增量新增镜像层:${incremental_mib} MiB;传输耗时:${upload_seconds} 秒"
ssh "${ssh_options[@]}" "$ssh_target" "'$remote_path/activate-incremental-release.sh' '$server_registry_image' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images'" \
|| fail '服务器增量激活或健康检查失败。'
fi
expected_page_hash="$(sha256_file "$repo_root/.caiwu-build/public/js/business/pages.min.js")"
deployed_page_hash="$(ssh "${ssh_options[@]}" "$ssh_target" "docker exec caiwu_app sha256sum /var/www/html/public/js/business/pages.min.js | cut -d ' ' -f1" | tr -d '[:space:]')"
if [ "$deployed_page_hash" != "$expected_page_hash" ]; then
fail '生产容器中的业务页签脚本与本次构建不一致。'
fi
if [ "$configure_dingtalk_callback" -eq 1 ]; then
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/configure-dingtalk-callback.sh' && '$remote_path/configure-dingtalk-callback.sh' '$server' '$remote_path'" \
|| fail '钉钉 HTTP 回调 Nginx 配置失败。'
fi
remove_old_project_images "$full_image"
log "发布完成:$full_image -> $app_url"