b5715aec8d
在本机完成整套客户端发版:本地代码、本地编译签名、二进制暂存 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>
507 lines
22 KiB
Bash
Executable File
507 lines
22 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# local-release.sh <version> [--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<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 镜像源等共享 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 <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_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 <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 "未找到 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" <<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
|
||
}
|
||
|
||
# iOS:manual 签名(App Store),用钥匙串已有 Apple Distribution 证书 + 已装 "Jiu App Store" profile。
|
||
# 不导入任何 base64(证书/profile 本机已有)。产出 IPA 供 upload_testflight 用。
|
||
build_ios() {
|
||
log "build_ios: Flutter IPA(app-store,manual 签名,版本 ${VER} build ${BUILD})"
|
||
sync_pubspec_version
|
||
|
||
# 自动发现已装的 App Store 分发 profile(AppID=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.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_<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.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 <<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
|