1// Play signs generated APKs with the app signing key, matching Play Store installs.
2const { execFileSync } = require("node:child_process");
3const crypto = require("node:crypto");
4const fs = require("node:fs");
5const os = require("node:os");
6const path = require("node:path");
7const { Readable } = require("node:stream");
8const { pipeline } = require("node:stream/promises");
9
10const PLAY_API = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications";
11
12function requireEnv(name) {
13 const value = process.env[name];
14 if (!value) throw new Error(`${name} is required.`);
15 return value;
16}
17
18async function playAccessToken(key) {
19 const now = Math.floor(Date.now() / 1000);
20 const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
21 const unsigned = `${encode({ alg: "RS256", typ: "JWT" })}.${encode({
22 iss: key.client_email,
23 scope: "https://www.googleapis.com/auth/androidpublisher",
24 aud: key.token_uri,
25 iat: now,
26 exp: now + 600,
27 })}`;
28 const signature = crypto.sign("RSA-SHA256", Buffer.from(unsigned), key.private_key);
29 const response = await fetch(key.token_uri, {
30 method: "POST",
31 body: new URLSearchParams({
32 grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
33 assertion: `${unsigned}.${signature.toString("base64url")}`,
34 }),
35 });
36 if (!response.ok) {
37 throw new Error(`Google token exchange failed: ${response.status} ${await response.text()}`);
38 }
39 return (await response.json()).access_token;
40}
41
42async function play(token, method, url) {
43 const response = await fetch(url, { method, headers: { authorization: `Bearer ${token}` } });
44 if (!response.ok) {
45 throw new Error(`${method} ${url} failed: ${response.status} ${await response.text()}`);
46 }
47 return response;
48}
49
50// Opening an edit would invalidate any EAS Submit edit using the same service account.
51async function newestTrackBuild(token, appUrl, trackName) {
52 const { releases = [] } = await (
53 await play(token, "GET", `${appUrl}/tracks/${trackName}/releases`)
54 ).json();
55 let newest;
56 for (const release of releases) {
57 if (release.releaseLifecycleState !== "RELEASE_LIFECYCLE_STATE_PUBLISHED") continue;
58 for (const { versionCode } of release.activeArtifacts ?? []) {
59 if (!newest || versionCode > newest.versionCode) {
60 newest = { versionCode, versionName: release.releaseName || String(versionCode) };
61 }
62 }
63 }
64 return newest;
65}
66
67function gh(...args) {
68 return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
69}
70
71function readRelease(tag) {
72 try {
73 return JSON.parse(gh("release", "view", tag, "--json", "assets,body,isDraft"));
74 } catch {
75 return undefined;
76 }
77}
78
79// Stage downloads so failed requests cannot leave partial release assets.
80async function downloadApk({ token, appUrl, build, apkName }) {
81 const generated = await (
82 await play(token, "GET", `${appUrl}/generatedApks/${build.versionCode}`)
83 ).json();
84 const universal = (generated.generatedApks ?? []).filter((entry) => entry.generatedUniversalApk);
85 if (universal.length === 0) return undefined;
86 if (universal.length > 1) {
87 throw new Error(`Play returned ${universal.length} universal APKs, one per signing key.`);
88 }
89 const { downloadId } = universal[0].generatedUniversalApk;
90 const directory = fs.mkdtempSync(path.join(os.tmpdir(), "apk-"));
91 const apkPath = path.join(directory, apkName);
92 try {
93 const download = await play(
94 token,
95 "GET",
96 `${appUrl}/generatedApks/${build.versionCode}/downloads/${encodeURIComponent(downloadId)}:download?alt=media`,
97 );
98 await pipeline(Readable.fromWeb(download.body), fs.createWriteStream(apkPath));
99 return apkPath;
100 } catch (error) {
101 fs.rmSync(directory, { recursive: true, force: true });
102 throw error;
103 }
104}
105
106function publishBeta(tag, apkPath, notes, title) {
107 const release = readRelease(tag);
108 if (
109 release &&
110 !release.isDraft &&
111 release.assets.some(
112 (asset) =>
113 asset.name === path.basename(apkPath) && asset.state === "uploaded" && asset.size > 0,
114 )
115 )
116 return;
117 if (!release) {
118 gh(
119 "release",
120 "create",
121 tag,
122 "--draft",
123 "--prerelease",
124 "--latest=false",
125 "--title",
126 title,
127 "--notes",
128 notes,
129 );
130 }
131 gh("release", "upload", tag, apkPath, "--clobber");
132 gh(
133 "release",
134 "edit",
135 tag,
136 "--draft=false",
137 "--prerelease",
138 "--latest=false",
139 "--title",
140 title,
141 "--notes",
142 notes,
143 );
144}
145
146async function main() {
147 const packageName = requireEnv("PACKAGE_NAME");
148 const outputDirectory = process.env.APK_OUTPUT_DIR;
149 const channel = outputDirectory ? "internal" : "beta";
150 const track = outputDirectory ? "internal" : "alpha";
151 const token = await playAccessToken(JSON.parse(requireEnv("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON")));
152 const appUrl = `${PLAY_API}/${packageName}`;
153 const build = await newestTrackBuild(token, appUrl, track);
154 if (!build) {
155 console.log(`The ${track} track has no live release.`);
156 return;
157 }
158 const safeName = build.versionName.replace(/[^0-9A-Za-z.]+/g, "-").replace(/^-|-$/g, "");
159 const apkName = `t3code-android-${channel}-${safeName}-${build.versionCode}.apk`;
160 const tag = `android-beta-${build.versionCode}`;
161 const release = outputDirectory ? undefined : readRelease(tag);
162 if (
163 release &&
164 !release.isDraft &&
165 release.assets.some(
166 (asset) => asset.name === apkName && asset.state === "uploaded" && asset.size > 0,
167 )
168 ) {
169 console.log(`${apkName} is already published.`);
170 return;
171 }
172 const apkPath = await downloadApk({ token, appUrl, build, apkName });
173 if (!apkPath) {
174 console.log(`Play is still processing the ${track} APK for ${build.versionCode}.`);
175 return;
176 }
177 try {
178 if (outputDirectory) {
179 fs.mkdirSync(outputDirectory, { recursive: true });
180 const destination = path.join(outputDirectory, apkName);
181 const temporary = `${destination}.tmp`;
182 try {
183 fs.copyFileSync(apkPath, temporary);
184 fs.renameSync(temporary, destination);
185 } finally {
186 fs.rmSync(temporary, { force: true });
187 }
188 return;
189 }
190 const notes = [
191 `T3 Code for Android beta, mirrored from Google Play closed testing. Build: ${build.versionName} (${build.versionCode}).`,
192 "",
193 "These builds have been promoted for wider testing, but may still contain bugs. The beta is optional; the store app also works with Nightly servers.",
194 ].join("\n");
195 // The build number makes retries and unchanged hourly checks idempotent.
196 publishBeta(tag, apkPath, notes, `Android beta ${build.versionName} (${build.versionCode})`);
197 } finally {
198 fs.rmSync(path.dirname(apkPath), { recursive: true, force: true });
199 }
200}
201
202main().catch((error) => {
203 console.error(error);
204 process.exit(1);
205});