diff --git a/.github/scripts/mirror-play-apk.cjs b/.github/scripts/mirror-play-apk.cjs index 3a7af06bee1c..773f90e76dcd 100644 --- a/.github/scripts/mirror-play-apk.cjs +++ b/.github/scripts/mirror-play-apk.cjs @@ -86,7 +86,7 @@ function readRelease(tag) { // Downloads the build's universal APK and uploads it, creating the release on // first use. Returns false when Play lists no universal APK, which happens // while it is still processing a fresh upload. -async function uploadApk({ token, appUrl, build, tag, apkName, notes, release }) { +async function uploadApk({ token, appUrl, build, tag, apkName, notes, title, release }) { const generated = await ( await play(token, "GET", `${appUrl}/generatedApks/${build.versionCode}`) ).json(); @@ -117,7 +117,7 @@ async function uploadApk({ token, appUrl, build, tag, apkName, notes, release }) "--prerelease", "--latest=false", "--title", - "T3 Code for Android (nightly)", + title, "--notes", notes, ); @@ -129,8 +129,8 @@ async function uploadApk({ token, appUrl, build, tag, apkName, notes, release }) async function main() { const packageName = requireEnv("PACKAGE_NAME"); - const trackName = requireEnv("PLAY_TRACK"); const tag = requireEnv("RELEASE_TAG"); + const title = "T3 Code for Android (testing)"; const key = JSON.parse(requireEnv("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON")); const summary = (line) => process.env.GITHUB_STEP_SUMMARY && @@ -138,48 +138,90 @@ async function main() { const token = await playAccessToken(key); const appUrl = `${PLAY_API}/${packageName}`; - const build = await newestTrackBuild(token, appUrl, trackName); - if (!build) { - console.log(`The ${trackName} track has no live release.`); - return; - } - - const safeName = build.versionName.replace(/[^0-9A-Za-z.]+/g, "-").replace(/^-|-$/g, ""); - const apkName = `t3code-android-${safeName}-${build.versionCode}.apk`; - const notes = [ - `T3 Code for Android that works with nightly servers, mirrored from the Google Play ${trackName} track. Current build: ${build.versionName} (${build.versionCode}).`, - "", - "The Play Store release can't connect to a nightly server yet. Install this APK if you can't join the Play testing group. Only the newest build is kept here.", - ].join("\n"); - - const release = readRelease(tag); + let release = readRelease(tag); const assets = release?.assets ?? []; - // A failed upload can leave an empty asset under the final name. - const published = assets.some( - (asset) => asset.name === apkName && asset.state === "uploaded" && asset.size > 0, - ); - if (published) { - console.log(`${apkName} is already published.`); - } else if (await uploadApk({ token, appUrl, build, tag, apkName, notes, release })) { - console.log(`Published ${apkName}.`); - summary(`:white_check_mark: Published \`${apkName}\` to the \`${tag}\` release.`); - } else { - // The next run retries. - console.log(`Play lists no universal APK for ${build.versionCode}.`); - summary( - `:hourglass: Play lists no universal APK for ${build.versionName} (${build.versionCode}).`, + const sections = []; + for (const { track, channel, label, stability } of [ + { + track: "alpha", + channel: "beta", + label: "Beta", + stability: + "Promoted for wider testing. Recommended over internal builds, but may still contain bugs.", + }, + { + track: "internal", + channel: "internal", + label: "Internal", + stability: "Newest, less-tested builds. May contain serious bugs.", + }, + ]) { + const prefix = `t3code-android-${channel}-`; + let available = assets.filter( + (asset) => asset.name.startsWith(prefix) && asset.state === "uploaded" && asset.size > 0, ); - return; - } - - // Reconciled on every run so an interrupted cleanup finishes on the next one. - for (const stale of assets) { - if (stale.name.endsWith(".apk") && stale.name !== apkName) { - gh("release", "delete-asset", tag, stale.name, "--yes"); + const build = await newestTrackBuild(token, appUrl, track); + if (build) { + const safeName = build.versionName.replace(/[^0-9A-Za-z.]+/g, "-").replace(/^-|-$/g, ""); + const apkName = `${prefix}${safeName}-${build.versionCode}.apk`; + const published = assets.some( + (asset) => asset.name === apkName && asset.state === "uploaded" && asset.size > 0, + ); + if ( + published || + (await uploadApk({ + token, + appUrl, + build, + tag, + apkName, + notes: "Android testing APKs. Release notes are being updated.", + title, + release, + })) + ) { + release ??= { assets: [], body: "" }; + available = [{ name: apkName }]; + summary(`Published or verified \`${apkName}\` on \`${tag}\`.`); + // Clean up only this track after its replacement is available. Retire + // the legacy unlabelled APK when the beta replacement is published. + for (const stale of assets) { + const belongsToTrack = + stale.name.startsWith(prefix) || + (track === "alpha" && /^t3code-android-(?!beta-|internal-).*\.apk$/.test(stale.name)); + if (belongsToTrack && stale.name.endsWith(".apk") && stale.name !== apkName) { + gh("release", "delete-asset", tag, stale.name, "--yes"); + } + } + } else { + summary( + `Play is still processing the ${track} APK for ${build.versionCode}; keeping the previous download.`, + ); + } } + sections.push( + `## ${label}`, + "", + stability, + "", + ...available.map( + (asset) => + `- [${asset.name}](https://github.com/${requireEnv("GH_REPO")}/releases/download/${tag}/${asset.name})`, + ), + ...(available.length ? [] : ["No APK available yet."]), + "", + ); } - if (release && release.body.replace(/\r\n/g, "\n").trim() !== notes) { - gh("release", "edit", tag, "--notes", notes); + if (!release) return; + const notes = [ + "T3 Code Android APKs mirrored from Google Play. Only the newest available APK from each track is kept here.", + "", + ...sections, + ] + .join("\n") + .trim(); + if (release.body.replace(/\r\n/g, "\n").trim() !== notes) { + gh("release", "edit", tag, "--title", title, "--notes", notes); } } diff --git a/.github/workflows/mobile-android-apk.yml b/.github/workflows/mobile-android-apk.yml index 9a81b25c6d5b..330ed8e45fd3 100644 --- a/.github/workflows/mobile-android-apk.yml +++ b/.github/workflows/mobile-android-apk.yml @@ -1,7 +1,7 @@ name: Mobile Android APK -# Publishes the newest nightly-compatible Android build as a sideloadable APK -# on the rolling "android-nightly" prerelease, for nightly users who can't join +# Publishes the newest Alpha and internal-testing Android builds as a sideloadable APK +# on the rolling "android-nightly" prerelease for users who can't join # the Play testing group. Play re-signs uploaded bundles with the app signing # key, so its universal APK matches the Play Store install and needs no signing # secrets here. EAS submits asynchronously (--no-wait), so this polls Play @@ -19,17 +19,13 @@ concurrency: jobs: mirror: - name: Mirror Play APK + name: Mirror Play APKs runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 10 permissions: contents: write env: PACKAGE_NAME: com.t3tools.t3code - # eas.json submits v2-preview builds to this closed testing track; they - # speak the orchestration protocol nightly servers use, which the Play - # Store release can't. - PLAY_TRACK: v2 RELEASE_TAG: android-nightly steps: - id: credentials diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 6f0ba9746a1d..8ae8ecc742e9 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -15,9 +15,6 @@ name: Mobile EAS Production # batch to avoid a store build per PR. # workflow_dispatch remains as a manual override for both modes (e.g. to # retry an errored build or force an OTA). -# Manual v2-preview builds keep the production app identity and disable OTA. -# They auto-submit to TestFlight and the Google Play closed testing track v2. -# Select the v2 branch when dispatching a preview build. on: workflow_dispatch: inputs: @@ -30,14 +27,13 @@ on: - build - update profile: - description: "production: store release; testflight: iOS 3.0.0 beta; v2-preview: legacy closed testing" + description: "production: store release; testflight: iOS 3.0.0 beta" required: true type: choice default: production options: - production - testflight - - v2-preview platform: description: "Target platform" required: true @@ -86,12 +82,6 @@ jobs: APP_VARIANT: production NODE_OPTIONS: --max-old-space-size=8192 steps: - - name: Reject preview OTA updates - if: github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview' && inputs.mode == 'update' - run: | - echo "::error::V2 previews use store builds only. Select mode=build." - exit 1 - - name: Validate TestFlight inputs if: github.event_name == 'workflow_dispatch' && inputs.profile == 'testflight' env: @@ -145,16 +135,6 @@ jobs: args: - --filter=@t3tools/mobile... - - name: Verify v2 preview source - if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview' - run: | - node --input-type=module -e ' - import * as environment from "./packages/contracts/src/environment.ts"; - if (environment.ORCHESTRATION_PROTOCOL_VERSION !== 2) { - throw new Error("V2 previews require a v2 source branch. Select the v2 branch when running this workflow."); - } - ' - - name: Expose pnpm if: steps.expo-token.outputs.present == 'true' run: | diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index ef3de7c5cb13..15ae4a15ba4a 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -55,14 +55,6 @@ "T3CODE_MOBILE_VERSION": "3.0.0" } }, - "v2-preview": { - "extends": "production", - "distribution": "store", - "channel": "v2-preview", - "env": { - "T3CODE_MOBILE_UPDATES_ENABLED": "0" - } - }, "production": { "corepack": true, "env": { @@ -81,13 +73,6 @@ "testflight": { "extends": "production" }, - "v2-preview": { - "extends": "production", - "android": { - "track": "v2", - "releaseStatus": "completed" - } - }, "production": { "ios": { "ascAppId": "6787819824" diff --git a/docs/operations/release.md b/docs/operations/release.md index 5d39b04d88b9..825f0836ca0d 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -321,9 +321,15 @@ unreleased major and get its first beta build approved. ## Android APK mirror `.github/workflows/mobile-android-apk.yml` checks Google Play hourly and publishes the newest -build on the `v2` closed testing track to the rolling `android-nightly` prerelease, for nightly -users who can't join the Play testing group. Those v2-preview builds are the only Android builds that can -connect to a nightly server. The APK is the universal APK Play generates and signs from the +builds on two tracks for users who can't join the Play testing group: + +- `beta` follows Alpha closed testing, with builds promoted for wider testing. +- `internal` follows internal testing, with newer, less-tested builds. + +Both APKs live in the `android-nightly` release, with `beta` or `internal` in each filename. +Release notes describe their stability and link to each download. The mirror keeps only +the newest available APK per track. Android submissions +go to internal testing; the Alpha mirror updates after promotion to Alpha. The APK is the universal APK Play generates and signs from the uploaded bundle, so no signing key lives in GitHub. The job skips itself until `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` holds a Play service account key with the "Release to production, exclude devices, and use Play App Signing" permission, which downloading generated