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