#!/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 核对) # --allow-rollback 允许覆盖不在本次版本里的线上提交(仅负责人决定撤回时使用) # --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 release_includes_commit() { git merge-base --is-ancestor "$1" "$2" 2>/dev/null && return 0 # Squash 改变提交身份;仅接受历史中完整文件树一致的提交。 local previous_tree release_trees previous_tree="$(git rev-parse "${1}^{tree}" 2>/dev/null)" || return 1 release_trees="$(git log --format=%T "$2" 2>/dev/null)" || return 1 printf '%s\n' "$release_trees" | grep -Fx -- "$previous_tree" >/dev/null } 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 allow_rollback=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 ;; --allow-rollback) allow_rollback=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" = ':' ]; 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 </dev/null 2>&1 || true ;; esac done </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) 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" ] && ! printf '%s\n' "$previous_sha" | grep -Eq '^[0-9a-f]{40}$'; then fail '线上版本标记格式无效,已停止发布。' fi expected_previous_sha="${previous_sha:-none}" deploy_actor="$(printf '%s@%s' "$(git config user.name)" "$(hostname)" | tr -c 'A-Za-z0-9_.@:-' '_')" remote_release_path="$remote_path/releases/$image_tag-$(uuidgen | tr 'A-Z' 'a-z')" remote_archive="$remote_release_path/image-$image_tag.tar.gz" if [ -n "$previous_sha" ]; then if git cat-file -e "${previous_sha}^{commit}" 2>/dev/null; then previous_sha_exists=1 else # 线上可能是别人刚发的热修复分支,先拉取再判断。 git fetch origin --quiet 2>/dev/null || true if git cat-file -e "${previous_sha}^{commit}" 2>/dev/null; then previous_sha_exists=1 else previous_sha_exists=0 fi fi # 防覆盖:线上当前版本必须包含在本次要发布的版本里,否则会撤掉别人已上线的改动。 if [ "$allow_rollback" -eq 0 ]; then if [ "$previous_sha_exists" -eq 0 ]; then fail "线上当前版本 ${previous_sha} 在本地和 origin 都找不到(可能是未推送的分支),发布会把它覆盖。请让发布人先推送并合并进 main;确需撤回时显式添加 --allow-rollback。" fi if ! release_includes_commit "$previous_sha" "$deploy_sha"; then fail "线上当前版本($(git log -1 --format='%h %an %ad %s' --date=format:'%m-%d %H:%M' "$previous_sha"))不在本次发布的版本里,继续发布会把它及之后别人上线的改动撤掉。请先 git fetch 并把最新 main(或线上热修复分支)合入当前分支后重试;确需撤回时显式添加 --allow-rollback。" fi 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' '$remote_release_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/release-guard.sh" \ "$repo_root/deploy/retired-activation.sh" \ "$repo_root/deploy/install-release-protection.sh" \ "$repo_root/deploy/config-db-init.sh" \ "$repo_root/deploy/run-finance-batch.sh" \ "$repo_root/deploy/clickhouse-resource-policy.xml" \ "$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_release_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_release_path/'*.sh && '$remote_release_path/activate-release.sh' '$remote_archive' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' '$expected_previous_sha' '$deploy_actor' '$app_url' && '$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_release_path/'*.sh && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remote_release_path/setup-incremental-registry.sh' '$remote_path' '$remote_release_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_release_path/activate-incremental-release.sh' '$server_registry_image' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' '$expected_previous_sha' '$deploy_actor' '$app_url'" \ || 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"