Skip to content

OPT: 查词归一化统一为单一 normalizeWord 路径 #72

OPT: 查词归一化统一为单一 normalizeWord 路径

OPT: 查词归一化统一为单一 normalizeWord 路径 #72

Workflow file for this run

name: Release
# Tag 触发自动发布:iOS 上传 App Store Connect、Android 上传 Cloudflare R2、
# 同时把 APK 作为 GitHub Release 附件挂出(IPA 不公开)。
#
# 触发:
# - push tag 形如 v1.0.11(推荐,新格式)或 v1.0.8+1(兼容旧格式)
# - 或在 Actions 页面手动 dispatch 选 tag
#
# 版本号约定:
# - tag 用纯 SemVer(v1.0.11),不带 +N
# - versionCode 由 CI 自动用 git rev-list --count <tag> 生成,全局单调递增
# - 详见 CLAUDE.md "Android versionCode 单调递增" 章节
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. v1.0.11)'
required: true
env:
FLUTTER_VERSION: '3.41.5'
jobs:
release:
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
platform: [ios, android]
include:
- platform: ios
runner: macos-latest
- platform: android
runner: ubuntu-latest
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
cache-key: flutter-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
- name: Select latest Xcode (iOS only)
if: matrix.platform == 'ios'
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Get dependencies
run: flutter pub get
- name: Parse version from tag
id: ver
env:
TAG_REF: ${{ github.event.inputs.tag || github.ref_name }}
run: |
# 兼容新格式 vX.Y.Z 和旧格式 vX.Y.Z+N
if [[ "$TAG_REF" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
NAME="${BASH_REMATCH[1]}"
# versionCode = 该 tag 指向 commit 的 git rev-list count
NUMBER="$(git rev-list --count "$TAG_REF")"
elif [[ "$TAG_REF" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)$ ]]; then
NAME="${BASH_REMATCH[1]}"
NUMBER="${BASH_REMATCH[2]}"
else
echo "ERROR: tag '$TAG_REF' is not in vX.Y.Z or vX.Y.Z+N format" >&2
exit 1
fi
if [[ -z "$NUMBER" || "$NUMBER" -le 0 ]]; then
echo "ERROR: invalid build number ($NUMBER) for tag $TAG_REF" >&2
exit 1
fi
# 校验 versionCode 严格大于所有历史 tag 的 versionCode
# 防止人为意外打了倒退的 +N 旧格式 tag
git fetch --tags --force
MAX_CODE=0
for t in $(git tag -l 'v*'); do
if [[ "$t" == "$TAG_REF" ]]; then continue; fi
if [[ "$t" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)$ ]]; then
CODE="${BASH_REMATCH[2]}"
elif [[ "$t" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
CODE="$(git rev-list --count "$t" 2>/dev/null || echo 0)"
else
continue
fi
if [[ "$CODE" -gt "$MAX_CODE" ]]; then MAX_CODE="$CODE"; fi
done
if [[ "$NUMBER" -le "$MAX_CODE" ]]; then
echo "ERROR: versionCode $NUMBER must be strictly greater than max existing $MAX_CODE" >&2
echo "如需重新构建已发布的 tag,请在最新 commit 上加空 commit 后再打新 tag" >&2
exit 1
fi
# 校验 pubspec.yaml 版本号一致
PUBSPEC_VERSION="$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1)"
if [[ "$NAME" != "$PUBSPEC_VERSION" ]]; then
echo "ERROR: tag version ($NAME) does not match pubspec.yaml ($PUBSPEC_VERSION)" >&2
echo "请先升级 pubspec.yaml 的 version 字段,再打 tag。" >&2
exit 1
fi
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "number=$NUMBER" >> "$GITHUB_OUTPUT"
echo "version=${NAME}+${NUMBER}" >> "$GITHUB_OUTPUT"
echo "Parsed: name=$NAME number=$NUMBER (pubspec OK, max existing code=$MAX_CODE)"
# ---------- Android ----------
- name: Setup Java
if: matrix.platform == 'android'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Restore Android signing config
if: matrix.platform == 'android'
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
run: |
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks
cat > android/key.properties <<EOF
storePassword=$ANDROID_KEYSTORE_PASSWORD
keyPassword=$ANDROID_KEY_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
storeFile=../app/upload-keystore.jks
EOF
- name: Build Android APK
if: matrix.platform == 'android'
env:
BUILD_NAME: ${{ steps.ver.outputs.name }}
BUILD_NUMBER: ${{ steps.ver.outputs.number }}
API_BASE_URL: ${{ vars.API_BASE_URL }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
POSTHOG_API_KEY: ${{ vars.POSTHOG_API_KEY }}
SUPABASE_URL: ${{ vars.SUPABASE_URL }}
SUPABASE_PUBLISHABLE_KEY: ${{ vars.SUPABASE_PUBLISHABLE_KEY }}
GOOGLE_WEB_CLIENT_ID: ${{ vars.GOOGLE_WEB_CLIENT_ID }}
run: |
flutter clean
DART_DEFINES=(
"--dart-define=API_BASE_URL=${API_BASE_URL}"
"--dart-define=POSTHOG_HOST=${POSTHOG_HOST}"
"--dart-define=SUPABASE_URL=${SUPABASE_URL}"
"--dart-define=SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}"
"--dart-define=GOOGLE_WEB_CLIENT_ID=${GOOGLE_WEB_CLIENT_ID}"
)
if [[ -n "$POSTHOG_API_KEY" ]]; then
DART_DEFINES+=("--dart-define=POSTHOG_API_KEY=${POSTHOG_API_KEY}")
fi
flutter build apk --release --flavor=prod \
--target-platform android-arm64 \
--build-name="$BUILD_NAME" \
--build-number="$BUILD_NUMBER" \
"${DART_DEFINES[@]}"
mkdir -p build/release
# 文件名只用 versionName。versionCode 已经隐藏在 APK 元数据里,
# 对外分发不需要暴露。同 versionName 重发会覆盖。
cp build/app/outputs/flutter-apk/app-prod-release.apk \
"build/release/Echo-Loop-${BUILD_NAME}-arm64.apk"
- name: Upload APK to Cloudflare R2
if: matrix.platform == 'android'
env:
BUILD_NAME: ${{ steps.ver.outputs.name }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ vars.R2_BUCKET }}
run: |
: "${R2_BUCKET:=public}"
APK="build/release/Echo-Loop-${BUILD_NAME}-arm64.apk"
aws s3 cp "$APK" "s3://${R2_BUCKET}/android/$(basename "$APK")" \
--endpoint-url "$R2_ENDPOINT" --region auto \
--content-type "application/vnd.android.package-archive"
aws s3 cp "$APK" "s3://${R2_BUCKET}/android/Echo-Loop-latest.apk" \
--endpoint-url "$R2_ENDPOINT" --region auto \
--content-type "application/vnd.android.package-archive"
- name: Upload APK artifact
if: matrix.platform == 'android'
uses: actions/upload-artifact@v4
with:
name: android-apk
path: build/release/Echo-Loop-*-arm64.apk
retention-days: 30
# Google Play 只接受 AAB 作为上架包(APK 仅用于 R2/GitHub 直装分发)。
# 单独构建一份 prod release AAB,签名沿用 gradle 中 prod 的 release 配置。
- name: Build Android App Bundle (AAB)
if: matrix.platform == 'android'
env:
BUILD_NAME: ${{ steps.ver.outputs.name }}
BUILD_NUMBER: ${{ steps.ver.outputs.number }}
API_BASE_URL: ${{ vars.API_BASE_URL }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
POSTHOG_API_KEY: ${{ vars.POSTHOG_API_KEY }}
SUPABASE_URL: ${{ vars.SUPABASE_URL }}
SUPABASE_PUBLISHABLE_KEY: ${{ vars.SUPABASE_PUBLISHABLE_KEY }}
GOOGLE_WEB_CLIENT_ID: ${{ vars.GOOGLE_WEB_CLIENT_ID }}
run: |
DART_DEFINES=(
"--dart-define=API_BASE_URL=${API_BASE_URL}"
"--dart-define=POSTHOG_HOST=${POSTHOG_HOST}"
"--dart-define=SUPABASE_URL=${SUPABASE_URL}"
"--dart-define=SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}"
"--dart-define=GOOGLE_WEB_CLIENT_ID=${GOOGLE_WEB_CLIENT_ID}"
)
if [[ -n "$POSTHOG_API_KEY" ]]; then
DART_DEFINES+=("--dart-define=POSTHOG_API_KEY=${POSTHOG_API_KEY}")
fi
# abiFilters 已在 gradle 限定 arm64,AAB 同样只含 arm64
flutter build appbundle --release --flavor=prod \
--build-name="$BUILD_NAME" \
--build-number="$BUILD_NUMBER" \
"${DART_DEFINES[@]}"
- name: Upload AAB to Google Play (internal track)
if: matrix.platform == 'android'
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
packageName: app.echoloop
releaseFiles: build/app/outputs/bundle/prodRelease/app-prod-release.aab
track: internal
status: completed
# ---------- iOS ----------
- name: Import distribution certificate to keychain
if: matrix.platform == 'ios'
env:
IOS_DIST_CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }}
IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
run: |
KEYCHAIN_PATH="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PWD="$(openssl rand -base64 24)"
CERT_PATH="$RUNNER_TEMP/dist_cert.p12"
echo "$IOS_DIST_CERT_BASE64" | base64 -d > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$IOS_DIST_CERT_PASSWORD" \
-A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PWD" "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain
echo "Available signing identities:"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
- name: Install provisioning profile
if: matrix.platform == 'ios'
env:
IOS_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
PROFILES_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PROFILES_DIR"
echo "$IOS_PROVISIONING_PROFILE_BASE64" | base64 -d \
> "$PROFILES_DIR/profile.mobileprovision"
ls -la "$PROFILES_DIR"
- name: Install App Store Connect API Key
if: matrix.platform == 'ios'
env:
APP_STORE_API_KEY_BASE64: ${{ secrets.APP_STORE_API_KEY_BASE64 }}
APP_STORE_API_KEY_ID: ${{ vars.APP_STORE_API_KEY_ID }}
run: |
KEYS_DIR="$HOME/.appstoreconnect/private_keys"
mkdir -p "$KEYS_DIR"
echo "$APP_STORE_API_KEY_BASE64" | base64 -d \
> "$KEYS_DIR/AuthKey_${APP_STORE_API_KEY_ID}.p8"
- name: CocoaPods install
if: matrix.platform == 'ios'
run: cd ios && pod install
- name: Generate ExportOptions.plist
if: matrix.platform == 'ios'
env:
IOS_TEAM_ID: ${{ vars.IOS_TEAM_ID }}
IOS_PROVISIONING_PROFILE_NAME: ${{ vars.IOS_PROVISIONING_PROFILE_NAME }}
run: |
cat > "$RUNNER_TEMP/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>destination</key>
<string>export</string>
<key>manageAppVersionAndBuildNumber</key>
<false/>
<key>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
<key>top.echo-loop</key>
<string>${IOS_PROVISIONING_PROFILE_NAME}</string>
</dict>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string>${IOS_TEAM_ID}</string>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
EOF
plutil -lint "$RUNNER_TEMP/ExportOptions.plist"
- name: Build iOS app (no codesign)
if: matrix.platform == 'ios'
env:
BUILD_NAME: ${{ steps.ver.outputs.name }}
BUILD_NUMBER: ${{ steps.ver.outputs.number }}
API_BASE_URL: ${{ vars.API_BASE_URL }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
POSTHOG_API_KEY: ${{ vars.POSTHOG_API_KEY }}
SUPABASE_URL: ${{ vars.SUPABASE_URL }}
SUPABASE_PUBLISHABLE_KEY: ${{ vars.SUPABASE_PUBLISHABLE_KEY }}
GOOGLE_WEB_CLIENT_ID: ${{ vars.GOOGLE_WEB_CLIENT_ID }}
run: |
# 避免 Homebrew rsync 抢占(Apple toolchain 需要系统 rsync)
export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
DART_DEFINES=(
"--dart-define=API_BASE_URL=${API_BASE_URL}"
"--dart-define=POSTHOG_HOST=${POSTHOG_HOST}"
"--dart-define=SUPABASE_URL=${SUPABASE_URL}"
"--dart-define=SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}"
"--dart-define=GOOGLE_WEB_CLIENT_ID=${GOOGLE_WEB_CLIENT_ID}"
)
if [[ -n "$POSTHOG_API_KEY" ]]; then
DART_DEFINES+=("--dart-define=POSTHOG_API_KEY=${POSTHOG_API_KEY}")
fi
# 先用 flutter 编译 dart 代码,--no-codesign 跳过签名
flutter build ios --release --no-codesign --flavor=prod \
--build-name="$BUILD_NAME" \
--build-number="$BUILD_NUMBER" \
"${DART_DEFINES[@]}"
- name: Patch Runner pbxproj for manual signing
if: matrix.platform == 'ios'
env:
IOS_TEAM_ID: ${{ vars.IOS_TEAM_ID }}
IOS_PROVISIONING_PROFILE_NAME: ${{ vars.IOS_PROVISIONING_PROFILE_NAME }}
run: |
# 用 xcodeproj gem 精确修改 Runner target 的 Release-prod 配置。
# 不动 Pods.xcodeproj 和其他 target/config,避免污染。
# CocoaPods 依赖 xcodeproj gem,macos-latest 自带 ruby + cocoapods,gem 已就绪。
gem list xcodeproj | grep -q xcodeproj || sudo gem install xcodeproj
ruby <<'RBEOF'
require 'xcodeproj'
project = Xcodeproj::Project.open('ios/Runner.xcodeproj')
profile = ENV.fetch('IOS_PROVISIONING_PROFILE_NAME')
team = ENV.fetch('IOS_TEAM_ID')
patched = 0
project.targets.each do |target|
next unless target.name == 'Runner'
target.build_configurations.each do |config|
next unless config.name == 'Release-prod'
config.build_settings['CODE_SIGN_STYLE'] = 'Manual'
config.build_settings['DEVELOPMENT_TEAM'] = team
config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution'
config.build_settings['CODE_SIGN_IDENTITY[sdk=iphoneos*]'] = 'Apple Distribution'
config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = profile
puts "Patched #{target.name}/#{config.name}"
patched += 1
end
end
project.save
raise "Did not find Runner/Release-prod" if patched == 0
RBEOF
- name: Archive iOS app
if: matrix.platform == 'ios'
run: |
export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
# 签名配置已写进 Runner pbxproj,命令行不传签名 args 避免污染 Pods
xcodebuild \
-workspace ios/Runner.xcworkspace \
-scheme prod \
-configuration Release-prod \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Runner.xcarchive" \
archive
- name: Export IPA from archive
if: matrix.platform == 'ios'
run: |
export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
mkdir -p build/ios/ipa
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Runner.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \
-exportPath "$(pwd)/build/ios/ipa"
- name: Upload IPA to App Store Connect
if: matrix.platform == 'ios'
env:
APP_STORE_API_KEY_ID: ${{ vars.APP_STORE_API_KEY_ID }}
APP_STORE_API_ISSUER_ID: ${{ vars.APP_STORE_API_ISSUER_ID }}
run: |
set -o pipefail
IPA="$(find build/ios/ipa -maxdepth 1 -name '*.ipa' | head -n 1)"
[[ -n "$IPA" ]] || { echo "No IPA produced" >&2; exit 1; }
echo "Uploading: $IPA"
# altool 在某些失败场景(如 bundle version 重复)会打印 ERROR 但仍以 0 退出。
# 这里把 stdout/stderr 都收下并 tee 到日志,结束后扫描 ERROR 关键字强制失败。
LOG="$RUNNER_TEMP/altool-upload.log"
set +e
xcrun altool --upload-app --type ios --file "$IPA" \
--apiKey "$APP_STORE_API_KEY_ID" \
--apiIssuer "$APP_STORE_API_ISSUER_ID" \
--p8-file-path "$HOME/.appstoreconnect/private_keys/AuthKey_${APP_STORE_API_KEY_ID}.p8" \
--show-progress 2>&1 | tee "$LOG"
STATUS=${PIPESTATUS[0]}
set -e
if [[ $STATUS -ne 0 ]]; then
echo "::error::altool exited with status $STATUS" >&2
exit "$STATUS"
fi
if grep -E '^[0-9-]+ [0-9:.]+ ERROR' "$LOG" >/dev/null; then
echo "::error::altool reported ERROR despite exit 0 — failing step" >&2
grep -E '^[0-9-]+ [0-9:.]+ ERROR' "$LOG" >&2 || true
exit 1
fi
publish:
needs: release
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- name: Download APK artifact
uses: actions/download-artifact@v4
with:
name: android-apk
path: ./artifacts
- name: Create GitHub Draft Release
# 先发草稿,避免 iOS 还在 App Store 审核时 Android 这边就显示成"已正式发布"。
# 等 App Store 审核通过并上线后,手动到 GitHub Releases 页面点 Publish;
# 或用 `gh release edit <tag> --draft=false` 转正。
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
files: ./artifacts/Echo-Loop-*-arm64.apk
generate_release_notes: true
fail_on_unmatched_files: true
draft: true
- name: Prune old draft releases (keep latest 3)
# 仅清理 draft,不会动已发布的 release。按 created_at 倒序保留最近 3 个,其余删除。
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
KEEP: 3
run: |
set -e
mapfile -t OLD < <(
gh api --paginate \
-H "Accept: application/vnd.github+json" \
"/repos/${GH_REPO}/releases" \
--jq '[.[] | select(.draft==true)] | sort_by(.created_at) | reverse | .['"$KEEP"':][] | .id'
)
if [[ ${#OLD[@]} -eq 0 ]]; then
echo "No stale draft releases to prune."
exit 0
fi
echo "Will delete ${#OLD[@]} old draft release(s): ${OLD[*]}"
for id in "${OLD[@]}"; do
gh api -X DELETE "/repos/${GH_REPO}/releases/${id}"
echo "Deleted draft release id=${id}"
done