Files
pleno-vue/scripts/mobile/upload-google-play.mjs
T
Jeppe B 7782d93fe9 Gate mobile releases behind explicit phased rollout (#212)
Require explicit mobile-v* tags or manual dispatch, gate exact tested master SHAs, and default Google Play production submissions to an initial 1% in-progress rollout.
2026-07-22 18:51:40 +02:00

271 lines
8.2 KiB
JavaScript

import { createSign } from "node:crypto";
import { existsSync, readFileSync, appendFileSync } from "node:fs";
import { env, exit } from "node:process";
const androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher";
const requiredVariables = [
"ANDROID_PACKAGE_NAME",
"ANDROID_AAB_PATH",
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64",
"MOBILE_VERSION_NAME",
"MOBILE_VERSION_CODE",
];
const fail = (message) => {
console.error(message);
exit(1);
};
const requireEnvironment = () => {
const missingVariables = requiredVariables.filter((name) => !env[name]);
if (missingVariables.length > 0) {
fail(`Google Play upload is not configured. Missing: ${missingVariables.join(", ")}`);
}
if (!existsSync(env.ANDROID_AAB_PATH)) {
fail(`Android App Bundle not found at ${env.ANDROID_AAB_PATH}`);
}
};
const base64Url = (value) =>
Buffer.from(value)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
const parseServiceAccount = () => {
let serviceAccount;
try {
serviceAccount = JSON.parse(Buffer.from(env.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64, "base64").toString("utf8"));
} catch {
fail("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 must be base64-encoded service account JSON.");
}
if (!serviceAccount.client_email || !serviceAccount.private_key) {
fail("Google Play service account JSON must include client_email and private_key.");
}
return serviceAccount;
};
const createJwtAssertion = (serviceAccount) => {
const now = Math.floor(Date.now() / 1000);
const header = {
alg: "RS256",
typ: "JWT",
};
const claims = {
iss: serviceAccount.client_email,
scope: androidPublisherScope,
aud: "https://oauth2.googleapis.com/token",
exp: now + 3600,
iat: now,
};
const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(claims))}`;
const signer = createSign("RSA-SHA256");
signer.update(signingInput);
signer.end();
return `${signingInput}.${base64Url(signer.sign(serviceAccount.private_key))}`;
};
const readJsonResponse = async (response, label) => {
const text = await response.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = { raw: text };
}
}
if (!response.ok) {
const detail = body?.error?.message || body?.raw || response.statusText;
throw new Error(`${label} failed with HTTP ${response.status}: ${detail}`);
}
return body;
};
const requestJson = async (url, options, label) => {
const response = await fetch(url, options);
return readJsonResponse(response, label);
};
const getAccessToken = async (serviceAccount) => {
const body = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: createJwtAssertion(serviceAccount),
});
const tokenResponse = await requestJson(
"https://oauth2.googleapis.com/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
},
"Google OAuth token request",
);
if (!tokenResponse?.access_token) {
throw new Error("Google OAuth token response did not include access_token.");
}
return tokenResponse.access_token;
};
const playUrl = (path) => `https://androidpublisher.googleapis.com/androidpublisher/v3/${path}`;
const playUploadUrl = (path) => `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/${path}`;
const authorizedJson = (accessToken, extraHeaders = {}) => ({
Authorization: `Bearer ${accessToken}`,
...extraHeaders,
});
const insertEdit = async (accessToken, packageName) =>
requestJson(
playUrl(`applications/${encodeURIComponent(packageName)}/edits`),
{
method: "POST",
headers: authorizedJson(accessToken),
},
"Google Play edit insert",
);
const deleteEdit = async (accessToken, packageName, editId) => {
const response = await fetch(playUrl(`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}`), {
method: "DELETE",
headers: authorizedJson(accessToken),
});
if (!response.ok && response.status !== 404) {
const body = await response.text();
console.warn(`Could not delete failed Google Play edit ${editId}: HTTP ${response.status} ${body}`);
}
};
const uploadBundle = async (accessToken, packageName, editId, bundlePath) =>
requestJson(
`${playUploadUrl(
`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/bundles`,
)}?uploadType=media`,
{
method: "POST",
headers: authorizedJson(accessToken, {
"Content-Type": "application/octet-stream",
}),
body: readFileSync(bundlePath),
},
"Google Play bundle upload",
);
const updateTrack = async (accessToken, packageName, editId, versionCode) => {
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "inProgress";
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
if (!validTracks.has(track)) {
throw new Error(`Unsupported PLAY_STORE_TRACK: ${track}`);
}
if (!validStatuses.has(status)) {
throw new Error(`Unsupported PLAY_STORE_RELEASE_STATUS: ${status}`);
}
const release = {
name: env.PLAY_STORE_RELEASE_NAME || `Truck Wash ${env.MOBILE_VERSION_NAME} (${versionCode})`,
versionCodes: [String(versionCode)],
status,
};
if (status === "inProgress") {
const userFraction = Number(env.PLAY_STORE_USER_FRACTION);
if (!(userFraction > 0 && userFraction < 1)) {
throw new Error("PLAY_STORE_USER_FRACTION must be greater than 0 and less than 1 when status is inProgress.");
}
release.userFraction = userFraction;
}
return requestJson(
playUrl(
`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/tracks/${encodeURIComponent(track)}`,
),
{
method: "PUT",
headers: authorizedJson(accessToken, {
"Content-Type": "application/json",
}),
body: JSON.stringify({
track,
releases: [release],
}),
},
"Google Play track update",
);
};
const commitEdit = async (accessToken, packageName, editId) =>
requestJson(
playUrl(`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}:commit`),
{
method: "POST",
headers: authorizedJson(accessToken),
},
"Google Play edit commit",
);
const writeStepSummary = (summary) => {
if (!env.GITHUB_STEP_SUMMARY) {
return;
}
appendFileSync(env.GITHUB_STEP_SUMMARY, `${summary}\n`);
};
const writeOutput = (name, value) => {
if (env.GITHUB_OUTPUT) {
appendFileSync(env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
};
const main = async () => {
requireEnvironment();
const packageName = env.ANDROID_PACKAGE_NAME;
const serviceAccount = parseServiceAccount();
const accessToken = await getAccessToken(serviceAccount);
let editId = null;
try {
const edit = await insertEdit(accessToken, packageName);
editId = edit.id;
if (!editId) {
throw new Error("Google Play edit insert response did not include id.");
}
const bundle = await uploadBundle(accessToken, packageName, editId, env.ANDROID_AAB_PATH);
const versionCode = String(bundle?.versionCode || env.MOBILE_VERSION_CODE);
await updateTrack(accessToken, packageName, editId, versionCode);
await commitEdit(accessToken, packageName, editId);
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "inProgress";
writeOutput("play_edit_id", editId);
writeOutput("version_code", versionCode);
console.log(`Uploaded Android App Bundle ${versionCode} to Google Play ${track} with status ${status}.`);
writeStepSummary(
`Android App Bundle ${versionCode} uploaded to Google Play ${track} with status ${status}; edit ${editId}; artifact SHA-256 ${env.ANDROID_AAB_SHA256 || "missing"}.`,
);
} catch (error) {
if (editId) {
await deleteEdit(accessToken, packageName, editId);
}
throw error;
}
};
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
exit(1);
});