feat(release): 本地发版脚本 local-release.sh(绕过不稳的 CI 隧道)

在本机完成整套客户端发版:本地代码、本地编译签名、二进制暂存 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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-20 10:51:12 +08:00
parent bec45c282f
commit b5715aec8d
+506
View File
@@ -0,0 +1,506 @@
#!/usr/bin/env bash
# local-release.sh <version> [--only web,macos,android,ios,windows,deploy,testflight]
#
# 本地发版:在这台 mac 上完成一次完整客户端发版——本地代码、本地编译签名、
# 二进制暂存 dist/、直接部署到 ali+ iOS 上 TestFlight)。
# 取代 CI 的 deploy-client.ymlrunner→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.p8key-id=3PZTHR8YMJ
# Android 签名 : Bitwarden 条目 "android signing jiu"rbw 取,解码成临时 keystore + 临时 key.properties
# Windows : GitHub Release winstage-v<VER>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<VER>(供手动回滚重取 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 镜像源等共享 envGOPROXY / 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 <version> [--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_IDApp 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,再签 apphardened 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 <submission-id> --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 "未找到 rbwBitwarden 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" <<EOF
storeFile=${KEYSTORE}
storePassword=${ks_pw}
keyAlias=${key_alias}
keyPassword=${key_pw}
EOF
( cd client && flutter build apk --release "${DEFINES[@]}" )
local APK=""
for cand in \
client/build/app/outputs/flutter-apk/app-release.apk \
client/build/app/outputs/apk/release/app-release.apk; do
[ -f "$cand" ] && { APK="$cand"; break; }
done
[ -n "$APK" ] || die "找不到构建产物 APK"
cp "$APK" "$DIST/jiu-android.apk"
log "build_android: 产物 $DIST/jiu-android.apk ($(du -sh "$DIST/jiu-android.apk" | cut -f1))"
# trap 会清掉 key.properties + 临时 keystore
}
# iOSmanual 签名(App Store),用钥匙串已有 Apple Distribution 证书 + 已装 "Jiu App Store" profile。
# 不导入任何 base64(证书/profile 本机已有)。产出 IPA 供 upload_testflight 用。
build_ios() {
log "build_ios: Flutter IPAapp-storemanual 签名,版本 ${VER} build ${BUILD}"
sync_pubspec_version
# 自动发现已装的 App Store 分发 profileAppID=BUNDLE_ID、无 ProvisionedDevices
local PROFILE_NAME=""
local pdir="$HOME/Library/MobileDevice/Provisioning Profiles"
if [ -d "$pdir" ]; then
local p plist name appid
for p in "$pdir"/*.mobileprovision; do
[ -e "$p" ] || continue
plist="$(security cms -D -i "$p" 2>/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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store</string>
<key>teamID</key><string>${IOS_TEAM_ID}</string>
<key>signingStyle</key><string>manual</string>
<key>uploadBitcode</key><false/>
<key>uploadSymbols</key><true/>
<key>provisioningProfiles</key>
<dict>
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
</dict>
</dict>
</plist>
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.ipaTestFlight 上传见 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_<id>.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.yamlversion/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"
}
# 部署到 aliweb 解到 /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 <<EOF
下一步(可选):空闲、网络稳时把本次产物补传成 Forgejo Release(供手动回滚重取 manifest):
source scripts/ci/lib-forgejo.sh
# 需 export FORGEJO_URL / FORGEJO_TOKEN / GITEA_REPOSITORY
create_release "$TAG" '"local release $TAG"'
upload_asset dist/web.tar.gz; upload_asset dist/version.yaml
upload_asset dist/jiu-macos-x64.zip; upload_asset dist/jiu-android.apk; upload_asset dist/jiu-windows-x64-setup.exe
EOF