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:
Executable
+506
@@ -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.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
|
||||
Reference in New Issue
Block a user