416 lines
16 KiB
YAML
416 lines
16 KiB
YAML
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"
|
|
required: true
|
|
default: production
|
|
type: choice
|
|
options:
|
|
- production
|
|
- alpha
|
|
- beta
|
|
- internal
|
|
recover_play_apk:
|
|
description: "Skip the completed Play upload and recover its signed APK"
|
|
required: true
|
|
default: false
|
|
type: boolean
|
|
ios_build_number:
|
|
description: "App Store Connect build number from Xcode Cloud. Required for iOS delivery."
|
|
required: false
|
|
default: ""
|
|
type: string
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
concurrency:
|
|
group: edgeever-store-delivery-${{ inputs.release_tag }}-${{ inputs.platform }}
|
|
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 }}
|
|
release_target: ${{ steps.release.outputs.release_target }}
|
|
steps:
|
|
- name: Validate Release and resolve its formal predecessor
|
|
id: release
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
|
run: |
|
|
release="$(
|
|
gh release view "$RELEASE_TAG" \
|
|
--repo "$GITHUB_REPOSITORY" \
|
|
--json isDraft,isPrerelease,tagName,targetCommitish
|
|
)"
|
|
release_is_draft="$(jq -r '.isDraft' <<<"$release")"
|
|
test "$release_is_draft" = "true" -o "$release_is_draft" = "false"
|
|
test "$(jq -r '.isPrerelease' <<<"$release")" = "false"
|
|
test "$(jq -r '.tagName' <<<"$release")" = "$RELEASE_TAG"
|
|
|
|
release_target="$(jq -r '.targetCommitish' <<<"$release")"
|
|
test -n "$release_target"
|
|
test "$release_target" != "null"
|
|
echo "release_target=$release_target" >> "$GITHUB_OUTPUT"
|
|
|
|
mapfile -t release_tags < <(
|
|
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
|
|
--jq '.[] | select(.draft == false and .prerelease == false) | .tag_name'
|
|
)
|
|
previous_tag=""
|
|
if [[ "$release_is_draft" = "true" ]]; then
|
|
previous_tag="${release_tags[0]:-}"
|
|
else
|
|
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"
|
|
fi
|
|
test -n "$previous_tag"
|
|
echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Check out the immutable Release target
|
|
uses: actions/checkout@v5
|
|
with:
|
|
ref: ${{ steps.release.outputs.release_target }}
|
|
fetch-depth: 0
|
|
|
|
- name: Validate the checked-out Release target
|
|
env:
|
|
PREVIOUS_TAG: ${{ steps.release.outputs.previous_tag }}
|
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
|
RELEASE_TARGET: ${{ steps.release.outputs.release_target }}
|
|
run: |
|
|
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RELEASE_TARGET}^{commit}")"
|
|
git fetch origin "refs/tags/${PREVIOUS_TAG}:refs/tags/${PREVIOUS_TAG}"
|
|
if git rev-parse --verify --quiet "refs/tags/${RELEASE_TAG}" >/dev/null; then
|
|
test "$(git rev-parse "${RELEASE_TAG}^{commit}")" = "$(git rev-parse "${RELEASE_TARGET}^{commit}")"
|
|
else
|
|
git tag "$RELEASE_TAG" "$RELEASE_TARGET"
|
|
fi
|
|
|
|
- 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 }}
|
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
|
run: |
|
|
set -o pipefail
|
|
node scripts/validate-store-delivery.mjs \
|
|
"$RELEASE_TAG" \
|
|
"$PREVIOUS_TAG" \
|
|
"$PLATFORM" \
|
|
"$ANDROID_TRACK" |
|
|
tee -a "$GITHUB_OUTPUT"
|
|
|
|
android:
|
|
needs: plan
|
|
if: >-
|
|
${{
|
|
github.repository == 'tianma-if/edgeever' &&
|
|
(inputs.platform == 'android' || inputs.platform == 'both')
|
|
}}
|
|
name: Deliver Google Play
|
|
runs-on: [self-hosted, macOS, ARM64, edgeever-mobile]
|
|
timeout-minutes: 120
|
|
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 target
|
|
uses: actions/checkout@v5
|
|
with:
|
|
ref: ${{ needs.plan.outputs.release_target }}
|
|
clean: true
|
|
|
|
- name: Set up Bun
|
|
uses: oven-sh/setup-bun@v2
|
|
|
|
- name: Set up EAS CLI
|
|
if: ${{ !inputs.recover_play_apk }}
|
|
uses: expo/expo-github-action@v8
|
|
with:
|
|
eas-version: 21.4.0
|
|
packager: npm
|
|
token: ${{ secrets.EXPO_TOKEN }}
|
|
|
|
- name: Install dependencies
|
|
if: ${{ !inputs.recover_play_apk }}
|
|
run: |
|
|
set -o pipefail
|
|
cache_key="$(
|
|
{
|
|
printf '%s\n' "$RUNNER_OS" "$RUNNER_ARCH"
|
|
bun --version
|
|
shasum -a 256 bun.lock patches/*.patch
|
|
} | shasum -a 256 | awk '{print $1}'
|
|
)"
|
|
store_cache="$RUNNER_TOOL_CACHE/edgeever-store-bun/$cache_key"
|
|
for attempt in 1 2 3; do
|
|
cache_dir="$store_cache"
|
|
if [ "$attempt" -gt 1 ]; then
|
|
cache_dir="$RUNNER_TEMP/edgeever-bun-cache-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${attempt}"
|
|
fi
|
|
if bun install --frozen-lockfile \
|
|
--cache-dir "$cache_dir"; then
|
|
exit 0
|
|
fi
|
|
echo "Dependency install failed on attempt ${attempt}/3" >&2
|
|
done
|
|
exit 1
|
|
|
|
- name: Install Play APK recovery dependency
|
|
if: ${{ inputs.recover_play_apk }}
|
|
run: |
|
|
if ! bun install --frozen-lockfile \
|
|
--cache-dir "$RUNNER_TEMP/edgeever-bun-cache-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"; then
|
|
node --input-type=module -e "await import('google-auth-library')"
|
|
fi
|
|
|
|
- name: Restore Android upload keystore
|
|
if: ${{ !inputs.recover_play_apk }}
|
|
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
|
|
if: ${{ !inputs.recover_play_apk }}
|
|
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 }}
|
|
EDGE_EVER_ANDROID_PLAY_ARCHS: arm64-v8a
|
|
run: bun run build:android:play
|
|
|
|
- name: Preserve Play bundle and deobfuscation mapping
|
|
if: ${{ !inputs.recover_play_apk }}
|
|
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
|
|
if: ${{ !inputs.recover_play_apk }}
|
|
env:
|
|
ANDROID_TRACK: ${{ inputs.android_track }}
|
|
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
|
run: |
|
|
test -n "$EXPO_TOKEN"
|
|
eas 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: Download Play-signed universal APK
|
|
env:
|
|
ANDROID_PLAY_APP_SIGNER_SHA256: ${{ secrets.ANDROID_PLAY_APP_SIGNER_SHA256 }}
|
|
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
|
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
|
VERSION_CODE: ${{ needs.plan.outputs.version_code }}
|
|
run: |
|
|
apk_path="$RUNNER_TEMP/edgeever-android-${RELEASE_TAG}-arm64-v8a.apk"
|
|
node scripts/download-play-universal-apk.mjs "$VERSION_CODE" "$apk_path"
|
|
echo "apk_path=$apk_path" >> "$GITHUB_ENV"
|
|
|
|
- name: Verify Play app signature
|
|
env:
|
|
EDGE_EVER_ANDROID_ALLOWED_SIGNER_SHA256: ${{ secrets.ANDROID_PLAY_APP_SIGNER_SHA256 }}
|
|
run: node scripts/verify-android-apk-signature.mjs "$apk_path"
|
|
|
|
- name: Verify Play APK architecture
|
|
run: |
|
|
actual_archs="$(unzip -Z1 "$apk_path" | sed -n 's#^lib/\([^/]*\)/.*#\1#p' | sort -u | paste -sd, -)"
|
|
if [[ "$actual_archs" != "arm64-v8a" ]]; then
|
|
echo "::error::Expected an arm64-v8a Play APK, received: ${actual_archs:-no native libraries}."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Replace GitHub Release APK with the Play-signed build
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
|
run: gh release upload "$RELEASE_TAG" "$apk_path" --repo "$GITHUB_REPOSITORY" --clobber
|
|
|
|
- name: Remove temporary upload keystore
|
|
if: always()
|
|
run: rm -f "$RUNNER_TEMP/edgeever-upload.p12"
|
|
|
|
ios:
|
|
needs: plan
|
|
if: >-
|
|
${{
|
|
github.repository == 'tianma-if/edgeever' &&
|
|
(inputs.platform == 'ios' || inputs.platform == 'both')
|
|
}}
|
|
name: Deliver App Store Connect
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 120
|
|
environment: store-delivery
|
|
steps:
|
|
- name: Check out the immutable Release target
|
|
uses: actions/checkout@v5
|
|
with:
|
|
ref: ${{ needs.plan.outputs.release_target }}
|
|
|
|
- name: Check out current store-delivery tooling
|
|
uses: actions/checkout@v5
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
path: .edgeever-ci
|
|
|
|
- name: Resolve the App Store build
|
|
id: ios_build
|
|
env:
|
|
ASC_APP_ID: "6792625631"
|
|
ASC_API_ISSUER: ${{ secrets.EDGEEVER_APPLE_API_ISSUER }}
|
|
ASC_API_KEY_ID: ${{ secrets.EDGEEVER_APPLE_API_KEY_ID }}
|
|
ASC_API_KEY_BASE64: ${{ secrets.EDGEEVER_APPLE_API_KEY_BASE64 }}
|
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
|
REQUESTED_BUILD_NUMBER: ${{ inputs.ios_build_number }}
|
|
run: |
|
|
if [[ -n "$REQUESTED_BUILD_NUMBER" ]]; then
|
|
echo "build_number=$REQUESTED_BUILD_NUMBER" >> "$GITHUB_OUTPUT"
|
|
echo "Using existing App Store Connect build $REQUESTED_BUILD_NUMBER"
|
|
exit 0
|
|
fi
|
|
test -n "$ASC_API_ISSUER"
|
|
test -n "$ASC_API_KEY_ID"
|
|
test -n "$ASC_API_KEY_BASE64"
|
|
python3 -m pip install --quiet PyJWT cryptography
|
|
log_file="$RUNNER_TEMP/xcode-cloud-control.log"
|
|
# Manual Xcode Cloud workflows reject tags that are not in the start
|
|
# condition. Start the Archive workflow on its configured default
|
|
# (main) and require that source's marketing version matches this
|
|
# Release. The Git tag itself may still contain a stale project.yml
|
|
# that xcodegen would archive as the previous live version.
|
|
python3 .edgeever-ci/scripts/xcode-cloud-control.py start \
|
|
--wait \
|
|
--wait-valid |
|
|
tee "$log_file"
|
|
grep -E '^(build_number|app_store_build_id|build_run_id|source_sha|processing_state)=' \
|
|
"$log_file" >> "$GITHUB_OUTPUT"
|
|
test -n "$(grep -E '^build_number=' "$log_file")"
|
|
source_sha="$(grep -E '^source_sha=' "$log_file" | tail -1 | cut -d= -f2)"
|
|
test -n "$source_sha"
|
|
git fetch --no-tags origin "$source_sha"
|
|
cloud_version="$(
|
|
git show "$source_sha:apps/ios/Config/Version.xcconfig" |
|
|
sed -nE 's/^MARKETING_VERSION[[:space:]]*=[[:space:]]*([^[:space:]]+).*/\1/p' |
|
|
head -1
|
|
)"
|
|
release_version="${RELEASE_TAG#v}"
|
|
if [[ "$cloud_version" != "$release_version" ]]; then
|
|
echo "Xcode Cloud source $source_sha MARKETING_VERSION=$cloud_version does not match $RELEASE_TAG." >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Load App Store release notes
|
|
id: notes
|
|
run: |
|
|
node --input-type=module <<'NODE'
|
|
import { appendFileSync, readFileSync } from "node:fs";
|
|
|
|
const summary = JSON.parse(readFileSync("release-summary.json", "utf8"));
|
|
const join = (items) =>
|
|
(Array.isArray(items) ? items : [])
|
|
.map((item) => String(item).trim())
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
const write = (key, value) => {
|
|
appendFileSync(process.env.GITHUB_OUTPUT, `${key}<<EOF\n${value}\nEOF\n`);
|
|
};
|
|
write("notes_en", join(summary.changes?.["en-US"]));
|
|
write("notes_zh", join(summary.changes?.["zh-CN"]));
|
|
const notesJa = join(summary.changes?.ja || summary.changes?.["ja-JP"]);
|
|
if (!notesJa) {
|
|
throw new Error(
|
|
"release-summary.json is missing Japanese What's New (ja or ja-JP). Pass --change-locale ja:...",
|
|
);
|
|
}
|
|
write("notes_ja", notesJa);
|
|
NODE
|
|
|
|
- name: Set up Ruby and fastlane
|
|
uses: ruby/setup-ruby@v1
|
|
with:
|
|
ruby-version: "3.3"
|
|
bundler-cache: true
|
|
working-directory: apps/ios
|
|
|
|
- name: Submit the uploaded build to App Review
|
|
env:
|
|
APP_STORE_BUILD_NUMBER: ${{ steps.ios_build.outputs.build_number }}
|
|
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.EDGEEVER_APPLE_API_ISSUER }}
|
|
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.EDGEEVER_APPLE_API_KEY_ID }}
|
|
APP_STORE_CONNECT_API_KEY_P8_BASE64: ${{ secrets.EDGEEVER_APPLE_API_KEY_BASE64 }}
|
|
APP_STORE_VERSION: ${{ needs.plan.outputs.version }}
|
|
APP_STORE_RELEASE_NOTES_EN: ${{ steps.notes.outputs.notes_en }}
|
|
APP_STORE_RELEASE_NOTES_ZH: ${{ steps.notes.outputs.notes_zh }}
|
|
APP_STORE_RELEASE_NOTES_JA: ${{ steps.notes.outputs.notes_ja }}
|
|
PRECHECK_INCLUDE_IN_APP_PURCHASES: "false"
|
|
run: |
|
|
test -n "$APP_STORE_CONNECT_API_ISSUER_ID"
|
|
test -n "$APP_STORE_CONNECT_API_KEY_ID"
|
|
test -n "$APP_STORE_CONNECT_API_KEY_P8_BASE64"
|
|
test -n "$APP_STORE_BUILD_NUMBER"
|
|
log_file="$RUNNER_TEMP/edgeever-app-store-review.log"
|
|
for attempt in {1..20}; do
|
|
set +e
|
|
bundle exec fastlane ios submit_review 2>&1 | tee "$log_file"
|
|
status="${PIPESTATUS[0]}"
|
|
set -e
|
|
if [[ "$status" -eq 0 ]]; then
|
|
exit 0
|
|
fi
|
|
if ! grep -Fq "Build number: ${APP_STORE_BUILD_NUMBER} does not exist" "$log_file" || [[ "$attempt" -eq 20 ]]; then
|
|
exit "$status"
|
|
fi
|
|
echo "App Store Connect has not exposed build ${APP_STORE_BUILD_NUMBER} yet; retrying in 60 seconds (${attempt}/20)."
|
|
sleep 60
|
|
done
|
|
working-directory: apps/ios
|