#!/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:'): # build_number 由版本号公式派生(major*10000+minor*100+patch),与 pubspec # 的 CFBundleVersion(BUILD)同源、天然单调。旧法「读仓库 version.yaml +1」有 bug: # 仓库那份从不回写、永远冻结 → 每次都算出同一个值(1.1.9/1.1.10 均为 5)。 try: _mj, _mn, _pt = (int(x) for x in ver.split('.')[:3]) b = _mj * 10000 + _mn * 100 + _pt 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 <