2026-09-18 17:44:35 +00:00
|
|
|
import {createSign} from "node:crypto"
|
|
|
|
|
|
|
|
|
|
const encode = value => Buffer.from(JSON.stringify(value)).toString("base64url")
|
|
|
|
|
|
|
|
|
|
const getAccessToken = async ({client_email, private_key}) => {
|
|
|
|
|
const issued = Math.floor(Date.now() / 1000)
|
|
|
|
|
const claims = {
|
|
|
|
|
iss: client_email,
|
|
|
|
|
scope: "https://www.googleapis.com/auth/androidpublisher",
|
|
|
|
|
aud: "https://oauth2.googleapis.com/token",
|
|
|
|
|
iat: issued,
|
|
|
|
|
exp: issued + 3600,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const signed = `${encode({alg: "RS256", typ: "JWT"})}.${encode(claims)}`
|
|
|
|
|
const signature = createSign("RSA-SHA256").update(signed).sign(private_key, "base64url")
|
|
|
|
|
|
|
|
|
|
const response = await fetch("https://oauth2.googleapis.com/token", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {"Content-Type": "application/x-www-form-urlencoded"},
|
|
|
|
|
body: new URLSearchParams({
|
|
|
|
|
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
|
|
|
assertion: `${signed}.${signature}`,
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const body = await response.json()
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`Google rejected the service account: ${body.error_description ?? body.error}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return body.access_token
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 20:56:46 +00:00
|
|
|
export const play = async ({credentials, packageName}) => {
|
2026-09-18 17:44:35 +00:00
|
|
|
const accessToken = await getAccessToken(credentials)
|
|
|
|
|
const base = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}`
|
|
|
|
|
|
|
|
|
|
const api = async (method, url, body) => {
|
|
|
|
|
const binary = body instanceof Uint8Array
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
method,
|
|
|
|
|
headers: {
|
|
|
|
|
Authorization: `Bearer ${accessToken}`,
|
|
|
|
|
...(body ? {"Content-Type": binary ? "application/octet-stream" : "application/json"} : {}),
|
|
|
|
|
},
|
|
|
|
|
body: binary ? body : body && JSON.stringify(body),
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-24 20:56:46 +00:00
|
|
|
if (response.status === 204) {
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 17:44:35 +00:00
|
|
|
const result = await response.json()
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`Play API: ${result.error?.message ?? JSON.stringify(result)}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 20:56:46 +00:00
|
|
|
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`)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return (await api("GET", `${base}/edits/${edit.id}/bundles`)).bundles ?? []
|
|
|
|
|
} finally {
|
|
|
|
|
await api("DELETE", `${base}/edits/${edit.id}`)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
release: async ({bundle, versionCode, track, status, notes}) => {
|
|
|
|
|
const edit = await api("POST", `${base}/edits`)
|
|
|
|
|
|
|
|
|
|
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`)
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-09-18 17:44:35 +00:00
|
|
|
}
|