From b5715aec8d1687a8d2a840d8edabaaed35147493 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 20 Jul 2026 10:51:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(release):=20=E6=9C=AC=E5=9C=B0=E5=8F=91?= =?UTF-8?q?=E7=89=88=E8=84=9A=E6=9C=AC=20local-release.sh(=E7=BB=95?= =?UTF-8?q?=E8=BF=87=E4=B8=8D=E7=A8=B3=E7=9A=84=20CI=20=E9=9A=A7=E9=81=93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在本机完成整套客户端发版:本地代码、本地编译签名、二进制暂存 dist/、直接 部署 ali(+iOS TestFlight)。取代 deploy-client.yml 在 mac-runner→NAS gitea 隧道反复 git checkout 失败的问题。 - 阶段:web/macos(Developer ID+公证+staple)/android(rbw keystore)/ ios(app-store)/windows(拉 GitHub 云构建)/deploy(version.yaml+scp ali)/testflight - 密钥全走本机(钥匙串+rbw+~/.appstoreconnect+ssh 别名 ali),不用 CI 的 base64→临时 keychain - --only 可选阶段;敏感临时物(keystore/key.properties)mktemp+trap 清理,不入库 - 实测:1.1.9 --only web,macos,android,windows,deploy 全过,已上线 ali Co-Authored-By: Claude Opus 4.8 --- scripts/local-release.sh | 506 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 506 insertions(+) create mode 100755 scripts/local-release.sh diff --git a/scripts/local-release.sh b/scripts/local-release.sh new file mode 100755 index 0000000..1c13fca --- /dev/null +++ b/scripts/local-release.sh @@ -0,0 +1,506 @@ +#!/usr/bin/env bash +# local-release.sh [--only web,macos,android,ios,windows,deploy,testflight] +# +# 本地发版:在这台 mac 上完成一次完整客户端发版——本地代码、本地编译签名、 +# 二进制暂存 dist/、直接部署到 ali(+ iOS 上 TestFlight)。 +# 取代 CI 的 deploy-client.yml(runner→NAS gitea 隧道不稳、git checkout 反复失败)。 +# +# 用法: +# sh scripts/local-release.sh 1.1.9 # 全跑(各端编译签名 → version.yaml → 部署 ali + TestFlight) +# sh scripts/local-release.sh 1.1.9 --only web # 只 build web(最快,验证脚本用) +# sh scripts/local-release.sh 1.1.9 --only web,android # 只跑指定平台 +# sh scripts/local-release.sh 1.1.9 --only deploy # 只部署(用 dist/ 已有产物 + 重生成 version.yaml) +# sh scripts/local-release.sh 1.1.9 --only ios,testflight +# +# 阶段名:web macos android ios windows deploy testflight +# - build_* 各端编译 + 分发级签名 → dist/ +# - windows 从 GitHub Release 拉已构建的安装器(本机不编 Windows) +# - deploy 生成 version.yaml + scp/ssh 到 ali(/opt/jiu) +# - testflight 用 build_ios 产出的 IPA 上传 App Store Connect +# 默认(不带 --only)顺序:web → macos → android → ios → windows → deploy → testflight +# +# ── 密钥来源(本机,全部本地,不走 CI 的 base64→临时 keychain 那套)── +# macOS 分发签名 : 钥匙串已有 "Developer ID Application: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)" +# iOS 分发签名 : 钥匙串已有 "Apple Distribution: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)" + 已装 "Jiu App Store" profile +# 公证/上传 key : ~/.appstoreconnect/private_keys/AuthKey_3PZTHR8YMJ.p8(key-id=3PZTHR8YMJ) +# Android 签名 : Bitwarden 条目 "android signing jiu"(rbw 取,解码成临时 keystore + 临时 key.properties) +# Windows : GitHub Release winstage-v(gh auth token) +# 部署 : ~/.ssh/config 的 `ali` 别名(密钥直连),非 CI 的 ALI_SSH_KEY +# +# 前提:`rbw unlock` 已解锁金库;PATH 含 /opt/homebrew/bin(脚本会自动补)。 +# +# 安全:keystore / key.properties / ExportOptions 等临时敏感物一律放 mktemp 目录,trap 清理, +# 绝不写进仓库或提交。 +# +# gitea 上传:本脚本【不做】。空闲时再用 scripts/ci/lib-forgejo.sh 的 create_release/upload_asset +# 把 dist/ 的产物 + version.yaml 补传成 Forgejo Release client-v(供手动回滚重取 manifest)。 +set -euo pipefail + +# 本脚本用 bash 数组/进程替换;若被 macOS 的 /bin/sh(bash --posix) 或 dash 调起, +# 先重执行到普通 bash(须是第一条可执行语句,见 local_test.sh 同款处理)。 +_posix=no +case ":${SHELLOPTS:-}:" in *:posix:*) _posix=yes ;; esac +if [ -z "${BASH_VERSION:-}" ] || [ "$_posix" = yes ]; then + exec bash "$0" "$@" +fi +unset _posix +set -euo pipefail + +export PATH="/opt/homebrew/bin:$PATH" + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +# CI 镜像源等共享 env(GOPROXY / PUB_HOSTED_URL / FLUTTER_STORAGE_BASE_URL) +# shellcheck source=scripts/ci/_env.sh +. "$REPO_ROOT/scripts/ci/_env.sh" + +# ─────────────────────────── 常量(本地密钥来源)──────────────────────────── +BASE_URL="https://jiu.51yanmei.com" +DOWNLOADS_BASE="${BASE_URL}/downloads" + +# macOS 分发签名身份(钥匙串已有;直接用全名匹配) +MACOS_IDENTITY="Developer ID Application: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)" + +# App Store Connect API key(公证 macOS + 上传 iOS TestFlight 共用;p8 已在本机) +ASC_KEY_ID="3PZTHR8YMJ" +ASC_KEY_P8="$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" + +# ⚠️ TODO(填我): App Store Connect Issuer ID —— 公证 + TestFlight 上传都需要。 +# 本机 Bitwarden(jiu_ios_keys)/keychain/config 里都没找到;到 +# App Store Connect → Users and Access → Integrations → App Store Connect API +# 顶部复制 "Issuer ID"(UUID,形如 69a6de70-xxxx-...),填到下面,或运行前 export ISSUER_ID=... +# 只有跑 macos / ios / testflight 阶段才需要;web/android/deploy 不需要。 +ISSUER_ID="${ISSUER_ID:-}" + +# iOS Team / bundle id +IOS_TEAM_ID="BYL4KQHMTN" +BUNDLE_ID="com.yanmei.jiu" + +# 部署目标:mac 的 ~/.ssh/config 的 `ali` 别名(密钥直连,User=root) +DEPLOY_SSH="ali" +REMOTE_ROOT="/opt/jiu" + +DIST="$REPO_ROOT/dist" + +# ─────────────────────────────── 参数解析 ────────────────────────────────── +VERSION="" +ONLY="" +for a in "$@"; do + case "$a" in + --only) : ;; # 兼容 `--only web`(值在下一个参数,见下) + --only=*) ONLY="${a#--only=}" ;; + -h|--help) grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//' | head -40; exit 0 ;; + -*) echo "!! 未知参数:$a" >&2; exit 2 ;; + *) if [ -z "$VERSION" ]; then VERSION="$a"; elif [ -z "$ONLY" ]; then ONLY="$a"; fi ;; + esac +done +# 支持空格分隔的 `--only web,android` +_prev="" +for a in "$@"; do + if [ "$_prev" = "--only" ]; then ONLY="$a"; fi + _prev="$a" +done + +if [ -z "$VERSION" ]; then + echo "!! 用法:sh scripts/local-release.sh [--only web,macos,android,ios,windows,deploy,testflight]" >&2 + echo " 例:sh scripts/local-release.sh 1.1.9" >&2 + exit 2 +fi +case "$VERSION" in + [0-9]*.[0-9]*.[0-9]*) : ;; + *) echo "!! 版本号格式应为 X.Y.Z(如 1.1.9),收到:$VERSION" >&2; exit 2 ;; +esac + +TAG="client-v${VERSION}" +VER="$VERSION" +# versionCode / iOS build 号:单调递增,由版本号推导(与 CI 一致:major*10000+minor*100+patch) +MAJOR="${VER%%.*}"; _rest="${VER#*.}"; MINOR="${_rest%%.*}"; PATCH="${VER##*.}" +BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) + +DEFINES=( + "--dart-define=BASE_URL=${BASE_URL}" + "--dart-define=PUBLIC_URL=${BASE_URL}" + "--dart-define=APP_VERSION=v${VER}" +) + +# 决定要跑的阶段 +if [ -n "$ONLY" ]; then + STAGES="$(printf '%s' "$ONLY" | tr ',' ' ')" +else + STAGES="web macos android ios windows deploy testflight" +fi + +_has_stage() { printf ' %s ' "$STAGES" | grep -q " $1 "; } + +log() { printf '\033[1;36m==> %s\033[0m\n' "$*"; } +warn() { printf '\033[1;33m!! %s\033[0m\n' "$*" >&2; } +die() { printf '\033[1;31mXX %s\033[0m\n' "$*" >&2; exit 1; } + +mkdir -p "$DIST" + +# 只在需要签名/公证/上传时才强制要求 ISSUER_ID +require_issuer_id() { + [ -n "$ISSUER_ID" ] || die "缺少 ISSUER_ID(App Store Connect Issuer ID)。到 App Store Connect → Users and Access → Integrations 复制,填进脚本顶部 ISSUER_ID= 或 export ISSUER_ID=... 后重试。" + [ -f "$ASC_KEY_P8" ] || die "缺少 App Store Connect 私钥 $ASC_KEY_P8" +} + +# 同步 pubspec 版本号(+BUILD,单调;各 build 阶段都调用,幂等) +_synced_pubspec=no +sync_pubspec_version() { + [ "$_synced_pubspec" = yes ] && return 0 + log "同步 pubspec 版本:${VER}+${BUILD}" + sed -i '' "s/^version:.*/version: ${VER}+${BUILD}/" client/pubspec.yaml + _synced_pubspec=yes +} + +# ═════════════════════════════════ 平台函数 ═════════════════════════════════ + +build_web() { + log "build_web: Flutter Web(版本 ${VER})" + sync_pubspec_version + ( cd client && flutter build web --release \ + --base-href=/app/ "${DEFINES[@]}" ) + tar -czf "$DIST/web.tar.gz" -C client/build web + log "build_web: 产物 $DIST/web.tar.gz ($(du -sh "$DIST/web.tar.gz" | cut -f1))" +} + +build_macos() { + log "build_macos: Flutter macOS + Developer ID 签名 + 公证 + staple(版本 ${VER})" + require_issuer_id + sync_pubspec_version + + # 快速失败:签名身份必须在钥匙串 + security find-identity -v -p codesigning | grep -qF "$MACOS_IDENTITY" \ + || die "钥匙串未找到签名身份:$MACOS_IDENTITY" + + ( cd client && flutter build macos --release "${DEFINES[@]}" ) + local APP_SRC="client/build/macos/Build/Products/Release/jiu_client.app" + [ -d "$APP_SRC" ] || die "找不到构建产物 $APP_SRC" + + local WORK; WORK="$(mktemp -d)" + # shellcheck disable=SC2064 + trap "rm -rf '$WORK'" RETURN + + # inside-out 签名:先签嵌套 framework/dylib,再签 app(hardened runtime + 安全时间戳) + log "build_macos: 代码签名" + if [ -d "${APP_SRC}/Contents/Frameworks" ]; then + find "${APP_SRC}/Contents/Frameworks" \( -name '*.framework' -o -name '*.dylib' \) -print0 \ + | xargs -0 -I{} codesign --force --options runtime --timestamp --sign "$MACOS_IDENTITY" {} + fi + codesign --force --options runtime --timestamp \ + --entitlements client/macos/Runner/Release.entitlements \ + --sign "$MACOS_IDENTITY" "${APP_SRC}" + codesign --verify --deep --strict --verbose=2 "${APP_SRC}" + + # 公证(本地 p8,阻塞等待) + log "build_macos: 提交公证(notarytool,阻塞等待)" + ditto -c -k --keepParent "${APP_SRC}" "$WORK/notarize.zip" + xcrun notarytool submit "$WORK/notarize.zip" \ + --key "$ASC_KEY_P8" --key-id "$ASC_KEY_ID" --issuer "$ISSUER_ID" --wait \ + || die "公证失败:xcrun notarytool log --key $ASC_KEY_P8 --key-id $ASC_KEY_ID --issuer $ISSUER_ID 看详情" + + xcrun stapler staple "${APP_SRC}" + xcrun stapler validate "${APP_SRC}" + spctl -a -vvv -t install "${APP_SRC}" || true # 期望 source=Notarized Developer ID + + # 打包(ditto 保留符号链接/权限/签名扩展属性;勿用 python zipfile) + ditto -c -k --keepParent "${APP_SRC}" "$DIST/jiu-macos-x64.zip" + log "build_macos: 产物 $DIST/jiu-macos-x64.zip ($(du -sh "$DIST/jiu-macos-x64.zip" | cut -f1))" +} + +build_android() { + log "build_android: Flutter APK + release 签名(版本 ${VER} build ${BUILD})" + sync_pubspec_version + + # 从 Bitwarden 取签名材料(字段名见条目 "android signing jiu") + command -v rbw >/dev/null || die "未找到 rbw(Bitwarden CLI)" + rbw unlocked >/dev/null 2>&1 || die "Bitwarden 金库未解锁,先 rbw unlock" + local ks_b64 ks_pw key_pw key_alias + ks_b64="$(rbw get --field ANDROID_KEYSTORE_BASE64 'android signing jiu')" + ks_pw="$(rbw get --field ANDROID_KEYSTORE_PASSWORD 'android signing jiu')" + key_pw="$(rbw get --field ANDROID_KEY_PASSWORD 'android signing jiu')" + key_alias="$(rbw get --field ANDROID_KEY_ALIAS 'android signing jiu')" + [ -n "$ks_b64" ] || die "rbw 取 ANDROID_KEYSTORE_BASE64 为空" + + local WORK; WORK="$(mktemp -d)" + local KS_PROPS="client/android/key.properties" + # 临时 keystore + key.properties 都要清(key.properties 在仓库目录内,务必清) + # shellcheck disable=SC2064 + trap "rm -rf '$WORK'; rm -f '$REPO_ROOT/$KS_PROPS'" RETURN + + local KEYSTORE="$WORK/jiu-release.jks" + printf '%s' "$ks_b64" | base64 --decode > "$KEYSTORE" + # build.gradle.kts 期望字段:storeFile / storePassword / keyAlias / keyPassword + cat > "$KS_PROPS" </dev/null)" || continue + appid="$(printf '%s' "$plist" | /usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' /dev/stdin 2>/dev/null || true)" + # App Store profile:无 ProvisionedDevices + if [ "$appid" = "${IOS_TEAM_ID}.${BUNDLE_ID}" ] \ + && ! printf '%s' "$plist" | /usr/libexec/PlistBuddy -c 'Print :ProvisionedDevices' /dev/stdin >/dev/null 2>&1; then + name="$(printf '%s' "$plist" | /usr/libexec/PlistBuddy -c 'Print :Name' /dev/stdin 2>/dev/null || true)" + [ -n "$name" ] && { PROFILE_NAME="$name"; break; } + fi + done + fi + [ -n "$PROFILE_NAME" ] || PROFILE_NAME="Jiu App Store" # 已知已装 profile 名 + log "build_ios: 使用 provisioning profile '${PROFILE_NAME}'" + + local WORK; WORK="$(mktemp -d)" + # shellcheck disable=SC2064 + trap "rm -rf '$WORK'" RETURN + + cat > "$WORK/ExportOptions.plist" < + + + + methodapp-store + teamID${IOS_TEAM_ID} + signingStylemanual + uploadBitcode + uploadSymbols + provisioningProfiles + + ${BUNDLE_ID}${PROFILE_NAME} + + + +EOF + + ( cd client && flutter build ipa --release \ + --build-name="${VER}" \ + --build-number="${BUILD}" \ + --export-options-plist="$WORK/ExportOptions.plist" \ + "${DEFINES[@]}" ) + + local IPA; IPA="$(ls client/build/ios/ipa/*.ipa 2>/dev/null | head -1 || true)" + [ -n "$IPA" ] && [ -f "$IPA" ] || die "找不到 IPA 产物(client/build/ios/ipa/*.ipa)" + cp "$IPA" "$DIST/jiu-ios.ipa" + log "build_ios: 产物 $DIST/jiu-ios.ipa(TestFlight 上传见 upload_testflight)" +} + +upload_testflight() { + log "upload_testflight: 上传 IPA 到 App Store Connect(TestFlight)" + require_issuer_id + local IPA="$DIST/jiu-ios.ipa" + [ -f "$IPA" ] || IPA="$(ls client/build/ios/ipa/*.ipa 2>/dev/null | head -1 || true)" + [ -n "$IPA" ] && [ -f "$IPA" ] || die "找不到 IPA,先跑 build_ios" + # altool 会从 ~/.appstoreconnect/private_keys/AuthKey_.p8 自动读取私钥 + xcrun altool --upload-app -f "$IPA" -t ios \ + --apiKey "$ASC_KEY_ID" --apiIssuer "$ISSUER_ID" \ + || die "TestFlight 上传失败" + log "upload_testflight: 完成,build ${BUILD} 已上传" +} + +fetch_windows() { + log "fetch_windows: 从 GitHub Release 拉 Windows 安装器(winstage-v${VER})" + local tok; tok="$(gh auth token 2>/dev/null || true)" + [ -n "$tok" ] || die "gh 未登录(gh auth login),无法拉 Windows 包" + GH_TOKEN="$tok" GH_REPO="${GH_REPO:-bj-wangjia/jiu}" \ + bash "$REPO_ROOT/scripts/ci/fetch-windows-staged.sh" "$TAG" + log "fetch_windows: 产物 $DIST/jiu-windows-x64-setup.exe" +} + +# 生成/更新 backend/config/version.yaml(复用 release-client.sh 的 python;跳过 gitea Release)。 +# download URLs 指 https://jiu.51yanmei.com/downloads/...;追加最近 3 条 changelog。 +ensure_version_yaml() { + [ -f "$DIST/version.yaml" ] && [ "${1:-}" != force ] && return 0 + log "version.yaml: 更新 backend/config/version.yaml(version/build_number/urls/changelog)" + local NOTES + NOTES="$(python3 - "CHANGELOG-client.md" <<'PYEOF' +import re, sys +fn = sys.argv[1] +try: + content = open(fn, encoding='utf-8').read() + parts = re.split(r'\n## ', '\n' + content) + if len(parts) > 1: + lines = parts[1].strip().split('\n') + notes = [l.strip() for l in lines[1:] if l.strip() and not l.strip().startswith('## ')] + # 到下一个 ## 为止 + out=[] + for l in lines[1:]: + s=l.strip() + if s.startswith('## '): break + if s: out.append(s) + print('\n'.join(out) if out else lines[0]) + else: + print('') +except Exception: + print('') +PYEOF +)" + log "version.yaml: release_notes=${NOTES}" + + python3 - "$VER" "$NOTES" "CHANGELOG-client.md" "$DOWNLOADS_BASE" <<'PYEOF' +import sys, re, json +ver, notes, changelog_file, base = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] +mac_url = f"{base}/jiu-macos-x64.zip" +win_url = f"{base}/jiu-windows-x64-setup.exe" +android_url = f"{base}/jiu-android.apk" + +with open('backend/config/version.yaml', encoding='utf-8') as f: + raw = f.read() +raw = re.split(r'\nchangelog:', raw, maxsplit=1)[0].rstrip('\n') + '\n' + +out = [] +for line in raw.splitlines(keepends=True): + if line.startswith('version:'): + out.append('version: "%s"\n' % ver) + elif line.startswith('build_number:'): + try: + b = int(line.split(':', 1)[1].strip()) + 1 + except Exception: + b = 1 + out.append('build_number: %d\n' % b) + elif line.startswith('release_notes:'): + safe = notes.replace('\\', '\\\\').replace('"', '\\"').replace('\n', ';') + out.append('release_notes: "%s"\n' % safe) + elif line.startswith(' macos:'): + out.append(' macos: "%s"\n' % mac_url) + elif line.startswith(' windows:'): + out.append(' windows: "%s"\n' % win_url) + elif line.startswith(' android:'): + out.append(' android: "%s"\n' % android_url) + else: + out.append(line) + +versions = [] +cur = None; sec = None +for line in open(changelog_file, encoding='utf-8'): + line = line.rstrip('\n') + m = re.match(r'^## \[([^\]]+)\] - (\d{4}-\d{2}-\d{2})', line) + if m: + if cur: versions.append(cur) + if len(versions) >= 3: + cur = None; break + cur = {'version': m.group(1), 'date': m.group(2), 'intro': '', 'sections': []} + sec = None; continue + ms = re.match(r'^### (.+)', line) + if ms and cur is not None: + sec = {'type': ms.group(1).strip(), 'items': []} + cur['sections'].append(sec); continue + mi = re.match(r'^- (.+)', line) + if mi and cur is not None and sec is not None: + sec['items'].append(mi.group(1).strip()); continue + if line.strip() and cur is not None and sec is None and not line.startswith('#'): + cur['intro'] = (cur['intro'] + ' ' + line.strip()).strip() +if cur is not None and len(versions) < 3: + versions.append(cur) + +text = ''.join(out).rstrip('\n') + '\n' +text += 'changelog: ' + json.dumps(versions, ensure_ascii=False) + '\n' +with open('backend/config/version.yaml', 'w', encoding='utf-8') as f: + f.write(text) +print('version.yaml updated to', ver, '|', len(versions), 'changelog entries') +PYEOF + + cp backend/config/version.yaml "$DIST/version.yaml" + log "version.yaml: 暂存 $DIST/version.yaml" +} + +# 部署到 ali:web 解到 /opt/jiu/web、version.yaml 到 /opt/jiu/config、下载包到 /opt/jiu/downloads。 +# 用 ~/.ssh/config 的 `ali` 别名直连(非 CI 部署 key)。不重启后端(version.yaml 按请求读)。 +deploy_ali() { + log "deploy_ali: 部署到 ${DEPLOY_SSH}:${REMOTE_ROOT}" + ensure_version_yaml force + + [ -f "$DIST/web.tar.gz" ] || die "缺 $DIST/web.tar.gz,先跑 build_web(或 --only web,deploy)" + [ -f "$DIST/version.yaml" ] || die "缺 $DIST/version.yaml" + + log "deploy_ali: dist/ 内容:" + ls -lh "$DIST" + + local STAGE; STAGE="$(mktemp -d)" + # shellcheck disable=SC2064 + trap "rm -rf '$STAGE'" RETURN + mkdir -p "$STAGE/jiu-web-new" + tar -xzf "$DIST/web.tar.gz" -C "$STAGE/jiu-web-new" --strip-components=1 + + log "deploy_ali: 上传 web + version.yaml + 安装包" + # web 目录用 rsync(走 ssh 别名);--delete 保证原子替换后无残留 + rsync -az --delete -e "ssh" "$STAGE/jiu-web-new/" "${DEPLOY_SSH}:/tmp/jiu-web-new/" + scp "$DIST/version.yaml" "${DEPLOY_SSH}:/tmp/version.yaml" + [ -f "$DIST/jiu-macos-x64.zip" ] && scp "$DIST/jiu-macos-x64.zip" "${DEPLOY_SSH}:/tmp/jiu-macos-x64.zip" || true + [ -f "$DIST/jiu-android.apk" ] && scp "$DIST/jiu-android.apk" "${DEPLOY_SSH}:/tmp/jiu-android.apk" || true + [ -f "$DIST/jiu-windows-x64-setup.exe" ] && scp "$DIST/jiu-windows-x64-setup.exe" "${DEPLOY_SSH}:/tmp/jiu-windows-x64-setup.exe" || true + + log "deploy_ali: 远端就位(切 web / version.yaml / downloads)" + ssh "${DEPLOY_SSH}" 'bash -s' <<'ENDSSH' +set -e +# version.yaml(后端按请求读,无需重启) +mkdir -p /opt/jiu/config +cp /tmp/version.yaml /opt/jiu/config/version.yaml + +# 原子切换 Flutter web +rm -rf /opt/jiu/web-old +mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true +mv /tmp/jiu-web-new /opt/jiu/web + +# 发布安装器到 nginx 的 downloads 目录,只留最新 +mkdir -p /opt/jiu/downloads +rm -f /opt/jiu/downloads/jiu-windows-* /opt/jiu/downloads/jiu-macos-* /opt/jiu/downloads/jiu-android-* /opt/jiu/downloads/jiu-android.apk 2>/dev/null || true +[ -f /tmp/jiu-macos-x64.zip ] && mv -f /tmp/jiu-macos-x64.zip /opt/jiu/downloads/ || true +[ -f /tmp/jiu-android.apk ] && mv -f /tmp/jiu-android.apk /opt/jiu/downloads/ || true +[ -f /tmp/jiu-windows-x64-setup.exe ] && mv -f /tmp/jiu-windows-x64-setup.exe /opt/jiu/downloads/ || true + +echo "Client deploy complete!" +ENDSSH + log "deploy_ali: 完成" +} + +# ═════════════════════════════════ 主流程 ══════════════════════════════════ +log "本地发版 ${TAG}(版本 ${VER} build ${BUILD})|阶段:${STAGES}" + +_has_stage web && build_web +_has_stage macos && build_macos +_has_stage android && build_android +_has_stage ios && build_ios +_has_stage windows && fetch_windows +_has_stage deploy && deploy_ali +_has_stage testflight && upload_testflight + +log "全部完成。dist/ 产物:" +ls -lh "$DIST" 2>/dev/null || true +cat <