Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 83 additions & 41 deletions .github/scripts/mirror-play-apk.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
);
Expand All @@ -129,57 +129,99 @@ 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 &&
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${line}\n`);

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);
}
}

Expand Down
10 changes: 3 additions & 7 deletions .github/workflows/mobile-android-apk.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
22 changes: 1 addition & 21 deletions .github/workflows/mobile-eas-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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: |
Expand Down
15 changes: 0 additions & 15 deletions apps/mobile/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -81,13 +73,6 @@
"testflight": {
"extends": "production"
},
"v2-preview": {
"extends": "production",
"android": {
"track": "v2",
"releaseStatus": "completed"
}
},
"production": {
"ios": {
"ascAppId": "6787819824"
Expand Down
12 changes: 9 additions & 3 deletions docs/operations/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading