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