import * as core from '@actions/core'; import * as fs from "fs"; import { readFileSync, lstatSync } from "fs"; import JSZip from 'jszip'; import path = require('path'); import { Readable } from 'stream'; import * as google from '@googleapis/androidpublisher'; import { androidpublisher_v3 } from "@googleapis/androidpublisher"; import AndroidPublisher = androidpublisher_v3.Androidpublisher; import Apk = androidpublisher_v3.Schema$Apk; import Bundle = androidpublisher_v3.Schema$Bundle; import Track = androidpublisher_v3.Schema$Track; import InternalAppSharingArtifact = androidpublisher_v3.Schema$InternalAppSharingArtifact; import { Compute } from "google-auth-library/build/src/auth/computeclient"; import { JSONClient } from "google-auth-library/build/src/auth/googleauth" import { readLocalizedReleaseNotes } from "./whatsnew"; const androidPublisher: AndroidPublisher = google.androidpublisher('v3'); export interface EditOptions { auth: Compute | JSONClient; applicationId: string; track: string; inAppUpdatePriority: number; userFraction: number; whatsNewDir?: string; mappingFile?: string; debugSymbols?: string; name?: string; status: string; changesNotSentForReview?: boolean; existingEditId?: string; } export async function uploadToPlayStore(options: EditOptions, releaseFiles: string[]): Promise { // Check the 'track' for 'internalsharing', if so switch to a non-track api if (options.track === 'internalsharing') { core.debug("Track is Internal app sharing, switch to special upload api") let downloadUrls: string[] = [] for (const releaseFile of releaseFiles) { core.debug(`Uploading ${releaseFile}`); await uploadInternalSharingRelease(options, releaseFile) .then(downloadUrl => { downloadUrls.push(downloadUrl as string) }) .catch(reason => { core.setFailed(reason); return Promise.reject(reason); }); } core.setOutput("internalSharingDownloadUrls", downloadUrls); core.exportVariable("INTERNAL_SHARING_DOWNLOAD_URLS", downloadUrls); } else { // Create a new Edit core.info(`Creating a new Edit for this release`) const appEditId = options.existingEditId || (await androidPublisher.edits.insert({ auth: options.auth, packageName: options.applicationId })).data.id // Validate the given track core.info(`Validating track '${options.track}'`) await validateSelectedTrack(appEditId!, options).catch(reason => { core.setFailed(reason); return Promise.reject(reason); }); // Upload artifacts to Google Play, and store their version codes const versionCodes = new Array(); for (const releaseFile of releaseFiles) { core.info(`Uploading ${releaseFile}`); const versionCode = await uploadRelease(appEditId!, options, releaseFile).catch(reason => { core.setFailed(reason); return Promise.reject(reason); }); versionCodes.push(versionCode!); } core.info(`Successfully uploaded ${versionCodes.length} artifacts`) // Add the uploaded artifacts to the Edit track core.info(`Adding ${versionCodes.length} artifacts to release on '${options.track}' track`) const track = await addReleasesToTrack(appEditId!, options, versionCodes); core.debug(`Track: ${track}`); // Commit the pending Edit core.info(`Committing the Edit`) const res = await androidPublisher.edits.commit({ auth: options.auth, editId: appEditId!, packageName: options.applicationId, changesNotSentForReview: options.changesNotSentForReview }); // Simple check to see whether commit was successful if (res.data.id != null) { core.info(`Successfully committed ${res.data.id}`); return Promise.resolve(res.data.id!); } else { core.setFailed(`Error ${res.status}: ${res.statusText}`); return Promise.reject(res.status); } } } async function uploadInternalSharingRelease(options: EditOptions, releaseFile: string): Promise { if (releaseFile.endsWith('.apk')) { const res = await internalSharingUploadApk(options, releaseFile) core.setOutput("internalSharingDownloadUrl", res.downloadUrl); core.exportVariable("INTERNAL_SHARING_DOWNLOAD_URL", res.downloadUrl); console.log(`${releaseFile} uploaded to Internal Sharing, download it with ${res.downloadUrl}`) return Promise.resolve(res.downloadUrl) } else if (releaseFile.endsWith('.aab')) { const res = await internalSharingUploadBundle(options, releaseFile) core.setOutput("internalSharingDownloadUrl", res.downloadUrl); core.exportVariable("INTERNAL_SHARING_DOWNLOAD_URL", res.downloadUrl); console.log(`${releaseFile} uploaded to Internal Sharing, download it with ${res.downloadUrl}`) return Promise.resolve(res.downloadUrl) } else { return Promise.reject(`${releaseFile} is invalid`) } } async function uploadRelease(appEditId: string, options: EditOptions, releaseFile: string): Promise { if (releaseFile.endsWith('.apk')) { const apk = await uploadApk(appEditId, options, releaseFile); await uploadMappingFile(appEditId, apk.versionCode!, options); await uploadDebugSymbolsFile(appEditId, apk.versionCode!, options); return Promise.resolve(apk.versionCode); } else if (releaseFile.endsWith('.aab')) { const bundle = await uploadBundle(appEditId, options, releaseFile); await uploadMappingFile(appEditId, bundle.versionCode!, options); await uploadDebugSymbolsFile(appEditId, bundle.versionCode!, options); return Promise.resolve(bundle.versionCode); } else { return Promise.reject(`${releaseFile} is invalid`); } } async function validateSelectedTrack(appEditId: string, options: EditOptions): Promise { const res = await androidPublisher.edits.tracks.list({ auth: options.auth, editId: appEditId, packageName: options.applicationId }); const allTracks = res.data.tracks; if (allTracks == undefined || allTracks.find(value => value.track == options.track) == undefined) { return Promise.reject(`Track "${options.track}" could not be found `); } } async function addReleasesToTrack(appEditId: string, options: EditOptions, versionCodes: number[]): Promise { const status = options.status core.debug(`Creating Track Release for Edit(${appEditId}) for Track(${options.track}) with a UserFraction(${options.userFraction}), Status(${status}), and VersionCodes(${versionCodes})`); const res = await androidPublisher.edits.tracks .update({ auth: options.auth, editId: appEditId, packageName: options.applicationId, track: options.track, requestBody: { track: options.track, releases: [ { name: options.name, userFraction: options.userFraction, status: status, inAppUpdatePriority: options.inAppUpdatePriority, releaseNotes: await readLocalizedReleaseNotes(options.whatsNewDir), versionCodes: versionCodes.filter(x => x != 0).map(x => x.toString()) } ] } }); return res.data; } async function uploadMappingFile(appEditId: string, versionCode: number, options: EditOptions) { if (options.mappingFile != undefined && options.mappingFile.length > 0) { const mapping = readFileSync(options.mappingFile, 'utf-8'); if (mapping != undefined) { core.debug(`[${appEditId}, versionCode=${versionCode}, packageName=${options.applicationId}]: Uploading Proguard mapping file @ ${options.mappingFile}`); await androidPublisher.edits.deobfuscationfiles.upload({ auth: options.auth, packageName: options.applicationId, editId: appEditId, apkVersionCode: versionCode, deobfuscationFileType: 'proguard', media: { mimeType: 'application/octet-stream', body: fs.createReadStream(options.mappingFile) } }) } } } async function uploadDebugSymbolsFile(appEditId: string, versionCode: number, options: EditOptions) { if (options.debugSymbols != undefined && options.debugSymbols.length > 0) { const fileStat = lstatSync(options.debugSymbols); var data: Buffer | null = null; if (fileStat.isDirectory()) { data = await createDebugSymbolZipFile(options.debugSymbols); } if (data == null) { data = readFileSync(options.debugSymbols); } if (data != null) { core.debug(`[${appEditId}, versionCode=${versionCode}, packageName=${options.applicationId}]: Uploading Debug Symbols file @ ${options.debugSymbols}`); const res = await androidPublisher.edits.deobfuscationfiles.upload({ auth: options.auth, packageName: options.applicationId, editId: appEditId, apkVersionCode: versionCode, deobfuscationFileType: 'nativeCode', media: { mimeType: 'application/octet-stream', body: Readable.from(data) } }) } } } async function zipFileAddDirectory(root: JSZip | null, dirPath: string, rootPath: string, isRootRoot: boolean) { if (root == null) return root; const newRootPath = path.join(rootPath, dirPath); const fileStat = lstatSync(newRootPath); if (!fileStat.isDirectory()) { const data = readFileSync(newRootPath); root.file(dirPath, data); return root; } const dir = fs.readdirSync(newRootPath); const zipFolder = isRootRoot ? root : root.folder(dirPath); for (var pathIndex = 0; pathIndex < dir.length; pathIndex++) { const underPath = dir[pathIndex]; await zipFileAddDirectory(zipFolder, underPath, newRootPath, false); } return root; } async function createDebugSymbolZipFile(debugSymbolsPath: string) { const zipFile = JSZip(); await zipFileAddDirectory(zipFile, ".", debugSymbolsPath, true); return zipFile.generateAsync({ type: "nodebuffer" }); } async function internalSharingUploadApk(options: EditOptions, apkReleaseFile: string): Promise { core.debug(`[packageName=${options.applicationId}]: Uploading Internal Sharing APK @ ${apkReleaseFile}`); const res = await androidPublisher.internalappsharingartifacts.uploadapk({ auth: options.auth, packageName: options.applicationId, media: { mimeType: 'application/vnd.android.package-archive', body: fs.createReadStream(apkReleaseFile) } }); return res.data; } async function internalSharingUploadBundle(options: EditOptions, bundleReleaseFile: string): Promise { core.debug(`[packageName=${options.applicationId}]: Uploading Internal Sharing Bundle @ ${bundleReleaseFile}`); const res = await androidPublisher.internalappsharingartifacts.uploadbundle({ auth: options.auth, packageName: options.applicationId, media: { mimeType: 'application/octet-stream', body: fs.createReadStream(bundleReleaseFile) } }); return res.data; } async function uploadApk(appEditId: string, options: EditOptions, apkReleaseFile: string): Promise { core.debug(`[${appEditId}, packageName=${options.applicationId}]: Uploading APK @ ${apkReleaseFile}`); const res = await androidPublisher.edits.apks.upload({ auth: options.auth, packageName: options.applicationId, editId: appEditId, media: { mimeType: 'application/vnd.android.package-archive', body: fs.createReadStream(apkReleaseFile) } }); return res.data } async function uploadBundle(appEditId: string, options: EditOptions, bundleReleaseFile: string): Promise { core.debug(`[${appEditId}, packageName=${options.applicationId}]: Uploading App Bundle @ ${bundleReleaseFile}`); const res = await androidPublisher.edits.bundles.upload({ auth: options.auth, packageName: options.applicationId, editId: appEditId, media: { mimeType: 'application/octet-stream', body: fs.createReadStream(bundleReleaseFile) } }); return res.data }