|
| 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. |
| 5 | +const { execFileSync } = require("node:child_process"); |
| 6 | +const crypto = require("node:crypto"); |
| 7 | +const fs = require("node:fs"); |
| 8 | +const os = require("node:os"); |
| 9 | +const path = require("node:path"); |
| 10 | +const { Readable } = require("node:stream"); |
| 11 | +const { pipeline } = require("node:stream/promises"); |
| 12 | + |
| 13 | +const PLAY_API = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"; |
| 14 | + |
| 15 | +function 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. |
| 22 | +async 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 | + |
| 46 | +async 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. |
| 58 | +async 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 | + |
| 74 | +function gh(...args) { |
| 75 | + return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }); |
| 76 | +} |
| 77 | + |
| 78 | +function 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. |
| 89 | +async 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 | + |
| 130 | +async 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 | + |
| 186 | +main().catch((error) => { |
| 187 | + console.error(error); |
| 188 | + process.exit(1); |
| 189 | +}); |
0 commit comments