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.
271 lines
8.2 KiB
JavaScript
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);
|
|
});
|