From 41dfc7dc062a831db932e34c4cbd4c1e745537a8 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 24 Sep 2026 13:56:46 -0700 Subject: [PATCH] Finish a Play release from the bundle already uploaded for this version code instead of failing on it --- scripts/release/lib/play.mjs | 58 +++++++++++++++++++++++----------- scripts/release/steps/play.mjs | 43 +++++++++++++++++++++---- 2 files changed, 75 insertions(+), 26 deletions(-) diff --git a/scripts/release/lib/play.mjs b/scripts/release/lib/play.mjs index b30e79b0..b63c404d 100644 --- a/scripts/release/lib/play.mjs +++ b/scripts/release/lib/play.mjs @@ -33,7 +33,7 @@ const getAccessToken = async ({client_email, private_key}) => { return body.access_token } -export const uploadToPlay = async ({credentials, packageName, bundle, track, status, notes}) => { +export const play = async ({credentials, packageName}) => { const accessToken = await getAccessToken(credentials) const base = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}` @@ -48,6 +48,10 @@ export const uploadToPlay = async ({credentials, packageName, bundle, track, sta body: binary ? body : body && JSON.stringify(body), }) + if (response.status === 204) { + return undefined + } + const result = await response.json() if (!response.ok) { @@ -57,25 +61,41 @@ export const uploadToPlay = async ({credentials, packageName, bundle, track, sta return result } - const edit = await api("POST", `${base}/edits`) - const {versionCode} = await api( - "POST", - `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${packageName}/edits/${edit.id}/bundles?uploadType=media`, - bundle, - ) + return { + // Listing bundles needs an edit, which is thrown away so the build can run before the real one + bundles: async () => { + const edit = await api("POST", `${base}/edits`) - await api("PUT", `${base}/edits/${edit.id}/tracks/${track}`, { - track, - releases: [ - { - status, - versionCodes: [String(versionCode)], - releaseNotes: [{language: "en-US", text: notes}], - }, - ], - }) + try { + return (await api("GET", `${base}/edits/${edit.id}/bundles`)).bundles ?? [] + } finally { + await api("DELETE", `${base}/edits/${edit.id}`) + } + }, - await api("POST", `${base}/edits/${edit.id}:commit`) + release: async ({bundle, versionCode, track, status, notes}) => { + const edit = await api("POST", `${base}/edits`) - return versionCode + if (bundle) { + await api( + "POST", + `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${packageName}/edits/${edit.id}/bundles?uploadType=media`, + bundle, + ) + } + + await api("PUT", `${base}/edits/${edit.id}/tracks/${track}`, { + track, + releases: [ + { + status, + versionCodes: [String(versionCode)], + releaseNotes: [{language: "en-US", text: notes}], + }, + ], + }) + + await api("POST", `${base}/edits/${edit.id}:commit`) + }, + } } diff --git a/scripts/release/steps/play.mjs b/scripts/release/steps/play.mjs index 0697e827..25cc35a5 100644 --- a/scripts/release/steps/play.mjs +++ b/scripts/release/steps/play.mjs @@ -1,8 +1,13 @@ +import {createHash} from "node:crypto" +import {existsSync} from "node:fs" import {readFile} from "node:fs/promises" import {join, resolve} from "node:path" +import {spec} from "@welshman/lib" import {gradle, keystoreEnv} from "../lib/android.mjs" import {followUps, missingEnv, notes, root, version} from "../lib/context.mjs" -import {uploadToPlay} from "../lib/play.mjs" +import {play} from "../lib/play.mjs" + +const aab = join(root, "android/app/build/outputs/bundle/release/app-release.aab") export default { name: "play", @@ -24,19 +29,43 @@ export default { "PLAY_TRACK (default production) and PLAY_STATUS (default draft) are optional.", ], run: async () => { - await gradle("bundleRelease", keystoreEnv("PLAY")) - const gradleConfig = await readFile(join(root, "android/app/build.gradle"), "utf-8") + const versionCode = Number(gradleConfig.match(/versionCode (\d+)/)[1]) const track = process.env.PLAY_TRACK ?? "production" const status = process.env.PLAY_STATUS ?? "draft" - const versionCode = await uploadToPlay({ + const api = await play({ credentials: JSON.parse( await readFile(resolve(root, process.env.PLAY_SERVICE_ACCOUNT), "utf-8"), ), packageName: gradleConfig.match(/applicationId "(.+)"/)[1], - bundle: await readFile( - join(root, "android/app/build/outputs/bundle/release/app-release.aab"), - ), + }) + + // Play never takes a version code twice, so a rerun after an upload goes on to finish the + // release with that bundle. Rebuilding would change its bytes, so it only counts as this + // build while the aab on disk is the one that went up. + const uploaded = (await api.bundles()).find(spec({versionCode})) + + if (uploaded) { + const local = + existsSync(aab) && + createHash("sha256") + .update(await readFile(aab)) + .digest("hex") + + if (local !== uploaded.sha256) { + throw new Error( + `Play already has version code ${versionCode} from a build that isn't ${aab}; bump versionCode in android/app/build.gradle`, + ) + } + + console.log(`Play already has version code ${versionCode} from this build, releasing it`) + } else { + await gradle("bundleRelease", keystoreEnv("PLAY")) + } + + await api.release({ + bundle: uploaded ? undefined : await readFile(aab), + versionCode, track, status, // Play rejects release notes over 500 characters