feat: separate store delivery from releases

This commit is contained in:
tianma-if committed 2026-07-29 20:01:36 +08:00
1 parent 313c3dab64
commit c941dcf032
14 files changed
+876 -70

No files matched your search

+7 -47
View File
@@ -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"
+231
View File
@@ -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
+30
View File
@@ -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"
}
}
}
}
+13 -22
View File
@@ -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
+7
View File
@@ -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.
+5
View File
@@ -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 构建,同时不会跳过
正式发布所需的构建。
+103
View File
@@ -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.
+95
View File
@@ -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。
+2 -1
View File
@@ -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",
+2
View File
@@ -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",
],
},
];
+144
View File
@@ -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 <tag> Required formal GitHub Release tag
--platform <target> android, ios, or both (default: both)
--android-track <track> internal, alpha, beta, or production (default: internal)
--confirm-production <tag> Required when the Android track is production
--repository <owner/name> 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);
}
}
+45
View File
@@ -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");
});
});
+131
View File
@@ -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 <release-tag> <previous-tag> [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);
}
}
+61
View File
@@ -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");
});
});