From c941dcf032ef385f87af44481be6d7d05c5119db Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 29 Jul 2026 20:01:36 +0800 Subject: [PATCH] feat: separate store delivery from releases --- .github/workflows/mobile-build.yml | 54 +----- .github/workflows/store-delivery.yml | 231 +++++++++++++++++++++++ apps/mobile/eas.json | 30 +++ docs/mobile-build.md | 35 ++-- docs/release.md | 7 + docs/release.zh-CN.md | 5 + docs/store-delivery.md | 103 ++++++++++ docs/store-delivery.zh-CN.md | 95 ++++++++++ package.json | 3 +- scripts/release.mjs | 2 + scripts/store-delivery.mjs | 144 ++++++++++++++ scripts/store-delivery.test.mjs | 45 +++++ scripts/validate-store-delivery.mjs | 131 +++++++++++++ scripts/validate-store-delivery.test.mjs | 61 ++++++ 14 files changed, 876 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/store-delivery.yml create mode 100644 docs/store-delivery.md create mode 100644 docs/store-delivery.zh-CN.md create mode 100644 scripts/store-delivery.mjs create mode 100644 scripts/store-delivery.test.mjs create mode 100644 scripts/validate-store-delivery.mjs create mode 100644 scripts/validate-store-delivery.test.mjs diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml index f7a6f241..2f0448e0 100644 --- a/.github/workflows/mobile-build.yml +++ b/.github/workflows/mobile-build.yml @@ -4,8 +4,8 @@ on: workflow_dispatch: inputs: release_tag: - description: "Upload the signed arm64 APK to this existing GitHub Release (optional)" - required: false + description: "Upload the signed arm64 APK to this existing Draft GitHub Release" + required: true type: string release: types: [published] @@ -33,7 +33,7 @@ jobs: github.repository == 'tianma-if/edgeever' && ( github.event_name == 'release' || - (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + github.event_name == 'workflow_dispatch' ) }} name: Plan Android release asset @@ -49,7 +49,7 @@ jobs: fetch-depth: 0 - name: Validate Draft Release target - if: github.event_name == 'workflow_dispatch' && inputs.release_tag != '' + if: github.event_name == 'workflow_dispatch' env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.release_tag }} @@ -123,7 +123,6 @@ jobs: ${{ github.repository == 'tianma-if/edgeever' && github.event_name == 'workflow_dispatch' && - inputs.release_tag != '' && needs.release-plan.result == 'success' && needs.release-plan.outputs.asset_ready == 'false' && needs.release-plan.outputs.rebuild == 'false' @@ -155,10 +154,8 @@ jobs: github.repository == 'tianma-if/edgeever' && ( (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'workflow_dispatch' && inputs.release_tag == '') || ( github.event_name == 'workflow_dispatch' && - inputs.release_tag != '' && needs.release-plan.result == 'success' && needs.release-plan.outputs.asset_ready == 'false' && needs.release-plan.outputs.rebuild == 'true' @@ -206,17 +203,8 @@ jobs: printf '%s' "$ANDROID_KEYSTORE_BASE64" | openssl base64 -d -A > "$RUNNER_TEMP/edgeever-upload.p12" chmod 600 "$RUNNER_TEMP/edgeever-upload.p12" - - name: Build signed release AAB - if: github.event_name == 'workflow_dispatch' && inputs.release_tag == '' - env: - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/edgeever-upload.p12 - ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} - ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - run: bun run build:android:play - - name: Build signed release APK for GitHub Release - if: github.event_name == 'workflow_dispatch' && inputs.release_tag != '' + if: github.event_name == 'workflow_dispatch' env: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/edgeever-upload.p12 @@ -225,23 +213,12 @@ jobs: EDGE_EVER_ANDROID_APK_ARCHS: arm64-v8a run: bun run build:android:apk - - name: Verify release AAB signature - if: github.event_name == 'workflow_dispatch' && inputs.release_tag == '' - run: | - "$JAVA_HOME/bin/jarsigner" -verify app/build/outputs/bundle/release/*.aab - working-directory: apps/mobile/android - - name: Verify release APK signature - if: github.event_name == 'workflow_dispatch' && inputs.release_tag != '' + if: github.event_name == 'workflow_dispatch' run: | "$ANDROID_SDK_ROOT/build-tools/36.0.0/apksigner" verify --verbose app/build/outputs/apk/release/app-release.apk working-directory: apps/mobile/android - - name: Verify R8 deobfuscation mapping - if: github.event_name == 'workflow_dispatch' && inputs.release_tag == '' - run: test -s app/build/outputs/mapping/release/mapping.txt - working-directory: apps/mobile/android - - name: Upload APK if: github.event_name == 'push' uses: actions/upload-artifact@v7 @@ -251,17 +228,8 @@ jobs: if-no-files-found: error compression-level: 0 - - name: Upload release AAB - if: github.event_name == 'workflow_dispatch' && inputs.release_tag == '' - uses: actions/upload-artifact@v7 - with: - name: edgeever-android-release-aab - path: apps/mobile/android/app/build/outputs/bundle/release/*.aab - if-no-files-found: error - compression-level: 0 - - name: Upload release APK to GitHub Release - if: github.event_name == 'workflow_dispatch' && inputs.release_tag != '' + if: github.event_name == 'workflow_dispatch' env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.release_tag }} @@ -270,14 +238,6 @@ jobs: cp apps/mobile/android/app/build/outputs/apk/release/app-release.apk "$release_asset" gh release upload "$RELEASE_TAG" "$release_asset" --repo "$GITHUB_REPOSITORY" --clobber - - name: Upload R8 deobfuscation mapping - if: github.event_name == 'workflow_dispatch' && inputs.release_tag == '' - uses: actions/upload-artifact@v7 - with: - name: edgeever-android-release-mapping - path: apps/mobile/android/app/build/outputs/mapping/release/mapping.txt - if-no-files-found: error - - name: Remove temporary upload keystore if: always() && github.event_name == 'workflow_dispatch' run: rm -f "$RUNNER_TEMP/edgeever-upload.p12" diff --git a/.github/workflows/store-delivery.yml b/.github/workflows/store-delivery.yml new file mode 100644 index 00000000..4e2518af --- /dev/null +++ b/.github/workflows/store-delivery.yml @@ -0,0 +1,231 @@ +name: Deliver Mobile Stores + +on: + workflow_dispatch: + inputs: + release_tag: + description: "Formal GitHub Release tag to deliver (vX.Y.Z)" + required: true + type: string + platform: + description: "Store platform" + required: true + default: both + type: choice + options: + - both + - android + - ios + android_track: + description: "Google Play track (production is uploaded as a Draft)" + required: true + default: internal + type: choice + options: + - internal + - alpha + - beta + - production + production_confirmation: + description: "For Google Play production, type the exact release tag" + required: false + default: "" + type: string + +permissions: + contents: read + +concurrency: + group: edgeever-store-delivery-${{ inputs.release_tag }} + cancel-in-progress: false + +jobs: + plan: + if: github.repository == 'tianma-if/edgeever' + name: Validate store delivery source + runs-on: ubuntu-latest + outputs: + version: ${{ steps.validate.outputs.version }} + version_code: ${{ steps.validate.outputs.version_code }} + steps: + - name: Check out the immutable Release tag + uses: actions/checkout@v5 + with: + ref: ${{ inputs.release_tag }} + fetch-depth: 0 + + - name: Validate formal Release and resolve its predecessor + id: release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + release="$( + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" + )" + test "$(jq -r '.draft' <<<"$release")" = "false" + test "$(jq -r '.prerelease' <<<"$release")" = "false" + test "$(jq -r '.tag_name' <<<"$release")" = "$RELEASE_TAG" + + release_target="$(jq -r '.target_commitish' <<<"$release")" + test "$(git rev-parse "${RELEASE_TAG}^{commit}")" = "$(git rev-parse "${release_target}^{commit}")" + + mapfile -t release_tags < <( + gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ + --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name' + ) + previous_tag="" + current_found="false" + for candidate in "${release_tags[@]}"; do + if [[ "$current_found" = "true" ]]; then + previous_tag="$candidate" + break + fi + if [[ "$candidate" = "$RELEASE_TAG" ]]; then + current_found="true" + fi + done + test "$current_found" = "true" + test -n "$previous_tag" + git fetch origin "refs/tags/${previous_tag}:refs/tags/${previous_tag}" + echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" + + - name: Validate mobile version and audited change range + id: validate + env: + ANDROID_TRACK: ${{ inputs.android_track }} + PLATFORM: ${{ inputs.platform }} + PREVIOUS_TAG: ${{ steps.release.outputs.previous_tag }} + PRODUCTION_CONFIRMATION: ${{ inputs.production_confirmation }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + node scripts/validate-store-delivery.mjs \ + "$RELEASE_TAG" \ + "$PREVIOUS_TAG" \ + "$PLATFORM" \ + "$ANDROID_TRACK" \ + "$PRODUCTION_CONFIRMATION" | + tee -a "$GITHUB_OUTPUT" + + android: + needs: plan + if: inputs.platform == 'android' || inputs.platform == 'both' + name: Deliver Google Play + runs-on: [self-hosted, macOS, ARM64, edgeever-mobile] + timeout-minutes: 60 + environment: ${{ inputs.android_track == 'production' && 'store-production' || 'store-delivery' }} + env: + ANDROID_HOME: /opt/homebrew/share/android-commandlinetools + ANDROID_SDK_ROOT: /opt/homebrew/share/android-commandlinetools + JAVA_HOME: /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home + steps: + - name: Check out the immutable Release tag + uses: actions/checkout@v5 + with: + ref: ${{ inputs.release_tag }} + clean: true + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Restore Android upload keystore + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + test -n "$ANDROID_KEYSTORE_BASE64" + printf '%s' "$ANDROID_KEYSTORE_BASE64" | openssl base64 -d -A > "$RUNNER_TEMP/edgeever-upload.p12" + chmod 600 "$RUNNER_TEMP/edgeever-upload.p12" + + - name: Build and verify signed Play bundle + env: + ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/edgeever-upload.p12 + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: bun run build:android:play + + - name: Preserve Play bundle and deobfuscation mapping + uses: actions/upload-artifact@v7 + with: + name: edgeever-${{ inputs.release_tag }}-google-play + path: | + apps/mobile/android/app/build/outputs/bundle/release/app-release.aab + apps/mobile/android/app/build/outputs/mapping/release/mapping.txt + if-no-files-found: error + compression-level: 0 + + - name: Upload bundle to Google Play + env: + ANDROID_TRACK: ${{ inputs.android_track }} + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + test -n "$EXPO_TOKEN" + bunx eas-cli submit \ + --platform android \ + --profile "store-${ANDROID_TRACK}" \ + --path android/app/build/outputs/bundle/release/app-release.aab \ + --non-interactive \ + --wait + working-directory: apps/mobile + + - name: Remove temporary upload keystore + if: always() + run: rm -f "$RUNNER_TEMP/edgeever-upload.p12" + + ios: + needs: plan + if: inputs.platform == 'ios' || inputs.platform == 'both' + name: Deliver App Store Connect + runs-on: ubuntu-latest + timeout-minutes: 90 + environment: store-delivery + steps: + - name: Check out the immutable Release tag + uses: actions/checkout@v5 + with: + ref: ${{ inputs.release_tag }} + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build the iOS archive on EAS + id: build + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + test -n "$EXPO_TOKEN" + build_json="$( + bunx eas-cli build \ + --platform ios \ + --profile production \ + --message "Store delivery ${RELEASE_TAG}" \ + --freeze-credentials \ + --non-interactive \ + --wait \ + --json + )" + build_id="$(jq -r 'if type == "array" then .[0].id else .id end' <<<"$build_json")" + test -n "$build_id" + test "$build_id" != "null" + echo "build_id=$build_id" >> "$GITHUB_OUTPUT" + working-directory: apps/mobile + + - name: Upload archive to App Store Connect and TestFlight + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + bunx eas-cli submit \ + --platform ios \ + --profile store-ios \ + --id "${{ steps.build.outputs.build_id }}" \ + --non-interactive \ + --wait + working-directory: apps/mobile diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index fdedbcf2..21575abc 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -25,6 +25,36 @@ "ios": { "ascAppId": "6792625631" } + }, + "store-internal": { + "android": { + "track": "internal", + "releaseStatus": "completed" + } + }, + "store-alpha": { + "android": { + "track": "alpha", + "releaseStatus": "completed" + } + }, + "store-beta": { + "android": { + "track": "beta", + "releaseStatus": "completed" + } + }, + "store-production": { + "android": { + "track": "production", + "releaseStatus": "draft", + "changesNotSentForReview": true + } + }, + "store-ios": { + "ios": { + "ascAppId": "6792625631" + } } } } diff --git a/docs/mobile-build.md b/docs/mobile-build.md index 595ef931..183006b5 100644 --- a/docs/mobile-build.md +++ b/docs/mobile-build.md @@ -86,26 +86,18 @@ app unless the key reset has been completed in Play Console. Set `EDGE_EVER_ANDROID_ENV_FILE` to use a different secure environment file. -### GitHub Actions fallback +### Automated store delivery -Run the workflow manually to build a signed Android App Bundle. The workflow -uses the following GitHub Actions secrets: +The GitHub Release workflow never builds or uploads a Play bundle. After a +formal Release that contains mobile changes is published, use the separate +store-delivery workflow. It builds a signed AAB from the immutable Release tag, +preserves the AAB and R8 mapping as Actions artifacts, and uploads the bundle +through EAS Submit. See [Mobile Store Delivery](store-delivery.md). -```text -ANDROID_KEYSTORE_BASE64 -ANDROID_KEYSTORE_PASSWORD -ANDROID_KEY_ALIAS -ANDROID_KEY_PASSWORD -``` - -The resulting app bundle is uploaded as `edgeever-android-release-aab`. Release -builds enable R8 code minification and resource shrinking, and the matching -deobfuscation file is uploaded as `edgeever-android-release-mapping`. Upload -that `mapping.txt` alongside the same app bundle version in Google Play Console -so production crash and ANR stack traces can be decoded correctly. The upload -keystore is only used to prove ownership when uploading bundles; Google Play -App Signing manages the app signing key delivered to users. Keep an encrypted -backup of the upload keystore and its credentials outside the repository. +The upload keystore is only used to prove ownership when uploading bundles; +Google Play App Signing manages the app signing key delivered to users. Keep an +encrypted backup of the upload keystore and its credentials outside the +repository. ### iOS App Store build @@ -122,10 +114,9 @@ bunx eas-cli build --platform ios --profile production The first command requires the Apple Account Holder to authenticate and may prompt for two-factor authentication. The production profile automatically -increments the App Store build number. After the build has succeeded, submit -the selected build with `bunx eas-cli submit --platform ios --profile -production`, or configure `submit.production.ios.ascAppId` and use -`--auto-submit` on subsequent releases. Apple credentials, App Store Connect +increments the App Store build number. Routine delivery should use the separate +store-delivery workflow, which builds from an immutable formal Release tag and +uploads to App Store Connect/TestFlight. Apple credentials, App Store Connect API keys, certificates, and provisioning profiles must never be committed. ## EAS diff --git a/docs/release.md b/docs/release.md index 350bd8c4..b1a85fd7 100644 --- a/docs/release.md +++ b/docs/release.md @@ -5,6 +5,10 @@ the repository, creates the tracking Issue, updates versions, prepares and audit native assets in a Draft Release, publishes the Release, and installs the final macOS DMG. +This process does not perform mobile store delivery. Google Play and App Store +Connect delivery is a separate, explicit operation documented in +[Mobile Store Delivery](store-delivery.md). + ## Prerequisites - Run the command on macOS from a clean `main` branch that exactly matches @@ -51,6 +55,9 @@ maintainer releases should install and launch the published application. 8. Link and close the tracking Issue, download the final DMG, verify its checksum and signature, replace `/Applications/EdgeEver.app`, and launch it. +No Release step builds a Play AAB, starts an EAS iOS build, or uploads to a +mobile store. + The release version commit includes the standard GitHub Actions skip marker. Draft asset workflows are dispatched explicitly, so this avoids an unnecessary push-triggered Android build without skipping required release builds. diff --git a/docs/release.zh-CN.md b/docs/release.zh-CN.md index 24eb5902..88205b62 100644 --- a/docs/release.zh-CN.md +++ b/docs/release.zh-CN.md @@ -4,6 +4,9 @@ EdgeEver 使用一条本地命令准备 Release。该命令会检查仓库、创 更新版本、在 Draft Release 中准备并审计原生资产、正式发布 Release,并安装最终 的 macOS DMG。 +该流程不执行移动端商店交付。Google Play 和 App Store Connect 交付是独立且 +需要显式触发的操作,详见[移动端商店交付](store-delivery.zh-CN.md)。 + ## 前置条件 - 在 macOS 上执行,当前必须是干净的 `main` 分支,并且与 `origin/main` 完全一致。 @@ -45,6 +48,8 @@ GitHub 状态。`--skip-install` 会跳过发布后的 DMG 安装,仅适用于 8. 回链并关闭跟踪 Issue,下载最终 DMG,验证校验和与签名,覆盖 `/Applications/EdgeEver.app` 并启动。 +Release 流程不会构建 Play AAB、启动 EAS iOS Build,也不会上传到移动端商店。 + 版本发布提交会包含 GitHub Actions 的标准跳过标记。Draft 资产工作流由脚本 显式触发,因此可以避免一次不必要的 push 触发 Android 构建,同时不会跳过 正式发布所需的构建。 diff --git a/docs/store-delivery.md b/docs/store-delivery.md new file mode 100644 index 00000000..d6ff09dc --- /dev/null +++ b/docs/store-delivery.md @@ -0,0 +1,103 @@ +# Mobile Store Delivery + +GitHub Releases and mobile store delivery are separate operations: + +- `bun run release` creates and audits the GitHub Release. It never contacts + Google Play or App Store Connect. +- `bun run publish:stores` dispatches a manual store-delivery workflow for one + existing formal Release tag. +- Store publication remains a separate decision. Google Play production uploads + remain Drafts, and iOS uploads stop at App Store Connect/TestFlight. + +## Safety Model + +The workflow checks out the immutable Release tag rather than `main`. Before any +store build starts, it verifies that: + +- the tag belongs to a formal, non-prerelease GitHub Release; +- the Release target and Git tag resolve to the same commit; +- the audited range since the previous formal Release contains mobile runtime + changes; +- root and mobile app versions both match the Release tag; +- Android `versionCode` increased; +- a Google Play production request repeats the exact Release tag as confirmation. + +A Release that reused the previous mobile binary is intentionally rejected. It +does not represent a new store binary and should not be uploaded again. + +## Prerequisites + +Configure these GitHub repository secrets: + +- `EXPO_TOKEN` +- `ANDROID_KEYSTORE_BASE64` +- `ANDROID_KEYSTORE_PASSWORD` +- `ANDROID_KEY_ALIAS` +- `ANDROID_KEY_PASSWORD` + +Upload the Google Play service-account key to the Android application's EAS +Submit credentials. Configure the iOS distribution credentials and App Store +Connect API key in EAS. Credentials and private keys must remain outside the +repository. + +Create these GitHub Environments: + +- `store-delivery` for Internal/Alpha/Beta and TestFlight delivery. +- `store-production` for Google Play production delivery. Configure required + reviewers on this environment. + +EAS Submit requires the application to exist in each store and Google Play API +submission requires the service account to have access to the application. +Follow the official +[EAS Android submission guide](https://docs.expo.dev/submit/android/) and +[EAS Submit configuration reference](https://docs.expo.dev/submit/eas-json/). + +## Commands + +Deliver both platforms to Google Play Internal and App Store Connect/TestFlight: + +```sh +bun run publish:stores -- --release v1.7.0 +``` + +Deliver only Android to a closed testing track: + +```sh +bun run publish:stores -- \ + --release v1.7.0 \ + --platform android \ + --android-track beta +``` + +Prepare a Google Play production Draft: + +```sh +bun run publish:stores -- \ + --release v1.7.0 \ + --platform android \ + --android-track production \ + --confirm-production v1.7.0 +``` + +Use `--dry-run` to print the GitHub workflow dispatch without starting it. + +## Platform Behavior + +### Google Play + +The self-hosted release runner builds a signed AAB from the selected tag, +verifies its signature and R8 mapping, preserves both as GitHub Actions +artifacts, and uploads the AAB through EAS Submit. + +Internal, Alpha, and Beta profiles use a completed release on the selected +testing track. Production uses `releaseStatus: draft` and +`changesNotSentForReview: true`; completing review and rollout remains a manual +Google Play Console action. + +### App Store Connect + +EAS Build creates the signed iOS archive from the selected tag and increments +the remote iOS build number. EAS Submit uploads that exact build to App Store +Connect, where it becomes available to TestFlight after Apple finishes +processing it. The workflow does not select the build for an App Store version +or submit it for App Review. diff --git a/docs/store-delivery.zh-CN.md b/docs/store-delivery.zh-CN.md new file mode 100644 index 00000000..5bf12cde --- /dev/null +++ b/docs/store-delivery.zh-CN.md @@ -0,0 +1,95 @@ +# 移动端商店交付 + +GitHub Release 与移动端商店交付是两个独立操作: + +- `bun run release` 只创建并审计 GitHub Release,不会访问 Google Play 或 + App Store Connect。 +- `bun run publish:stores` 针对一个已经存在的正式 Release tag,触发手动商店 + 交付工作流。 +- 商店正式发布仍然是独立决策。Google Play Production 上传会保持 Draft, + iOS 上传只到 App Store Connect/TestFlight。 + +## 安全模型 + +工作流检出不可变的 Release tag,而不是 `main`。开始任何商店构建前都会验证: + +- tag 属于正式、非 Prerelease 的 GitHub Release; +- Release 目标提交与 Git tag 指向同一个提交; +- 与上一个正式 Release 相比,审计范围内确实包含移动端运行时代码变化; +- 根版本和移动端 App 版本都与 Release tag 一致; +- Android `versionCode` 已递增; +- Google Play Production 请求再次输入了完全一致的 Release tag。 + +如果某个 Release 复用了上一版移动端二进制,工作流会主动拒绝。它不代表新的 +商店二进制,不应重复上传。 + +## 前置配置 + +在 GitHub 仓库中配置以下 Secrets: + +- `EXPO_TOKEN` +- `ANDROID_KEYSTORE_BASE64` +- `ANDROID_KEYSTORE_PASSWORD` +- `ANDROID_KEY_ALIAS` +- `ANDROID_KEY_PASSWORD` + +将 Google Play 服务账号密钥上传到 Android 应用的 EAS Submit Credentials。 +在 EAS 中配置 iOS 分发凭据和 App Store Connect API Key。凭据和私钥禁止提交 +到仓库。 + +创建以下 GitHub Environments: + +- `store-delivery`:用于 Internal/Alpha/Beta 和 TestFlight 交付。 +- `store-production`:用于 Google Play Production 交付,并为该 Environment + 配置 Required Reviewers。 + +EAS Submit 要求应用已经在对应商店中创建;Google Play API 提交还要求服务账号 +拥有该应用的访问权限。配置方法参考官方 +[EAS Android 提交指南](https://docs.expo.dev/submit/android/)和 +[EAS Submit 配置参考](https://docs.expo.dev/submit/eas-json/)。 + +## 命令 + +同时交付到 Google Play Internal 与 App Store Connect/TestFlight: + +```sh +bun run publish:stores -- --release v1.7.0 +``` + +只将 Android 交付到封闭测试轨道: + +```sh +bun run publish:stores -- \ + --release v1.7.0 \ + --platform android \ + --android-track beta +``` + +准备 Google Play Production Draft: + +```sh +bun run publish:stores -- \ + --release v1.7.0 \ + --platform android \ + --android-track production \ + --confirm-production v1.7.0 +``` + +使用 `--dry-run` 可以只输出将要触发的 GitHub 工作流,不实际启动。 + +## 各平台行为 + +### Google Play + +自托管发布 Runner 会从指定 tag 构建签名 AAB,验证签名和 R8 Mapping,将两者 +保留为 GitHub Actions Artifacts,然后通过 EAS Submit 上传 AAB。 + +Internal、Alpha 和 Beta 配置会在对应测试轨道创建 Completed Release。 +Production 使用 `releaseStatus: draft` 和 `changesNotSentForReview: true`; +完成审核和发布仍需在 Google Play Console 中手动操作。 + +### App Store Connect + +EAS Build 从指定 tag 创建签名 iOS Archive,并递增远端 iOS Build Number。 +EAS Submit 将同一个构建上传到 App Store Connect;Apple 处理完成后,它会出现在 +TestFlight 中。工作流不会为 App Store 版本选择构建,也不会自动提交 App Review。 diff --git a/package.json b/package.json index f95bc144..eabaf0ce 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,9 @@ "build:desktop": "EDGE_EVER_DESKTOP_BUILD=1 bun run build:web && bun run build:desktop:sidecar && bun run --cwd apps/desktop dist", "benchmark:desktop:sidecar": "bun scripts/benchmark-desktop-sidecar.mjs", "verify:web-performance": "bun scripts/verify-web-performance.mjs", - "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/release.test.mjs", + "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs", "release": "bun scripts/release.mjs", + "publish:stores": "bun scripts/store-delivery.mjs", "test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs", "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar", "verify:desktop-package": "bun scripts/verify-desktop-package.mjs", diff --git a/scripts/release.mjs b/scripts/release.mjs index b0b74c7c..57007192 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -37,6 +37,8 @@ export const RELEASE_VALIDATIONS = [ "scripts/plan-native-release.test.mjs", "scripts/check-native-release-assets.test.mjs", "scripts/release.test.mjs", + "scripts/validate-store-delivery.test.mjs", + "scripts/store-delivery.test.mjs", ], }, ]; diff --git a/scripts/store-delivery.mjs b/scripts/store-delivery.mjs new file mode 100644 index 00000000..b09101aa --- /dev/null +++ b/scripts/store-delivery.mjs @@ -0,0 +1,144 @@ +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_REPOSITORY = "tianma-if/edgeever"; +const PLATFORMS = new Set(["android", "ios", "both"]); +const ANDROID_TRACKS = new Set(["internal", "alpha", "beta", "production"]); + +const usage = `Usage: + bun run publish:stores -- --release vX.Y.Z [options] + +Options: + --release Required formal GitHub Release tag + --platform android, ios, or both (default: both) + --android-track internal, alpha, beta, or production (default: internal) + --confirm-production Required when the Android track is production + --repository GitHub repository (default: ${DEFAULT_REPOSITORY}) + --dry-run Print the workflow dispatch plan + --help Show this help +`; + +export const parseStoreDeliveryArgs = (argv) => { + const options = { + releaseTag: "", + platform: "both", + androidTrack: "internal", + productionConfirmation: "", + repository: DEFAULT_REPOSITORY, + dryRun: false, + help: false, + }; + const valueOptions = new Map([ + ["--release", "releaseTag"], + ["--platform", "platform"], + ["--android-track", "androidTrack"], + ["--confirm-production", "productionConfirmation"], + ["--repository", "repository"], + ]); + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--dry-run") { + options.dryRun = true; + continue; + } + if (argument === "--help") { + options.help = true; + continue; + } + const key = valueOptions.get(argument); + if (!key) { + throw new Error(`Unknown option: ${argument}`); + } + const value = argv[index + 1]?.trim(); + if (!value || value.startsWith("--")) { + throw new Error(`${argument} requires a value.`); + } + options[key] = value; + index += 1; + } + + if (options.help) { + return options; + } + if (!/^v\d+\.\d+\.\d+$/.test(options.releaseTag)) { + throw new Error("--release must use stable vX.Y.Z format."); + } + if (!PLATFORMS.has(options.platform)) { + throw new Error("--platform must be android, ios, or both."); + } + if (!ANDROID_TRACKS.has(options.androidTrack)) { + throw new Error( + "--android-track must be internal, alpha, beta, or production.", + ); + } + if ( + options.androidTrack === "production" && + options.platform !== "ios" && + options.productionConfirmation !== options.releaseTag + ) { + throw new Error( + `Production delivery requires --confirm-production ${options.releaseTag}.`, + ); + } + if (!/^[^/\s]+\/[^/\s]+$/.test(options.repository)) { + throw new Error("--repository must use owner/name format."); + } + return options; +}; + +const run = (options) => { + const args = [ + "workflow", + "run", + "store-delivery.yml", + "--repo", + options.repository, + "--ref", + "main", + "-f", + `release_tag=${options.releaseTag}`, + "-f", + `platform=${options.platform}`, + "-f", + `android_track=${options.androidTrack}`, + "-f", + `production_confirmation=${options.productionConfirmation}`, + ]; + if (options.dryRun) { + console.log(`gh ${args.join(" ")}`); + return; + } + + const result = spawnSync("gh", args, { + cwd: resolve("."), + encoding: "utf8", + env: process.env, + stdio: ["ignore", "pipe", "inherit"], + }); + if (result.status !== 0) { + throw new Error(`GitHub workflow dispatch failed with status ${result.status ?? 1}.`); + } + const output = String(result.stdout ?? "").trim(); + console.log(output || "Store delivery workflow dispatched."); +}; + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + const options = parseStoreDeliveryArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage); + } else { + run(options); + } + } catch (error) { + console.error( + `[store-delivery] failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } +} diff --git a/scripts/store-delivery.test.mjs b/scripts/store-delivery.test.mjs new file mode 100644 index 00000000..f84930e8 --- /dev/null +++ b/scripts/store-delivery.test.mjs @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { parseStoreDeliveryArgs } from "./store-delivery.mjs"; + +describe("store delivery command", () => { + test("uses safe testing defaults", () => { + expect( + parseStoreDeliveryArgs(["--release", "v1.7.0"]), + ).toMatchObject({ + releaseTag: "v1.7.0", + platform: "both", + androidTrack: "internal", + productionConfirmation: "", + }); + }); + + test("accepts an explicit single platform", () => { + expect( + parseStoreDeliveryArgs([ + "--release", + "v1.7.0", + "--platform", + "ios", + ]).platform, + ).toBe("ios"); + }); + + test("rejects malformed release tags", () => { + expect(() => + parseStoreDeliveryArgs(["--release", "latest"]) + ).toThrow("stable vX.Y.Z"); + }); + + test("requires exact confirmation for Google Play production", () => { + expect(() => + parseStoreDeliveryArgs([ + "--release", + "v1.7.0", + "--platform", + "android", + "--android-track", + "production", + ]) + ).toThrow("--confirm-production v1.7.0"); + }); +}); diff --git a/scripts/validate-store-delivery.mjs b/scripts/validate-store-delivery.mjs new file mode 100644 index 00000000..54447c93 --- /dev/null +++ b/scripts/validate-store-delivery.mjs @@ -0,0 +1,131 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { planNativeRelease } from "./plan-native-release.mjs"; + +const STABLE_RELEASE_TAG = /^v(\d+\.\d+\.\d+)$/; +const PLATFORMS = new Set(["android", "ios", "both"]); +const ANDROID_TRACKS = new Set(["internal", "alpha", "beta", "production"]); + +export const validateStoreDelivery = ({ + releaseTag, + rootVersion, + mobileVersion, + currentVersionCode, + previousVersionCode, + changedFiles, + platform, + androidTrack, + productionConfirmation, +}) => { + const version = STABLE_RELEASE_TAG.exec(releaseTag)?.[1]; + if (!version) { + throw new Error(`Release tag must use stable vX.Y.Z format: ${releaseTag}`); + } + if (!ANDROID_TRACKS.has(androidTrack)) { + throw new Error(`Unsupported Google Play track: ${androidTrack}`); + } + if (!PLATFORMS.has(platform)) { + throw new Error(`Unsupported store platform: ${platform}`); + } + + const mobilePlan = planNativeRelease("mobile", changedFiles); + if (!mobilePlan.rebuild) { + throw new Error( + `${releaseTag} contains no mobile runtime changes; keep the existing store binary.`, + ); + } + if (rootVersion !== version || mobileVersion !== version) { + throw new Error( + `${releaseTag} requires package.json and apps/mobile/app.json versions to both equal ${version}.`, + ); + } + if ( + !Number.isInteger(currentVersionCode) || + !Number.isInteger(previousVersionCode) || + currentVersionCode <= previousVersionCode + ) { + throw new Error( + `Android versionCode must increase beyond ${previousVersionCode}; received ${currentVersionCode}.`, + ); + } + if ( + platform !== "ios" && + androidTrack === "production" && + productionConfirmation !== releaseTag + ) { + throw new Error( + `Production delivery requires --confirm-production ${releaseTag}.`, + ); + } + + return { + version, + versionCode: currentVersionCode, + relevantChanges: mobilePlan.relevantChanges, + }; +}; + +const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); + +const run = () => { + const [ + releaseTag, + previousTag, + platform = "both", + androidTrack = "internal", + productionConfirmation = "", + ] = process.argv.slice(2); + if (!releaseTag || !previousTag) { + console.error( + "Usage: node scripts/validate-store-delivery.mjs [platform] [android-track] [production-confirmation]", + ); + process.exit(2); + } + + const git = (...args) => + execFileSync("git", args, { encoding: "utf8" }).trim(); + const changedFiles = git( + "diff", + "--name-only", + `${previousTag}...${releaseTag}`, + ).split("\n").filter(Boolean); + const rootPackage = readJson("package.json"); + const mobileConfig = readJson("apps/mobile/app.json"); + const previousMobileConfig = JSON.parse( + git("show", `${previousTag}:apps/mobile/app.json`), + ); + const result = validateStoreDelivery({ + releaseTag, + rootVersion: rootPackage.version, + mobileVersion: mobileConfig.expo.version, + currentVersionCode: mobileConfig.expo.android.versionCode, + previousVersionCode: previousMobileConfig.expo.android.versionCode, + changedFiles, + platform, + androidTrack, + productionConfirmation, + }); + + process.stdout.write(`version=${result.version}\n`); + process.stdout.write(`version_code=${result.versionCode}\n`); + process.stdout.write("mobile_changed=true\n"); + process.stderr.write( + `Store delivery ${releaseTag}: ${result.relevantChanges.join(", ")}\n`, + ); +}; + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + run(); + } catch (error) { + console.error( + `[store-delivery] failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } +} diff --git a/scripts/validate-store-delivery.test.mjs b/scripts/validate-store-delivery.test.mjs new file mode 100644 index 00000000..f2a9fe91 --- /dev/null +++ b/scripts/validate-store-delivery.test.mjs @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { validateStoreDelivery } from "./validate-store-delivery.mjs"; + +const validInput = { + releaseTag: "v1.7.0", + rootVersion: "1.7.0", + mobileVersion: "1.7.0", + currentVersionCode: 57, + previousVersionCode: 56, + changedFiles: ["apps/mobile/src/screens/WorkspaceScreen.tsx"], + platform: "both", + androidTrack: "internal", + productionConfirmation: "", +}; + +describe("store delivery validation", () => { + test("accepts a mobile release with increasing versions", () => { + expect(validateStoreDelivery(validInput)).toEqual({ + version: "1.7.0", + versionCode: 57, + relevantChanges: ["apps/mobile/src/screens/WorkspaceScreen.tsx"], + }); + }); + + test("rejects releases that reuse the existing mobile binary", () => { + expect(() => + validateStoreDelivery({ + ...validInput, + changedFiles: ["apps/web/src/app/App.tsx"], + }) + ).toThrow("contains no mobile runtime changes"); + }); + + test("requires release and mobile versions to match", () => { + expect(() => + validateStoreDelivery({ ...validInput, mobileVersion: "1.6.99" }) + ).toThrow("versions to both equal 1.7.0"); + }); + + test("requires Android versionCode to increase", () => { + expect(() => + validateStoreDelivery({ ...validInput, currentVersionCode: 56 }) + ).toThrow("versionCode must increase"); + }); + + test("requires an exact confirmation for production delivery", () => { + expect(() => + validateStoreDelivery({ + ...validInput, + androidTrack: "production", + }) + ).toThrow("--confirm-production v1.7.0"); + expect( + validateStoreDelivery({ + ...validInput, + androidTrack: "production", + productionConfirmation: "v1.7.0", + }).version, + ).toBe("1.7.0"); + }); +});