authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-10-11 17:51:39-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 14:51:39-07:00
logc0c495e4dc12bbb0bb44387657a5682661cdd05c
treeb8db2ae0bc14fb42c83f2fdb38fb54fd91f59302
parentf43f6ddde06047d8d53da0abb355b41d703562ce
signature Signed by PGP key B5690EEEBB952194

ci(mobile): publish nightly Android APKs to a GitHub prerelease (#16271)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

3 files changed, 260 insertions(+), 0 deletions(-)

.github/scripts/mirror-play-apk.cjs created+189
...@@ -0,0 +1,189 @@
1// Mirrors the newest build on a Google Play track to a rolling GitHub
2// prerelease as a universal APK. Play generates and signs that APK from the
3// uploaded bundle with the app signing key, so it carries the same signature
4// as the Play Store install. Run by .github/workflows/mobile-android-apk.yml.
5const { execFileSync } = require("node:child_process");
6const crypto = require("node:crypto");
7const fs = require("node:fs");
8const os = require("node:os");
9const path = require("node:path");
10const { Readable } = require("node:stream");
11const { pipeline } = require("node:stream/promises");
12
13const PLAY_API = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications";
14
15function requireEnv(name) {
16 const value = process.env[name];
17 if (!value) throw new Error(`${name} is required.`);
18 return value;
19}
20
21// Service account key -> OAuth access token via a self-signed JWT grant.
22async function playAccessToken(key) {
23 const now = Math.floor(Date.now() / 1000);
24 const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
25 const unsigned = `${encode({ alg: "RS256", typ: "JWT" })}.${encode({
26 iss: key.client_email,
27 scope: "https://www.googleapis.com/auth/androidpublisher",
28 aud: key.token_uri,
29 iat: now,
30 exp: now + 600,
31 })}`;
32 const signature = crypto.sign("RSA-SHA256", Buffer.from(unsigned), key.private_key);
33 const response = await fetch(key.token_uri, {
34 method: "POST",
35 body: new URLSearchParams({
36 grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
37 assertion: `${unsigned}.${signature.toString("base64url")}`,
38 }),
39 });
40 if (!response.ok) {
41 throw new Error(`Google token exchange failed: ${response.status} ${await response.text()}`);
42 }
43 return (await response.json()).access_token;
44}
45
46async function play(token, method, url) {
47 const response = await fetch(url, { method, headers: { authorization: `Bearer ${token}` } });
48 if (!response.ok) {
49 throw new Error(`${method} ${url} failed: ${response.status} ${await response.text()}`);
50 }
51 return response;
52}
53
54// Highest version code the track serves, with its release name. Play names a
55// release after the bundle's versionName unless the uploader sets one. This
56// endpoint reads without opening an edit; an edit would invalidate any open
57// EAS Submit edit made with the same service account.
58async function newestTrackBuild(token, appUrl, trackName) {
59 const { releases = [] } = await (
60 await play(token, "GET", `${appUrl}/tracks/${trackName}/releases`)
61 ).json();
62 let newest;
63 for (const release of releases) {
64 if (release.releaseLifecycleState !== "RELEASE_LIFECYCLE_STATE_PUBLISHED") continue;
65 for (const { versionCode } of release.activeArtifacts ?? []) {
66 if (!newest || versionCode > newest.versionCode) {
67 newest = { versionCode, versionName: release.releaseName || String(versionCode) };
68 }
69 }
70 }
71 return newest;
72}
73
74function gh(...args) {
75 return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
76}
77
78function readRelease(tag) {
79 try {
80 return JSON.parse(gh("release", "view", tag, "--json", "assets,body"));
81 } catch {
82 return undefined;
83 }
84}
85
86// Downloads the build's universal APK and uploads it, creating the release on
87// first use. Returns false when Play lists no universal APK, which happens
88// while it is still processing a fresh upload.
89async function uploadApk({ token, appUrl, build, tag, apkName, notes, release }) {
90 const generated = await (
91 await play(token, "GET", `${appUrl}/generatedApks/${build.versionCode}`)
92 ).json();
93 // One entry per app signing key. More than one means a key rotation, so
94 // fail rather than guess which signature testers should get.
95 const universal = (generated.generatedApks ?? []).filter((entry) => entry.generatedUniversalApk);
96 if (universal.length === 0) return false;
97 if (universal.length > 1) {
98 throw new Error(`Play returned ${universal.length} universal APKs, one per signing key.`);
99 }
100 const { downloadId } = universal[0].generatedUniversalApk;
101
102 const apkPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "apk-")), apkName);
103 const download = await play(
104 token,
105 "GET",
106 `${appUrl}/generatedApks/${build.versionCode}/downloads/${encodeURIComponent(downloadId)}:download?alt=media`,
107 );
108 await pipeline(Readable.fromWeb(download.body), fs.createWriteStream(apkPath));
109
110 if (!release) {
111 // Prerelease with a non-v tag: release.yml, the desktop updater, the CLI,
112 // and the marketing site's latest-release lookup all ignore it.
113 gh(
114 "release",
115 "create",
116 tag,
117 "--prerelease",
118 "--latest=false",
119 "--title",
120 "T3 Code for Android (nightly)",
121 "--notes",
122 notes,
123 );
124 }
125 // --clobber replaces an asset a failed upload left behind.
126 gh("release", "upload", tag, apkPath, "--clobber");
127 return true;
128}
129
130async function main() {
131 const packageName = requireEnv("PACKAGE_NAME");
132 const trackName = requireEnv("PLAY_TRACK");
133 const tag = requireEnv("RELEASE_TAG");
134 const key = JSON.parse(requireEnv("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"));
135 const summary = (line) =>
136 process.env.GITHUB_STEP_SUMMARY &&
137 fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${line}\n`);
138
139 const token = await playAccessToken(key);
140 const appUrl = `${PLAY_API}/${packageName}`;
141 const build = await newestTrackBuild(token, appUrl, trackName);
142 if (!build) {
143 console.log(`The ${trackName} track has no live release.`);
144 return;
145 }
146
147 const safeName = build.versionName.replace(/[^0-9A-Za-z.]+/g, "-").replace(/^-|-$/g, "");
148 const apkName = `t3code-android-${safeName}-${build.versionCode}.apk`;
149 const notes = [
150 `T3 Code for Android that works with nightly servers, mirrored from the Google Play ${trackName} track. Current build: ${build.versionName} (${build.versionCode}).`,
151 "",
152 "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.",
153 ].join("\n");
154
155 const release = readRelease(tag);
156 const assets = release?.assets ?? [];
157 // A failed upload can leave an empty asset under the final name.
158 const published = assets.some(
159 (asset) => asset.name === apkName && asset.state === "uploaded" && asset.size > 0,
160 );
161 if (published) {
162 console.log(`${apkName} is already published.`);
163 } else if (await uploadApk({ token, appUrl, build, tag, apkName, notes, release })) {
164 console.log(`Published ${apkName}.`);
165 summary(`:white_check_mark: Published \`${apkName}\` to the \`${tag}\` release.`);
166 } else {
167 // The next run retries.
168 console.log(`Play lists no universal APK for ${build.versionCode}.`);
169 summary(
170 `:hourglass: Play lists no universal APK for ${build.versionName} (${build.versionCode}).`,
171 );
172 return;
173 }
174
175 // Reconciled on every run so an interrupted cleanup finishes on the next one.
176 for (const stale of assets) {
177 if (stale.name.endsWith(".apk") && stale.name !== apkName) {
178 gh("release", "delete-asset", tag, stale.name, "--yes");
179 }
180 }
181 if (release && release.body.replace(/\r\n/g, "\n").trim() !== notes) {
182 gh("release", "edit", tag, "--notes", notes);
183 }
184}
185
186main().catch((error) => {
187 console.error(error);
188 process.exit(1);
189});
.github/workflows/mobile-android-apk.yml created+59
...@@ -0,0 +1,59 @@
1name: Mobile Android APK
2
3# Publishes the newest nightly-compatible Android build as a sideloadable APK
4# on the rolling "android-nightly" prerelease, for nightly users who can't join
5# the Play testing group. Play re-signs uploaded bundles with the app signing
6# key, so its universal APK matches the Play Store install and needs no signing
7# secrets here. EAS submits asynchronously (--no-wait), so this polls Play
8# instead of chaining off mobile-eas-production.yml; runs with nothing new exit
9# early.
10on:
11 schedule:
12 # Avoid minute zero, when GitHub scheduled jobs are busiest.
13 - cron: "23 * * * *"
14 workflow_dispatch:
15
16concurrency:
17 group: mobile-android-apk
18 cancel-in-progress: false
19
20jobs:
21 mirror:
22 name: Mirror Play APK
23 runs-on: blacksmith-2vcpu-ubuntu-2404
24 timeout-minutes: 10
25 permissions:
26 contents: write
27 env:
28 PACKAGE_NAME: com.t3tools.t3code
29 # eas.json submits v2-preview builds to this closed testing track; they
30 # speak the orchestration protocol nightly servers use, which the Play
31 # Store release can't.
32 PLAY_TRACK: v2
33 RELEASE_TAG: android-nightly
34 steps:
35 - id: credentials
36 name: Check for Play credentials
37 env:
38 GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
39 run: |
40 if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then
41 echo "present=true" >> "$GITHUB_OUTPUT"
42 else
43 echo "present=false" >> "$GITHUB_OUTPUT"
44 echo "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is not available; skipping APK mirror."
45 fi
46
47 - name: Checkout
48 if: steps.credentials.outputs.present == 'true'
49 uses: actions/checkout@v6
50 with:
51 sparse-checkout: .github/scripts
52
53 - name: Mirror the newest APK
54 if: steps.credentials.outputs.present == 'true'
55 env:
56 GH_TOKEN: ${{ github.token }}
57 GH_REPO: ${{ github.repository }}
58 GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
59 run: node .github/scripts/mirror-play-apk.cjs
docs/operations/release.md+12
...@@ -291,6 +291,18 @@ and `VERCEL_ORG_ID` secrets. It also respects the optional `VERCEL_TEAM_SLUG`...@@ -291,6 +291,18 @@ and `VERCEL_ORG_ID` secrets. It also respects the optional `VERCEL_TEAM_SLUG`
291variable. The Vercel project's root directory must be `apps/marketing`.291variable. The Vercel project's root directory must be `apps/marketing`.
292Git deployments remain disabled in `apps/marketing/vercel.ts`.292Git deployments remain disabled in `apps/marketing/vercel.ts`.
293293
294## Android APK mirror
295
296`.github/workflows/mobile-android-apk.yml` checks Google Play hourly and publishes the newest
297build on the `v2` closed testing track to the rolling `android-nightly` prerelease, for nightly
298users who can't join the Play testing group. Those v2-preview builds are the only Android builds that can
299connect to a nightly server. The APK is the universal APK Play generates and signs from the
300uploaded bundle, so no signing key lives in GitHub. The job skips itself until
301`GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` holds a Play service account key with the "Release to
302production, exclude devices, and use Play App Signing" permission, which downloading generated
303APKs requires. The job reads tracks without opening a Play edit, so it can share the service
304account EAS Submit uses.
305
294## Hosted web app release deployment306## Hosted web app release deployment
295307
296The hosted app is intentionally not deployed by Vercel's Git integration. The308The hosted app is intentionally not deployed by Vercel's Git integration. The