Files
jiu/scripts/local-release.sh
T
wangjia b5715aec8d 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>
2026-07-20 10:51:12 +08:00

507 lines
22 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
# 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