diff --git a/GooglePlay.ts b/GooglePlay.ts index 16b3981..67aa098 100644 --- a/GooglePlay.ts +++ b/GooglePlay.ts @@ -2,17 +2,23 @@ import * as fs from 'fs'; import * as path from 'path'; import * as tl from 'azure-pipelines-task-lib/task'; import * as glob from 'glob'; -import * as apkReader from 'adbkit-apkreader'; + import * as googleutil from './googleutil'; +import * as metadataHelper from './metadataHelper'; + +import * as googleapis from 'googleapis'; import { androidpublisher_v3 as pub3 } from 'googleapis'; -import { JWT } from 'google-auth-library'; -async function run() { +type Action = 'OnlyStoreListing' | 'SingleBundle' | 'SingleApk' | 'MultiApkAab'; + +async function run(): Promise { try { tl.setResourcePath(path.join(__dirname, 'task.json')); tl.debug('Prepare task inputs.'); + // Authentication inputs + const authType: string = tl.getInput('authType', true); let key: googleutil.ClientKey = {}; if (authType === 'JsonFile') { @@ -30,44 +36,42 @@ async function run() { key.client_email = serviceEndpoint.parameters['username']; key.private_key = serviceEndpoint.parameters['password'].replace(/\\n/g, '\n'); } - const mainApkPattern: string = tl.getPathInput('apkFile', true); - tl.debug(`Main APK pattern: ${mainApkPattern}`); - - const mainApkFile: string = resolveGlobPath(mainApkPattern); - tl.checkPath(mainApkFile, 'apkFile'); - const reader = await apkReader.open(mainApkFile); - const manifest = await reader.readManifest(); - const mainVersionCode = manifest.versionCode; - console.log(tl.loc('FoundMainApk', mainApkFile, mainVersionCode)); - tl.debug(` Found the main APK file: ${mainApkFile} (version code ${mainVersionCode}).`); - - const apkFileList: string[] = await getAllApkPaths(mainApkFile); - if (apkFileList.length > 1) { - console.log(tl.loc('FoundMultiApks')); - console.log(apkFileList); + + // General inputs + + const actionString: string = tl.getInput('action', false); + if ( + actionString !== 'MultiApkAab' + && actionString !== 'SingleBundle' + && actionString !== 'SingleApk' + && actionString !== 'OnlyStoreListing' + ) { + throw new Error(`Action input value is invalid: ${actionString}`); } + const action: Action = actionString; + tl.debug(`Action: ${action}`); - const versionCodeFilterType: string = tl.getInput('versionCodeFilterType', false) ; - let versionCodeFilter: string | number[] = null; - if (versionCodeFilterType === 'list') { - versionCodeFilter = getVersionCodeListInput(); - } else if (versionCodeFilterType === 'expression') { - versionCodeFilter = tl.getInput('replaceExpression', true); + const packageName: string = tl.getInput('applicationId', true); + tl.debug(`Application identifier: ${packageName}`); + + const bundleFileList: string[] = getBundles(action); + tl.debug(`Bundles: ${bundleFileList}`); + const apkFileList: string[] = getApks(action); + tl.debug(`APKs: ${apkFileList}`); + + const shouldPickObb: boolean = tl.getBoolInput('shouldPickObbFile', false); + + if (shouldPickObb && apkFileList.length === 0) { + throw new Error(tl.loc('MustProvideApkIfObb')); } - const track: string = tl.getInput('track', true); - const userFractionSupplied: boolean = tl.getBoolInput('rolloutToUserFraction'); - const userFraction: number = Number(userFractionSupplied ? tl.getInput('userFraction', false) : 1.0); + if (action !== 'OnlyStoreListing' && bundleFileList.length === 0 && apkFileList.length === 0) { + throw new Error(tl.loc('MustProvideApkOrAab')); + } - const updatePrioritySupplied: boolean = tl.getBoolInput('changeUpdatePriority'); - const updatePriority: number = Number(updatePrioritySupplied ? tl.getInput('updatePriority', false) : 0); + const track: string = tl.getInput('track', true); const shouldAttachMetadata: boolean = tl.getBoolInput('shouldAttachMetadata', false); - const updateStoreListing: boolean = tl.getBoolInput('updateStoreListing', false); - const shouldUploadApks: boolean = tl.getBoolInput('shouldUploadApks', false); - - const shouldPickObbFile: boolean = tl.getBoolInput('shouldPickObbFile', false); - const shouldPickObbFileForAdditonalApks: boolean = tl.getBoolInput('shouldPickObbFileForAdditonalApks', false); let changelogFile: string = null; let languageCode: string = null; @@ -80,26 +84,54 @@ async function run() { languageCode = tl.getInput('languageCode', false) || 'en-US'; } - const globalParams: googleutil.GlobalParams = { auth: null, params: {} }; - const apkVersionCodes: number[] = []; + // Advanced inputs - // The submission process is composed - // of a transaction with the following steps: + const updatePrioritySupplied: boolean = tl.getBoolInput('changeUpdatePriority'); + const updatePriority: number = Number(updatePrioritySupplied ? tl.getInput('updatePriority', false) : 0); + + const userFractionSupplied: boolean = tl.getBoolInput('rolloutToUserFraction'); + const userFraction: number = Number(userFractionSupplied ? tl.getInput('userFraction', false) : 1.0); + + const uploadMappingFile: boolean = tl.getBoolInput('shouldUploadMappingFile', false); + const mappingFilePattern: string = tl.getInput('mappingFilePath'); + + const changesNotSentForReview: boolean = tl.getBoolInput('changesNotSentForReview'); + + const releaseName: string = tl.getInput('releaseName', false); + + const versionCodeFilterType: string = tl.getInput('versionCodeFilterType', false) || 'all'; + let versionCodeFilter: string | number[] = null; + if (versionCodeFilterType === 'list') { + versionCodeFilter = getVersionCodeListInput(); + } else if (versionCodeFilterType === 'expression') { + versionCodeFilter = tl.getInput('replaceExpression', true); + } + + // Warn about unused inputs + + switch (action) { + case 'MultiApkAab': warnIfUnusedInputsSet('bundleFile', 'apkFile', 'shouldUploadMappingFile', 'mappingFilePath'); break; + case 'SingleBundle': warnIfUnusedInputsSet('apkFile', 'bundleFiles', 'apkFiles'); break; + case 'SingleApk': warnIfUnusedInputsSet('bundleFile', 'bundleFiles', 'apkFiles'); break; + case 'OnlyStoreListing': warnIfUnusedInputsSet('bundleFile', 'apkFile', 'bundleFiles', 'apkFiles', 'track'); break; + } + + // The regular submission process is composed + // of a transction with the following steps: // ----------------------------------------- - // #1) Extract the package name from the specified APK file - // #2) Get an OAuth token by authenticating the service account - // #3) Create a new editing transaction - // #4) Upload the new APK(s) - // #5) Specify the track that should be used for the new APK (e.g. alpha, beta) - // #6) Specify the new change log - // #7) Commit the edit transaction - - tl.debug(`Getting a package name from ${mainApkFile}`); - const packageName: string = manifest.package; + // #1) Get an OAuth token by authentincating the service account + // #2) Create a new editing transaction + // #3) Upload the new APK(s) or AAB(s) + // #4) Specify the track that should be used for the new APK/AAB (e.g. alpha, beta) + // #5) Specify the new change log + // #6) Commit the edit transaction + + const globalParams: googleapis.Common.GlobalOptions = { auth: null, params: {} }; + googleutil.updateGlobalParams(globalParams, 'packageName', packageName); tl.debug('Initializing JWT.'); - const jwtClient: JWT = googleutil.getJWT(key); + const jwtClient: googleapis.Common.JWT = googleutil.getJWT(key); globalParams.auth = jwtClient; tl.debug('Initializing Google Play publisher API.'); @@ -110,66 +142,76 @@ async function run() { console.log(tl.loc('GetNewEditAfterAuth')); tl.debug('Creating a new edit transaction in Google Play.'); - const edit = await googleutil.getNewEdit(edits, globalParams, packageName); + const edit: pub3.Schema$AppEdit = await googleutil.getNewEdit(edits, packageName); googleutil.updateGlobalParams(globalParams, 'editId', edit.id); let requireTrackUpdate = false; + const versionCodes: number[] = []; - if (updateStoreListing) { - tl.debug('Selected store listing update -> skip APK reading'); - } else if (shouldUploadApks) { - tl.debug(`Uploading ${apkFileList.length} APK(s).`); + if (action === 'OnlyStoreListing') { + tl.debug('Selected store listing update only -> skip APK/AAB reading'); + } else { requireTrackUpdate = true; + tl.debug(`Uploading ${bundleFileList.length} AAB(s).`); + + for (const bundleFile of bundleFileList) { + tl.debug(`Uploading bundle ${bundleFile}`); + const bundle: pub3.Schema$Bundle = await googleutil.addBundle(edits, packageName, bundleFile); + tl.debug(`Uploaded ${bundleFile} with the version code ${bundle.versionCode}`); + versionCodes.push(bundle.versionCode); + } + + tl.debug(`Uploading ${apkFileList.length} APK(s).`); + for (const apkFile of apkFileList) { tl.debug(`Uploading APK ${apkFile}`); - const apk: googleutil.Apk = await googleutil.addApk(edits, packageName, apkFile); + const apk: pub3.Schema$Apk = await googleutil.addApk(edits, packageName, apkFile); tl.debug(`Uploaded ${apkFile} with the version code ${apk.versionCode}`); - if ((shouldPickObbForApk(apkFile, mainApkFile, shouldPickObbFile, shouldPickObbFileForAdditonalApks)) && (getObbFile(apkFile, packageName, apk.versionCode) !== null)) { - const obb: googleutil.ObbResponse = await googleutil.addObb(edits, packageName, getObbFile(apkFile, packageName, apk.versionCode), apk.versionCode, 'main'); - if (obb.expansionFile.fileSize !== 0) { - console.log(`Uploaded Obb file with version code ${apk.versionCode} and size ${obb.expansionFile.fileSize}`); + + if (shouldPickObb) { + const obbFile: string | null = getObbFile(apkFile, packageName, apk.versionCode); + + if (obbFile !== null) { + const obb: pub3.Schema$ExpansionFilesUploadResponse | null = await googleutil.addObb( + edits, + packageName, + obbFile, + apk.versionCode, + 'main' + ); + + if (obb.expansionFile.fileSize !== null && Number(obb.expansionFile.fileSize) !== 0) { + console.log(`Uploaded Obb file with version code ${apk.versionCode} and size ${obb.expansionFile.fileSize}`); + } } } - apkVersionCodes.push(apk.versionCode); + versionCodes.push(apk.versionCode); } - if (apkVersionCodes.length > 0 && tl.getBoolInput('shouldUploadMappingFile', false)) { - const mappingFilePattern = tl.getPathInput('mappingFilePath', false); + if (uploadMappingFile) { tl.debug(`Mapping file pattern: ${mappingFilePattern}`); const mappingFilePath = resolveGlobPath(mappingFilePattern); - tl.checkPath(mappingFilePath, 'mappingFilePath'); + tl.checkPath(mappingFilePath, 'Mapping file path'); console.log(tl.loc('FoundDeobfuscationFile', mappingFilePath)); - tl.debug(`Uploading mapping file ${mappingFilePath}`); - await googleutil.uploadDeobfuscation(edits, mappingFilePath, packageName, apkVersionCodes[0]); - tl.debug(`Uploaded ${mappingFilePath} for APK ${mainApkFile}`); - } - } else { - tl.debug(`Getting APK version codes of ${apkFileList.length} APK(s).`); - - for (let apkFile of apkFileList) { - tl.debug(`Getting version code of APK ${apkFile}`); - const reader = await apkReader.open(apkFile); - const manifest = await reader.readManifest(); - const apkVersionCode: number = manifest.versionCode; - tl.debug(`Got APK ${apkFile} version code: ${apkVersionCode}`); - apkVersionCodes.push(apkVersionCode); + tl.debug(`Uploading ${mappingFilePath} for version code ${versionCodes[0]}`); + await googleutil.uploadDeobfuscation(edits, mappingFilePath, packageName, versionCodes[0]); } } - let releaseNotes: googleutil.ReleaseNotes[]; + let releaseNotes: googleapis.androidpublisher_v3.Schema$LocalizedText[]; if (shouldAttachMetadata) { console.log(tl.loc('AttachingMetadataToRelease')); tl.debug(`Uploading metadata from ${metadataRootPath}`); - releaseNotes = await addMetadata(edits, apkVersionCodes, metadataRootPath); - if (updateStoreListing) { + releaseNotes = await metadataHelper.addMetadata(edits, versionCodes.map((versionCode) => Number(versionCode)), metadataRootPath); + if (action === 'OnlyStoreListing') { tl.debug('Selected store listing update -> skip update track'); } - requireTrackUpdate = !updateStoreListing; + requireTrackUpdate = action !== 'OnlyStoreListing'; } else if (changelogFile) { tl.debug(`Uploading the common change log ${changelogFile} to all versions`); - const commonNotes = await getCommonReleaseNotes(languageCode, changelogFile); + const commonNotes = await metadataHelper.getCommonReleaseNotes(languageCode, changelogFile); releaseNotes = commonNotes && [commonNotes]; requireTrackUpdate = true; } @@ -177,61 +219,110 @@ async function run() { if (requireTrackUpdate) { console.log(tl.loc('UpdateTrack')); tl.debug(`Updating the track ${track}.`); - const updatedTrack: googleutil.Track = await updateTrack(edits, packageName, track, apkVersionCodes, versionCodeFilterType, versionCodeFilter, userFraction, updatePriority, releaseNotes); + const updatedTrack: pub3.Schema$Track = await updateTrack( + edits, + packageName, + track, + versionCodes, + versionCodeFilterType, + versionCodeFilter, + userFraction, + updatePriority, + releaseNotes, + releaseName + ); tl.debug('Updated track info: ' + JSON.stringify(updatedTrack)); } tl.debug('Committing the edit transaction in Google Play.'); - await edits.commit(); + await edits.commit({ changesNotSentForReview }); - if (updateStoreListing) { - console.log(tl.loc('StoreListUpdateSucceed')); - } else { - console.log(tl.loc('AptPublishSucceed')); - console.log(tl.loc('TrackInfo', track)); + console.log(tl.loc('TrackInfo', track)); + tl.setResult(tl.TaskResult.Succeeded, tl.loc('PublishSucceed')); + } catch (e) { + tl.setResult(tl.TaskResult.Failed, e); + } +} + +/** + * Gets the right bundle(s) depending on the action + * @param action user's action + * @returns a list of bundles + */ +function getBundles(action: Action): string[] { + if (action === 'SingleBundle') { + const bundlePattern: string = tl.getInput('bundleFile', true); + const bundlePath: string = resolveGlobPath(bundlePattern); + tl.checkPath(bundlePath, 'bundlePath'); + return [bundlePath]; + } else if (action === 'MultiApkAab') { + const bundlePatterns: string[] = tl.getDelimitedInput('bundleFiles', '\n'); + const allBundlePaths = new Set(); + for (const bundlePattern of bundlePatterns) { + const bundlePaths: string[] = resolveGlobPaths(bundlePattern); + bundlePaths.forEach((bundlePath) => allBundlePaths.add(bundlePath)); } + return Array.from(allBundlePaths); + } - tl.setResult(tl.TaskResult.Succeeded, tl.loc('Success')); - } catch (e) { - if (e) { - tl.debug('Exception thrown releasing to Google Play: ' + e); - } else { - tl.debug('Unknown error, no response given from Google Play'); + return []; +} + +/** + * Gets the right apk(s) depending on the action + * @param action user's action + * @returns a list of apks + */ +function getApks(action: Action): string[] { + if (action === 'SingleApk') { + const apkPattern: string = tl.getInput('apkFile', true); + const apkPath: string = resolveGlobPath(apkPattern); + tl.checkPath(apkPath, 'apkPath'); + return [apkPath]; + } else if (action === 'MultiApkAab') { + const apkPatterns: string[] = tl.getDelimitedInput('apkFiles', '\n'); + const allApkPaths = new Set(); + for (const apkPattern of apkPatterns) { + const apkPaths: string[] = resolveGlobPaths(apkPattern); + apkPaths.forEach((apkPath) => allApkPaths.add(apkPath)); } - tl.setResult(tl.TaskResult.Failed, e); + return Array.from(allApkPaths); } + + return []; } /** * Update a given release track with the given information * Assumes authorized - * @param {string} packageName unique android package name (com.android.etc) - * @param {string} track one of the values {"internal", "alpha", "beta", "production"} - * @param {number[]} apkVersionCodes version code of uploaded modules. - * @param {string} versionCodeListType type of version code replacement filter, i.e. 'all', 'list', or 'expression' - * @param {string | string[]} versionCodeFilter version code filter, i.e. either a list of version code or a regular expression string. - * @param {double} userFraction the fraction of users to get update - * @param {priority} updatePriority - In-app update priority value of the release. All newly added APKs in the release will be considered at this priority. Can take values in the range [0, 5], with 5 the highest priority. Defaults to 0. - * @param {googleutil.ReleaseNotes[]} releaseNotes optional release notes to be attached as part of the update - * @returns {Promise} track A promise that will return result from updating a track + * @param packageName unique android package name (com.android.etc) + * @param track one of the values {"internal", "alpha", "beta", "production"} + * @param bundleVersionCode version code of uploaded modules. + * @param versionCodeFilterType type of version code replacement filter, i.e. 'all', 'list', or 'expression' + * @param versionCodeFilter version code filter, i.e. either a list of version code or a regular expression string. + * @param userFraction the fraction of users to get update + * @param updatePriority - In-app update priority value of the release. All newly added APKs in the release will be considered at this priority. Can take values in the range [0, 5], with 5 the highest priority. Defaults to 0. + * @returns track A promise that will return result from updating a track * { track: string, versionCodes: [integer], userFraction: double } */ async function updateTrack( edits: pub3.Resource$Edits, packageName: string, track: string, - apkVersionCodes: number[], - versionCodeListType: string, + versionCodes: number[], + versionCodeFilterType: string, versionCodeFilter: string | number[], userFraction: number, updatePriority: number, - releaseNotes?: googleutil.ReleaseNotes[]): Promise { + releaseNotes?: pub3.Schema$LocalizedText[], + releaseName?: string +): Promise { let newTrackVersionCodes: number[] = []; - let res: googleutil.Track; + let res: pub3.Schema$Track; - if (versionCodeListType === 'all') { - newTrackVersionCodes = apkVersionCodes; + if (versionCodeFilterType === 'all') { + newTrackVersionCodes = versionCodes; } else { try { res = await googleutil.getTrack(edits, packageName, track); @@ -241,7 +332,7 @@ async function updateTrack( throw new Error(tl.loc('CannotDownloadTrack', track, e)); } - const oldTrackVersionCodes: number[] = res.releases[0].versionCodes; + const oldTrackVersionCodes: number[] = res.releases[0].versionCodes.map((v) => Number(v)); tl.debug('Current version codes: ' + JSON.stringify(oldTrackVersionCodes)); if (typeof(versionCodeFilter) === 'string') { @@ -254,7 +345,7 @@ async function updateTrack( } }); } else { - const versionCodesToRemove: number[] = versionCodeFilter as number[]; + const versionCodesToRemove = versionCodeFilter as number[]; tl.debug('Removing version codes: ' + JSON.stringify(versionCodesToRemove)); oldTrackVersionCodes.forEach((versionCode) => { @@ -265,7 +356,7 @@ async function updateTrack( } tl.debug('Version codes to keep: ' + JSON.stringify(newTrackVersionCodes)); - apkVersionCodes.forEach((versionCode) => { + versionCodes.forEach((versionCode) => { if (newTrackVersionCodes.indexOf(versionCode) === -1) { newTrackVersionCodes.push(versionCode); } @@ -274,7 +365,7 @@ async function updateTrack( tl.debug(`New ${track} track version codes: ` + JSON.stringify(newTrackVersionCodes)); try { - res = await googleutil.updateTrack(edits, packageName, track, newTrackVersionCodes, userFraction, updatePriority, releaseNotes); + res = await googleutil.updateTrack(edits, packageName, track, newTrackVersionCodes, userFraction, updatePriority, releaseNotes, releaseName); } catch (e) { tl.debug(`Failed to update track ${track}.`); tl.debug(e); @@ -285,8 +376,8 @@ async function updateTrack( /** * Get the appropriate file from the provided pattern - * @param {string} path The minimatch pattern of glob to be resolved to file path - * @returns {string} path path of the file resolved by glob + * @param path The minimatch pattern of glob to be resolved to file path + * @returns path path of the file resolved by glob. Returns null if not found or if `path` argument was not provided */ function resolveGlobPath(path: string): string { if (path) { @@ -295,17 +386,19 @@ function resolveGlobPath(path: string): string { const filesList: string[] = glob.sync(path); if (filesList.length > 0) { - path = filesList[0]; + return filesList[0]; } + + return null; } - return path; + return null; } /** * Get the appropriate files from the provided pattern - * @param {string} path The minimatch pattern of glob to be resolved to file path - * @returns {string[]} paths of the files resolved by glob + * @param path The minimatch pattern of glob to be resolved to file path + * @returns paths of the files resolved by glob */ function resolveGlobPaths(path: string): string[] { if (path) { @@ -313,10 +406,7 @@ function resolveGlobPaths(path: string): string[] { path = tl.resolve(tl.getVariable('System.DefaultWorkingDirectory'), path); let filesList: string[] = glob.sync(path); - if (filesList.length === 0) { - filesList.push(path); - } - tl.debug(`Additional APK paths: ${JSON.stringify(filesList)}`); + tl.debug(`Additional paths: ${JSON.stringify(filesList)}`); return filesList; } @@ -324,15 +414,51 @@ function resolveGlobPaths(path: string): string[] { return []; } +function getVersionCodeListInput(): number[] { + const versionCodeFilterInput: string[] = tl.getDelimitedInput('replaceList', ',', false); + const versionCodeFilter: number[] = []; + const incorrectCodes: string[] = []; + + for (const versionCode of versionCodeFilterInput) { + const versionCodeNumber: number = parseInt(versionCode.trim(), 10); + + if (versionCodeNumber && (versionCodeNumber > 0)) { + versionCodeFilter.push(versionCodeNumber); + } else { + incorrectCodes.push(versionCode.trim()); + } + } + + if (incorrectCodes.length > 0) { + throw new Error(tl.loc('IncorrectVersionCodeFilter', JSON.stringify(incorrectCodes))); + } else { + return versionCodeFilter; + } +} + +/** + * If any of the provided inputs are set, it will show a warning + * @param inputs inputs to check + */ +function warnIfUnusedInputsSet(...inputs: string[]): void { + for (const input of inputs) { + tl.debug(`Checking if unused input ${input} is set...`); + const inputValue: string | undefined = tl.getInput(input); + if (inputValue !== undefined && inputValue.length !== 0) { + tl.warning(tl.loc('SetUnusedInput', input)); + } + } +} + /** * Get obb file. Returns any file with .obb extension if present in parent directory else returns * from apk directory with pattern: main...obb - * @param {string} apkPath apk file path - * @param {string} packageName package name of the apk - * @param {string} versionCode version code of the apk - * @returns {string} ObbPathFile of the obb file if present else null + * @param apkPath apk file path + * @param packageName package name of the apk + * @param versionCode version code of the apk + * @returns ObbPathFile of the obb file is present else null */ -function getObbFile(apkPath: string, packageName: string, versionCode: number): string { +function getObbFile(apkPath: string, packageName: string, versionCode: number): string | null { const currentDirectory: string = path.dirname(apkPath); const parentDirectory: string = path.dirname(currentDirectory); @@ -353,74 +479,8 @@ function getObbFile(apkPath: string, packageName: string, versionCode: number): return path.join(currentDirectory, obbPathFileInCurrent); } else { tl.debug(`No Obb found for ${apkPath}, skipping upload`); + return null; } - - return obbPathFileInCurrent; -} - -/** - * Get unique APK file paths from main and additional APK file inputs. - * @returns {string[]} paths of the files - */ -async function getAllApkPaths(mainApkFile: string): Promise { - const apkFileList: { [key: string]: number } = {}; - - apkFileList[mainApkFile] = 0; - - const additionalApks: string[] = tl.getDelimitedInput('additionalApks', '\n'); - for (const additionalApk of additionalApks) { - tl.debug(`Additional APK pattern: ${additionalApk}`); - const apkPaths: string[] = resolveGlobPaths(additionalApk); - - for (const apkPath of apkPaths) { - apkFileList[apkPath] = 0; - tl.debug(`Checking additional APK ${apkPath} version...`); - const reader = await apkReader.open(apkPath); - const manifest = await reader.readManifest(); - tl.debug(` Found the additional APK file: ${apkPath} (version code ${manifest.versionCode}).`); - } - } - - return Object.keys(apkFileList); -} - -function getVersionCodeListInput(): number[] { - const versionCodeFilterInput: string[] = tl.getDelimitedInput('replaceList', ',', false); - const versionCodeFilter: number[] = []; - const incorrectCodes: string[] = []; - - for (const versionCode of versionCodeFilterInput) { - const versionCodeNumber: number = parseInt(versionCode.trim(), 10); - - if (versionCodeNumber && (versionCodeNumber > 0)) { - versionCodeFilter.push(versionCodeNumber); - } else { - incorrectCodes.push(versionCode.trim()); - } - } - - if (incorrectCodes.length > 0) { - throw new Error(tl.loc('IncorrectVersionCodeFilter', JSON.stringify(incorrectCodes))); - } else { - return versionCodeFilter; - } -} - -function shouldPickObbForApk(apk: string, mainApk: string, shouldPickObbFile: boolean, shouldPickObbFileForAdditonalApks: boolean): boolean { - - if ((apk === mainApk) && shouldPickObbFile) { - return true; - } else if ((apk !== mainApk) && shouldPickObbFileForAdditonalApks) { - return true; - } - return false; } -// Future features: -// ---------------- -// 1) Adding testers -// 2) Adding new images -// 3) Adding expansion files -// 4) Updating contact info - run(); diff --git a/googleutil.ts b/googleutil.ts index ce041f5..c0a6b91 100644 --- a/googleutil.ts +++ b/googleutil.ts @@ -1,133 +1,34 @@ -// common code shared by all tasks import * as fs from 'fs'; -import * as tl from 'azure-pipelines-task-lib/task'; -import { google } from 'googleapis'; -import { JWT } from 'google-auth-library'; +import * as tl from 'azure-pipelines-task-lib'; +import * as googleapis from 'googleapis'; +import { androidpublisher_v3 as pub3 } from 'googleapis'; // Short alias for convenience -export const publisher = google.androidpublisher('v3'); +export const publisher: pub3.Androidpublisher = googleapis.google.androidpublisher('v3'); export interface ClientKey { client_email?: string; private_key?: string; } -export interface Apk { - versionCode: number; - binary: { - sha1: string; - }; -} - -export interface Obb { - referencesVersion: number; - fileSize: number; -} - -export interface ObbResponse { - expansionFile: Obb; -} - -export interface AndroidRelease { - name?: string; - userFraction?: number; - releaseNotes?: ReleaseNotes[]; - versionCodes?: [number]; - status?: string; - inAppUpdatePriority?: number; -} - -export interface AndroidMedia { - body: fs.ReadStream; - mimeType: string; -} - -export interface AndroidResource { - track?: string; - releases?: AndroidRelease[]; -} - -export interface AndroidListingResource { - language?: string; - title?: string; - fullDescription?: string; - shortDescription?: string; - video?: string; -} - -export interface Edit { - id: string; - expiryTimeSeconds: string; -} - -export interface ObbRequest { - packageName?: string; - media?: AndroidMedia; - apkVersionCode?: number; - expansionFileType?: string; -} - -export interface PackageParams { - packageName?: string; - editId?: any; - track?: string; - resource?: AndroidResource; // 'resource' goes into the 'body' of the http request - media?: AndroidMedia; - apkVersionCode?: number; - language?: string; - imageType?: string; - uploadType?: string; -} - -export interface PackageListingParams { - packageName?: string; - editId?: any; - track?: string; - resource?: AndroidListingResource; // 'resource' goes into the 'body' of the http request - media?: AndroidMedia; - apkVersionCode?: number; - language?: string; - imageType?: string; - uploadType?: string; -} - -export interface ReleaseNotes { - language?: string; - text?: string; -} - -export interface Release { - name?: string; - versionCodes?: number[]; - userFraction?: number; - releaseNotes: ReleaseNotes[]; - status?: string; -} - -export interface Track { - track: string; - releases: Release[]; -} - -export interface GlobalParams { - auth?: any; - params?: PackageParams; -} - -export function getJWT(key: ClientKey): JWT { +/** + * @param key an object containing client email and private key + * @returns JWT service account credentials. + */ +export function getJWT(key: ClientKey): googleapis.Auth.JWT { const GOOGLE_PLAY_SCOPES: string[] = ['https://www.googleapis.com/auth/androidpublisher']; - return new JWT(key.client_email, null, key.private_key, GOOGLE_PLAY_SCOPES, null); + return new googleapis.Auth.JWT(key.client_email, null, key.private_key, GOOGLE_PLAY_SCOPES, null); } /** * Uses the provided JWT client to request a new edit from the Play store and attach the edit id to all requests made this session * Assumes authorized - * @param {string} packageName - unique android package name (com.android.etc) - * @return {Promise} edit - A promise that will return result from inserting a new edit + * @param packageName - unique android package name (com.android.etc) + * @return edit - A promise that will return result from inserting a new edit * { id: string, expiryTimeSeconds: string } */ -export async function getNewEdit(edits: any, globalParams: GlobalParams, packageName: string): Promise { + export async function getNewEdit(edits: pub3.Resource$Edits, packageName: string): Promise { tl.debug('Creating a new edit'); - const requestParameters: PackageParams = { + const requestParameters: pub3.Params$Resource$Edits$Insert = { packageName: packageName }; @@ -139,14 +40,14 @@ export async function getNewEdit(edits: any, globalParams: GlobalParams, package /** * Gets information for the specified app and track * Assumes authorized - * @param {string} packageName - unique android package name (com.android.etc) - * @param {string} track - one of the values {"internal", "alpha", "beta", "production"} - * @returns {Promise} track - A promise that will return result from updating a track + * @param packageName - unique android package name (com.android.etc) + * @param track - one of the values {"internal", "alpha", "beta", "production"} + * @returns track - A promise that will return result from updating a track * { track: string, versionCodes: [integer], userFraction: double } */ -export async function getTrack(edits: any, packageName: string, track: string): Promise { +export async function getTrack(edits: pub3.Resource$Edits, packageName: string, track: string): Promise { tl.debug('Getting Track information'); - const requestParameters: PackageParams = { + const requestParameters: pub3.Params$Resource$Edits$Tracks$Get = { packageName: packageName, track: track }; @@ -157,23 +58,39 @@ export async function getTrack(edits: any, packageName: string, track: string): } /** - * Update a given release track with the given information - * Assumes authorized - * @param {string} packageName - unique android package name (com.android.etc) - * @param {string} track - one of the values {"internal", "alpha", "beta", "production"} - * @param {integer or [integers]} versionCode - version code returned from an apk call. will take either a number or a [number] - * @param {double} userFraction - for rollouting out a release to a track, it's the fraction of users to get update 1.0 is all users - * @param {number} updatePriority - In-app update priority value of the release. All newly added APKs in the release will be considered at this priority. Can take values in the range [0, 5], with 5 the highest priority. Defaults to 0. - * @param {releaseNotes} releaseNotes - optional release notes to be attached as part of the update - * @returns {Promise} track - A promise that will return result from updating a track + * @param edits Google API Edits + * @param packageName unique android package name (com.android.etc) + * @param track release track. Should be one of {"internal", "alpha", "beta", "production"} + * @param versionCodes version codes that will be exposed to the users of this track when this release is rolled out + * @param userFraction for rollouting out a release to a track, it's the fraction of users to get update; 1.0 is all users + * @param updatePriority in-app update priority value of the release. All newly added APKs in the release will be considered at this priority. Can take values in the range [0, 5], with 5 the highest priority. Defaults to 0. + * @param releaseNotes optional release notes to be attached as part of the update + * @param releaseName optional release name. If not set, the name is generated from the APK's version_name. If the release contains multiple APKs, the name is generated from the date + * @returns track - A promise that will return result from updating a track * { track: string, versionCodes: [integer], userFraction: double } */ -export async function updateTrack(edits: any, packageName: string, track: string, versionCode: any, userFraction: number, updatePriority: number, releaseNotes?: ReleaseNotes[]): Promise { - const release: AndroidRelease = { - versionCodes: (typeof versionCode === 'number' ? [versionCode] : versionCode), +export async function updateTrack( + edits: pub3.Resource$Edits, + packageName: string, + track: string, + versionCodes: number | number[], + userFraction: number, + updatePriority: number, + releaseNotes?: pub3.Schema$LocalizedText[], + releaseName?: string +): Promise { + tl.debug('Updating track'); + const versionCodesArray: number[] = (Array.isArray(versionCodes) ? versionCodes : [versionCodes]); + const release: pub3.Schema$TrackRelease = { + versionCodes: versionCodesArray.map((versionCode) => versionCode.toString()), inAppUpdatePriority: updatePriority }; + if (releaseName && releaseName.length > 0) { + tl.debug('Add release name: ' + releaseName); + release.name = releaseName; + } + if (releaseNotes && releaseNotes.length > 0) { tl.debug('Attaching release notes to the update'); release.releaseNotes = releaseNotes; @@ -187,113 +104,149 @@ export async function updateTrack(edits: any, packageName: string, track: string release.status = 'completed'; } - const requestParameters: PackageParams = { + const requestParameters: pub3.Params$Resource$Edits$Tracks$Update = { packageName: packageName, track: track, - resource: { - track: track, + requestBody: { + track, releases: [release] } }; tl.debug('Additional Parameters: ' + JSON.stringify(requestParameters)); - - tl.debug('Updating track'); const updatedTrack = await edits.tracks.update(requestParameters); - return updatedTrack.data; } /** * Update the universal parameters attached to every request - * @param {string} paramName - Name of parameter to add/update - * @param {any} value - value to assign to paramName. Any value is admissible. - * @returns {void} void + * @param paramName - Name of parameter to add/update + * @param value - value to assign to paramName. Any value is admissible. */ -export function updateGlobalParams(globalParams: GlobalParams, paramName: string, value: any): void { +export function updateGlobalParams(globalParams: googleapis.Common.GlobalOptions, paramName: string, value: any): void { tl.debug('Updating Global Parameters'); tl.debug('SETTING ' + paramName + ' TO ' + JSON.stringify(value)); globalParams.params[paramName] = value; - google.options(globalParams); + googleapis.google.options(globalParams); tl.debug('Global Params set to ' + JSON.stringify(globalParams)); } +/** + * Adds a bundle to an existing edit + * Assumes authorized + * @param packageName unique android package name (com.android.etc) + * @param bundleFile path to bundle file + * @returns A promise that will return result from uploading a bundle + * { versionCode: integer, binary: { sha1: string } } + */ +export async function addBundle(edits: pub3.Resource$Edits, packageName: string, bundleFile: string): Promise { + let requestParameters: pub3.Params$Resource$Edits$Bundles$Upload = { + packageName: packageName, + media: { + body: fs.createReadStream(bundleFile), + mimeType: 'application/octet-stream' + } + }; + + try { + tl.debug('Request Parameters: ' + JSON.stringify(requestParameters)); + const res = await edits.bundles.upload(requestParameters, { onUploadProgress }); + tl.debug('Returned: ' + JSON.stringify(res)); + return res.data; + } catch (e) { + tl.debug(`Failed to upload Bundle ${bundleFile}`); + tl.debug(e); + throw new Error(tl.loc('CannotUploadBundle', bundleFile, e)); + } +} + /** * Adds an apk to an existing edit * Assumes authorized - * @param {string} packageName unique android package name (com.android.etc) - * @param {string} apkFile path to apk file - * @returns {Promise} apk A promise that will return result from uploading an apk + * @param packageName unique android package name (com.android.etc) + * @param apkFile path to apk file + * @returns A promise that will return result from uploading an apk * { versionCode: integer, binary: { sha1: string } } */ -export async function addApk(edits: any, packageName: string, apkFile: string): Promise { - let requestParameters: PackageParams = { + export async function addApk(edits: pub3.Resource$Edits, packageName: string, apkFile: string): Promise { + let requestParameters: pub3.Params$Resource$Edits$Apks$Upload = { packageName: packageName, media: { body: fs.createReadStream(apkFile), - mimeType: 'application/vnd.android.package-archive' + mimeType: 'application/octet-stream' } }; try { tl.debug('Request Parameters: ' + JSON.stringify(requestParameters)); - const res: Apk = (await edits.apks.upload(requestParameters)).data; - tl.debug('returned: ' + JSON.stringify(res)); - return res; + const res = await edits.apks.upload(requestParameters, { onUploadProgress }); + tl.debug('Returned: ' + JSON.stringify(res)); + return res.data; } catch (e) { - tl.debug(`Failed to upload the APK ${apkFile}`); + tl.debug(`Failed to upload APK ${apkFile}`); tl.debug(e); - throw new Error(tl.loc('CannotUploadApk', apkFile, e)); + throw new Error(tl.loc('CannotUploadAPK', apkFile, e)); } } /** * Adds an obb for an apk to an existing edit * Assumes authorized - * @param {string} packageName unique android package name (com.android.etc) - * @param {string} obbFile path to obb file - * @param {string} obbVersionCode version code of the corresponding apk - * @param {string} obbFileType type of obb to be uploaded (main/patch) - * @returns {Promise} ObbResponse A promise that will return result from uploading an obb + * @param packageName unique android package name (com.android.etc) + * @param obbFile path to obb file + * @param apkVersionCode version code of the corresponding apk + * @param obbFileType type of obb to be uploaded (main/patch) + * @returns ObbResponse A promise that will return result from uploading an obb * { expansionFile: { referencesVersion: number, fileSize: number } } */ -export async function addObb(edits: any, packageName: string, obbFile: string, obbVersionCode: number, obbFileType: string): Promise { - const requestParameters: ObbRequest = { +export async function addObb( + edits: pub3.Resource$Edits, + packageName: string, + obbFile: string, + apkVersionCode: number, + obbFileType: string +): Promise { + const requestParameters: pub3.Params$Resource$Edits$Expansionfiles$Upload = { packageName: packageName, media: { body: fs.createReadStream(obbFile), mimeType: 'application/octet-stream' }, - apkVersionCode: obbVersionCode, + apkVersionCode: apkVersionCode, expansionFileType: obbFileType }; try { tl.debug('Request Parameters: ' + JSON.stringify(requestParameters)); - const res: ObbResponse = ( await edits.expansionfiles.upload(requestParameters)).data; + const res = await edits.expansionfiles.upload(requestParameters, { onUploadProgress }); tl.debug('returned: ' + JSON.stringify(res)); - return res; + return res.data; } catch (e) { tl.debug(`Failed to upload the Obb ${obbFile}`); tl.debug(e); - throw new Error(e); + throw new Error(tl.loc('CannotUploadExpansionFile', obbFile, e)); } } /** * Uploads a deobfuscation file (mapping.txt) for a given package * Assumes authorized - * @param {string} mappingFilePath the path to the file to upload - * @param {string} packageName unique android package name (com.android.etc) - * @param apkVersionCode version code of uploaded APK - * @returns {Promise} deobfuscationFiles A promise that will return result from uploading a deobfuscation file + * @param mappingFilePath the path to the file to upload + * @param packageName unique android package name (com.android.etc) + * @param versionCode version code of uploaded APK or AAB + * @returns deobfuscationFiles A promise that will return result from uploading a deobfuscation file * { deobfuscationFile: { symbolType: string } } */ -export async function uploadDeobfuscation(edits: any, mappingFilePath: string, packageName: string, apkVersionCode: number): Promise { - const requestParameters = { +export async function uploadDeobfuscation( + edits: pub3.Resource$Edits, + mappingFilePath: string, + packageName: string, + versionCode: number +): Promise { + const requestParameters: pub3.Params$Resource$Edits$Deobfuscationfiles$Upload = { deobfuscationFileType: 'proguard', packageName: packageName, - apkVersionCode: apkVersionCode, + apkVersionCode: versionCode, media: { body: fs.createReadStream(mappingFilePath), mimeType: '' @@ -302,11 +255,20 @@ export async function uploadDeobfuscation(edits: any, mappingFilePath: string, p try { tl.debug('Request Parameters: ' + JSON.stringify(requestParameters)); - const res = (await edits.deobfuscationfiles.upload(requestParameters)).data; + const res = await edits.deobfuscationfiles.upload(requestParameters, { onUploadProgress }); tl.debug('returned: ' + JSON.stringify(res)); + return res.data; } catch (e) { tl.debug(`Failed to upload deobfuscation file ${mappingFilePath}`); tl.debug(e); throw new Error(tl.loc('CannotUploadDeobfuscationFile', mappingFilePath, e)); } } + +/** + * Default logger for uploading files + * @param progress progress update from googleapis + */ +function onUploadProgress(progress: any): void { + tl.debug('Upload progress: ' + JSON.stringify(progress)); +} diff --git a/metadataHelper.ts b/metadataHelper.ts index 57b0315..0083fbc 100644 --- a/metadataHelper.ts +++ b/metadataHelper.ts @@ -5,15 +5,15 @@ import * as tl from 'azure-pipelines-task-lib/task'; import { androidpublisher_v3 as pub3 } from 'googleapis'; /** - * Uploads change log files if specified for all the apk version codes in the update + * Uploads change log files if specified for all the version codes in the update * @param changelogFile - * @param apkVersionCodes + * @param versionCodes * @returns nothing */ - async function getCommonReleaseNotes(languageCode: string, changelogFile: string): Promise { +export async function getCommonReleaseNotes(languageCode: string, changelogFile: string): Promise { const stats: tl.FsStats = tl.stats(changelogFile); - let releaseNotes: googleutil.ReleaseNotes = null; + let releaseNotes: pub3.Schema$LocalizedText = null; if (stats && stats.isFile()) { console.log(tl.loc('AppendChangelog', changelogFile)); releaseNotes = { @@ -45,13 +45,13 @@ function getChangelog(changelogFile: string): string { } /** - * Adds all release notes found in directory to an edit. Pulls version code from file name. Failing this, assumes the global version code inferred from apk + * Adds all release notes found in directory to an edit. Pulls version code from file name. * Assumes authorized * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update * @param {string} directory Directory with a changesogs folder where release notes can be found. * @returns nothing */ -async function addAllReleaseNotes(apkVersionCodes: number[], languageCode: string, directory: string): Promise { +async function addAllReleaseNotes(versionCodes: number[], languageCode: string, directory: string): Promise { const changelogDir: string = path.join(directory, 'changelogs'); const changelogs: string[] = filterDirectoryContents(changelogDir, stat => stat.isFile()); @@ -60,11 +60,11 @@ async function addAllReleaseNotes(apkVersionCodes: number[], languageCode: strin return []; } - const releaseNotes: googleutil.ReleaseNotes[] = []; + const releaseNotes: pub3.Schema$LocalizedText[] = []; for (const changelogFile of changelogs) { const changelogName: string = path.basename(changelogFile, path.extname(changelogFile)); const changelogVersion: number = parseInt(changelogName, 10); - if (!isNaN(changelogVersion) && (apkVersionCodes.indexOf(changelogVersion) !== -1)) { + if (!isNaN(changelogVersion) && (versionCodes.indexOf(changelogVersion) !== -1)) { const fullChangelogPath: string = path.join(changelogDir, changelogFile); console.log(tl.loc('AppendChangelog', fullChangelogPath)); @@ -135,16 +135,16 @@ function filterDirectoryContents(directory: string, filter: (stats: tl.FsStats) * @param {string} metadataRootDirectory Path to the folder where the Fastlane metadata structure is found. eg the folders under this directory should be the language codes * @returns nothing */ -async function addMetadata(edits: pub3.Resource$Edits, apkVersionCodes: number[], metadataRootDirectory: string): Promise { +export async function addMetadata(edits: pub3.Resource$Edits, versionCodes: number[], metadataRootDirectory: string): Promise { const metadataLanguageCodes: string[] = filterDirectoryContents(metadataRootDirectory, stat => stat.isDirectory()); tl.debug(`Found language codes: ${metadataLanguageCodes}`); - let allReleaseNotes: googleutil.ReleaseNotes[] = []; + let allReleaseNotes: pub3.Schema$LocalizedText[] = []; for (const languageCode of metadataLanguageCodes) { const metadataDirectory: string = path.join(metadataRootDirectory, languageCode); - tl.debug(`Uploading metadata from ${metadataDirectory} for language code ${languageCode} and version codes ${apkVersionCodes}`); - const releaseNotesForLanguage = await uploadMetadataWithLanguageCode(edits, apkVersionCodes, languageCode, metadataDirectory); + tl.debug(`Uploading metadata from ${metadataDirectory} for language code ${languageCode} and version codes ${versionCodes}`); + const releaseNotesForLanguage = await uploadMetadataWithLanguageCode(edits, versionCodes, languageCode, metadataDirectory); allReleaseNotes = allReleaseNotes.concat(releaseNotesForLanguage); } @@ -159,14 +159,14 @@ async function addMetadata(edits: pub3.Resource$Edits, apkVersionCodes: number[] * @param {string} directory Directory where updated listing details can be found. * @returns nothing */ -async function uploadMetadataWithLanguageCode(edits: pub3.Resource$Edits, apkVersionCodes: number[], languageCode: string, directory: string): Promise { +async function uploadMetadataWithLanguageCode(edits: pub3.Resource$Edits, versionCodes: number[], languageCode: string, directory: string): Promise { console.log(tl.loc('UploadingMetadataForLanguage', directory, languageCode)); tl.debug(`Adding localized store listing for language code ${languageCode} from ${directory}`); await addLanguageListing(edits, languageCode, directory); tl.debug(`Uploading change logs for language code ${languageCode} from ${directory}`); - const releaseNotes: googleutil.ReleaseNotes[] = await addAllReleaseNotes(apkVersionCodes, languageCode, directory); + const releaseNotes: pub3.Schema$LocalizedText[] = await addAllReleaseNotes(versionCodes, languageCode, directory); tl.debug(`Uploading images for language code ${languageCode} from ${directory}`); await attachImages(edits, languageCode, directory); @@ -182,7 +182,7 @@ async function uploadMetadataWithLanguageCode(edits: pub3.Resource$Edits, apkVer * @returns nothing */ async function addLanguageListing(edits: pub3.Resource$Edits, languageCode: string, directory: string) { - const listingResource: googleutil.AndroidListingResource = createListingResource(languageCode, directory); + const listingResource: pub3.Schema$Listing = createListingResource(languageCode, directory); const isPatch:boolean = (!listingResource.fullDescription) || (!listingResource.shortDescription) || @@ -193,9 +193,9 @@ async function addLanguageListing(edits: pub3.Resource$Edits, languageCode: stri (!listingResource.video) && (!listingResource.title); - const listingRequestParameters: googleutil.PackageListingParams = { + const listingRequestParameters: pub3.Params$Resource$Edits$Listings$Patch = { language: languageCode, - resource: listingResource + requestBody: listingResource }; try { @@ -229,7 +229,7 @@ async function addLanguageListing(edits: pub3.Resource$Edits, languageCode: stri * @returns {AndroidListingResource} resource A crafted resource for the edits.listings.update method. * { languageCode: string, fullDescription: string, shortDescription: string, title: string, video: string } */ -function createListingResource(languageCode: string, directory: string): googleutil.AndroidListingResource { +function createListingResource(languageCode: string, directory: string): pub3.Schema$Listing { tl.debug(`Constructing resource to update listing with language code ${languageCode} from ${directory}`); const resourceParts = { @@ -239,7 +239,7 @@ function createListingResource(languageCode: string, directory: string): googleu video: 'video.txt' }; - const resource: googleutil.AndroidListingResource = { + const resource: pub3.Schema$Listing = { language: languageCode }; @@ -301,7 +301,7 @@ async function attachImages(edits: pub3.Resource$Edits, languageCode: string, di */ async function removeOldImages(edits: pub3.Resource$Edits, languageCode: string, imageType: string) { try { - let imageRequest: googleutil.PackageParams = { + let imageRequest: pub3.Params$Resource$Edits$Images$Deleteall = { language: languageCode, imageType: imageType }; @@ -432,12 +432,12 @@ function getImageList(directory: string): { [key: string]: string[] } { * @returns nothing */ async function uploadImage(edits: pub3.Resource$Edits, languageCode: string, imageType: string, imagePath: string) { - const imageRequest: googleutil.PackageParams = { + // Docs at https://developers.google.com/android-publisher/api-ref/edits/images/upload + const imageRequest: pub3.Params$Resource$Edits$Images$Upload = { language: languageCode, imageType: imageType }; - - imageRequest.uploadType = 'media'; + // imageRequest.uploadType = 'media'; imageRequest.media = { body: fs.createReadStream(imagePath), mimeType: helperResolveImageMimeType(imagePath) diff --git a/task.json b/task.json index 6f2bc29..941dca2 100644 --- a/task.json +++ b/task.json @@ -9,15 +9,14 @@ "Build", "Release" ], - "demands": [ - "npm" - ], + "demands": [], "version": { - "Major": "3", + "Major": "4", "Minor": "193", "Patch": "0" }, "minimumAgentVersion": "2.182.1", + "instanceNameFormat": "Release $(applicationId) to $(track)", "groups": [ { "name": "advanced", @@ -25,13 +24,12 @@ "isExpanded": false } ], - "instanceNameFormat": "Release $(apkFile) to $(track)", "inputs": [ { "name": "authType", - "type": "pickList", "label": "Authentication method", "defaultValue": "ServiceEndpoint", + "type": "pickList", "helpMarkDown": "", "options": { "JsonFile": "JSON Auth File", @@ -43,45 +41,94 @@ "aliases": [ "serviceConnection" ], - "type": "connectedService:google-play", "label": "Service connection", "defaultValue": "", "required": true, + "type": "connectedService:google-play", "helpMarkDown": "Google Play service connection that is configured with your account credentials.", "visibleRule": "authType = ServiceEndpoint" }, { "name": "serviceAccountKey", - "type": "filePath", "label": "JSON key path", "defaultValue": "", "required": true, - "helpMarkDown": "The JSON file provided by Google Play that includes the service account's identity you wish to publish your APK under.", + "type": "filePath", + "helpMarkDown": "The JSON file provided by Google Play that includes the service account's identity you wish to publish your APKs or AABs under.", "visibleRule": "authType = JsonFile" }, + { + "name": "applicationId", + "label": "Application id (com.google.MyApp)", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "The application id of APK or AAB you want to release, e.g. com.company.MyApp." + }, + { + "name": "action", + "label": "Action", + "defaultValue": "SingleBundle", + "required": true, + "type": "pickList", + "helpMarkDown": "", + "options": { + "OnlyStoreListing": "Only update store listing", + "SingleBundle": "Upload single bundle", + "SingleApk": "Upload single apk", + "MultiApkAab": "Upload multiple apk/aab files" + } + }, + { + "name": "bundleFile", + "label": "Bundle path", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "Path to the bundle file you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.aab_ to match the first AAB file, in any directory.", + "visibleRule": "action = SingleBundle" + }, { "name": "apkFile", - "type": "filePath", "label": "APK path", "defaultValue": "", "required": true, - "helpMarkDown": "Path to the APK file you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.apk_ to match the first APK file, in any directory." + "type": "string", + "helpMarkDown": "Path to the APK file you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.apk_ to match the first APK file, in any directory.", + "visibleRule": "action = SingleApk" + }, + { + "name": "bundleFiles", + "label": "Bundle paths", + "defaultValue": "", + "type": "multiLine", + "helpMarkDown": "Paths to the bundle files you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.aab_ to match all AAB files, in any directory.", + "visibleRule": "action = MultiApkAab" + }, + { + "name": "apkFiles", + "label": "APK paths", + "defaultValue": "", + "type": "multiLine", + "helpMarkDown": "Paths to the APK files you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.apk_ to match all APK files, in any directory.", + "visibleRule": "action = MultiApkAab" }, { "name": "shouldPickObbFile", - "type": "boolean", - "label": "Upload Obb", + "label": "Upload OBB for APK", "defaultValue": false, - "required": false, - "helpMarkDown": "Select this option to pick obb file for the apk. If present in the parent directory, it will pick the first file with .obb extension, else it will pick from apk directory with expected format as main...obb" + "type": "boolean", + "helpMarkDown": "Select this option to pick expansion file for the apk(s). If present in the parent directory, it will pick the first file with .obb extension, else it will pick from apk directory with expected format as main...obb", + "visibleRule": "action = SingleApk || action = MultiApkAab" }, { "name": "track", - "type": "pickList", "label": "Track", "defaultValue": "internal", "required": true, - "helpMarkDown": "Track you want to publish the APK to.", + "type": "pickList", + "helpMarkDown": "Track you want to publish the apk(s)/aab(s) to.", + "visibleRule": "action != OnlyStoreListing", "options": { "internal": "Internal test", "alpha": "Alpha", @@ -92,143 +139,125 @@ "EditableOptions": "True" } }, - { - "name": "changeUpdatePriority", - "type": "boolean", - "label": "Set in-app update priority", - "defaultValue": false, - "required": false, - "helpMarkDown": "Change the in-app update priority value." - }, - { - "name": "updatePriority", - "type": "pickList", - "label": "In-app Update Priority", - "defaultValue": "0", - "required": false, - "options": { - "0": "0", - "1": "1", - "2": "2", - "3": "3", - "4": "4", - "5": "5" - }, - "helpMarkDown": "Set a custom in-app update priority value to help keep your app up-to-date on your users’ devices. To determine priority, Google Play uses an integer value between 0 and 5, with 0 being the default, and 5 being the highest priority. Priority can only be set when rolling out a new release, and cannot be changed later.", - "visibleRule": "changeUpdatePriority = true" - }, - { - "name": "rolloutToUserFraction", - "type": "boolean", - "label": "Roll out release", - "defaultValue": false, - "required": false, - "helpMarkDown": "Roll out the release to a percentage of users." - }, - { - "name": "userFraction", - "type": "string", - "label": "Rollout fraction", - "defaultValue": "1.0", - "required": false, - "helpMarkDown": "The percentage of users the specified APK will be released to for the specified 'Track'. It can be increased later with the 'Google Play - Increase Rollout' task.", - "visibleRule": "rolloutToUserFraction = true" - }, { "name": "shouldAttachMetadata", - "type": "boolean", "label": "Update metadata", "defaultValue": false, - "required": false, - "helpMarkDown": "Select this option to update the metadata on your app release." + "type": "boolean", + "helpMarkDown": "Select this option to update the metadata in fastlane format on your app release." }, { "name": "changeLogFile", - "type": "filePath", "label": "Release notes (file)", "defaultValue": "", - "required": false, - "helpMarkDown": "Path to the file specifying the release notes (change log) for the APK you are publishing.", + "type": "filePath", + "helpMarkDown": "Path to the file specifying the release notes (change log) for the application you are publishing.", "visibleRule": "shouldAttachMetadata = false" }, { "name": "languageCode", - "type": "string", "label": "Language code", "defaultValue": "en-US", - "required": false, + "type": "string", "helpMarkDown": "An IETF language tag identifying the language of the release notes as specified in the BCP-47 document. Default value is _en-US_", "visibleRule": "shouldAttachMetadata = false" }, { "name": "metadataRootPath", - "type": "filePath", "label": "Metadata root directory", "defaultValue": "", "required": true, + "type": "filePath", "helpMarkDown": "The path to the metadata folder with the fastlane metadata structure.", "visibleRule": "shouldAttachMetadata = true" }, { - "name": "updateStoreListing", - "type": "boolean", - "label": "Update only store listing", + "name": "changeUpdatePriority", + "label": "Set in-app update priority", "defaultValue": false, - "required": false, - "helpMarkDown": " By default, the task will update the specified track and selected APK file(s) will be assigned to the related track. By selecting this option you can update only store listing." + "type": "boolean", + "helpMarkDown": "Change the in-app update priority value.", + "visibleRule": "action != OnlyStoreListing", + "groupName": "advanced" + }, + { + "name": "updatePriority", + "label": "In-app Update Priority", + "defaultValue": "0", + "type": "pickList", + "helpMarkDown": "Set a custom in-app update priority value to help keep your app up-to-date on your users' devices. To determine priority, Google Play uses an integer value between 0 and 5, with 0 being the default, and 5 being the highest priority. Priority can only be set when rolling out a new release, and cannot be changed later.", + "visibleRule": "action != OnlyStoreListing && changeUpdatePriority = true", + "groupName": "advanced", + "options": { + "0": "0", + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5" + } }, { - "name": "shouldUploadApks", + "name": "rolloutToUserFraction", + "label": "Roll out release", + "defaultValue": false, "type": "boolean", - "label": "Update APK(s)", - "defaultValue": true, - "required": false, - "visibleRule": "updateStoreListing = false", - "helpMarkDown": "By default, the task will update the specified binary APK file(s) on your app release. By unselecting this option you can update metadata keeping the APKs untouched." + "helpMarkDown": "Roll out the release to a percentage of users.", + "visibleRule": "action != OnlyStoreListing", + "groupName": "advanced" + }, + { + "name": "userFraction", + "label": "Rollout fraction", + "defaultValue": "1.0", + "type": "string", + "helpMarkDown": "The percentage of users the specified application will be released to for the specified 'Track'. It can be increased later with the 'Google Play - Increase Rollout' task.", + "visibleRule": "action != OnlyStoreListing && rolloutToUserFraction = true", + "groupName": "advanced" }, { "name": "shouldUploadMappingFile", - "type": "boolean", "label": "Upload deobfuscation file (mapping.txt)", "defaultValue": false, - "required": false, - "helpMarkDown": "Select this option to attach your proguard mapping.txt file to the primary APK." + "type": "boolean", + "helpMarkDown": "Select this option to attach your proguard mapping.txt file to your aab/apk.", + "visibleRule": "action != OnlyStoreListing && action != MultiApkAab", + "groupName": "advanced" }, { "name": "mappingFilePath", - "type": "filePath", "label": "Deobfuscation path", "defaultValue": "", - "required": false, - "helpMarkDown": "The path to the proguard mapping.txt file to upload.", - "visibleRule": "shouldUploadApks = true && shouldUploadMappingFile = true" - }, - { - "name": "additionalApks", - "type": "multiLine", - "label": "Additional APK path(s)", - "defaultValue": "", - "groupName": "advanced", - "required": false, - "helpMarkDown": "Paths to additional APK files you want to publish to the specified track (e.g. an x86 build). Wildcards can be used. For example, _\\*\\*/\\*.apk_ to match all APK files, in any directory." + "type": "string", + "helpMarkDown": "The path to the proguard mapping.txt file to upload. Glob patterns are supported.", + "visibleRule": "action != OnlyStoreListing && action != MultiApkAab && shouldUploadMappingFile = true", + "groupName": "advanced" }, { - "name": "shouldPickObbFileForAdditonalApks", + "name": "changesNotSentForReview", "type": "boolean", - "label": "Upload Obbs", + "label": "Send changes to review", "defaultValue": false, - "groupName": "advanced", - "required": false, - "helpMarkDown": "Select this option to pick obb file for the apk. If present in the parent directory, it will pick the first file with .obb extension, else it will pick from apk directory with expected format as main...obb" + "helpMarkDown": "Select this option to send changes for review in GooglePlay Console. If changes are already sent for review automatically, you shouldn't select this option. [More info](https://developers.google.com/android-publisher/api-ref/rest/v3/edits/commit#query-parameters).", + "groupName": "advanced" + }, + { + "name": "releaseName", + "type": "string", + "label": "Release name", + "defaultValue": "", + "helpMarkDown": "The release name is only for use in Play Console and won't be visible to users. To make your release easier to identify, add a release name that's meaningful to you.", + "visibleRule": "action != OnlyStoreListing", + "groupName": "advanced" }, { "name": "versionCodeFilterType", - "type": "pickList", "label": "Replace version codes", "defaultValue": "all", + "type": "pickList", + "helpMarkDown": "Specify version codes to replace in the selected track with the new aab(s)/apk(s): all, the comma separated list, or a regular expression pattern.", + "visibleRule": "action != OnlyStoreListing", "groupName": "advanced", - "required": false, - "helpMarkDown": "Specify version codes to replace in the selected track with the new APKs: all, the comma separated list, or a regular expression pattern.", "options": { "all": "All", "list": "List", @@ -237,67 +266,58 @@ }, { "name": "replaceList", - "type": "string", "label": "Version code list", "defaultValue": "", - "groupName": "advanced", "required": true, - "helpMarkDown": "The comma separated list of APK version codes to be removed from the track with this deployment.", - "visibleRule": "versionCodeFilterType = list" + "type": "string", + "helpMarkDown": "The comma separated list of version codes to be removed from the track with this deployment.", + "visibleRule": "action != OnlyStoreListing && versionCodeFilterType = list", + "groupName": "advanced" }, { "name": "replaceExpression", - "type": "string", "label": "Version code pattern", "defaultValue": "", - "groupName": "advanced", "required": true, - "helpMarkDown": "The regular expression pattern to select a list of APK version codes to be removed from the track with this deployment, e.g. _.\\*12?(3|4)?5_ ", - "visibleRule": "versionCodeFilterType = expression" + "type": "string", + "helpMarkDown": "The regular expression pattern to select a list of version codes to be removed from the track with this deployment, e.g. _.\\*12?(3|4)?5_ ", + "visibleRule": "action != OnlyStoreListing && versionCodeFilterType = expression", + "groupName": "advanced" } ], "execution": { "Node10": { "target": "GooglePlay.js", "argumentFormat": "" - }, - "PowerShell": { - "target": "$(currentDirectory)\\GooglePlay.ps1", - "argumentFormat": "", - "workingDirectory": "$(currentDirectory)" } }, "messages": { - "InvalidAuthFile": "%s is not a valid auth file", - "FoundMainApk": "Found main APK to upload: %s (version code %s)", - "FoundMultiApks": "Found multiple APKs to upload:", - "FoundDeobfuscationFile": "Found deobfuscation (mapping) file: %s", - "GetNewEditAfterAuth": "Authenticated with Google Play and getting new edit ", - "UploadApk": "Uploading APK file %s...", - "UpdateTrack": "Updating track information...", - "AttachingMetadataToRelease": "Attempting to attach metadata to release...", - "AptPublishSucceed": "APK successfully published!", - "TrackInfo": "Track: %s", - "Success": "Successfully publish APKs.", - "Failure": "Failed to publish APKs.", - "AddChangelog": "Adding changelog file...", "AppendChangelog": "Appending changelog %s", - "UploadingMetadataForLanguage": "Attempting to upload metadata in %s for language code %s", + "AttachingMetadataToRelease": "Attempting to attach metadata to release...", + "CannotCreateListing": "Failed to create the localized %s store listing. Failed with message: %s.", + "CannotDownloadTrack": "Failed to download track %s information. Failed with message: %s.", + "CannotReadChangeLog": "Failed to read change log %s. Failed with message: %s.", + "CannotUpdateTrack": "Failed to update track %s information. Failed with message: %s.", + "CannotUploadApk": "Failed to upload the APK %s. Failed with message: %s.", + "CannotUploadBundle": "Failed to upload the bundle %s. Failed with message: %s.", + "CannotUploadDeobfuscationFile": "Failed to upload the deobfuscation file %s. Failed with message: %s.", + "CannotUploadExpansionFile": "Failed to upload the expansion file %s. Failed with message: %s.", + "FoundDeobfuscationFile": "Found deobfuscation (mapping) file: %s", "FoundImageAtPath": "Found image for type %s at %s", + "GetNewEditAfterAuth": "Authenticated with Google Play and getting new edit", + "ImageDirNotFound": "Image directory for %s was not found. Skipping...", "ImageTypeNotFound": "Image for %s was not found. Skipping...", + "IncorrectVersionCodeFilter": "Version code list specified contains incorrect codes: %s", + "InvalidAuthFile": "%s is not a valid auth file", + "MustProvideApkIfObb": "shouldPickObbFile input is enabled, but no apk files could be found", + "MustProvideApkOrAab": "You must provide either apk or aab file(s). Neither were found.", + "PublishSucceed": "App was successfully published!", + "SetUnusedInput": "Input %s was set, but it will not be used in this action", "StatNotDirectory": "Stat returned that %s was not a directory. Is there a file that shares this name?", - "ImageDirNotFound": "Image directory for %s was not found. Skipping...", + "TrackInfo": "Track: %s", + "UpdateTrack": "Updating track information...", "UploadImageFail": "Failed to upload image.", - "RequestDetails": "Request Details: %s", - "CannotCreateTransaction": "Failed to create a new edit transaction for the package %s. Failed with message: %s. See log for details.", - "CannotUploadApk": "Failed to upload the APK %s. Failed with message: %s.", - "CannotUploadDeobfuscationFile": "Failed to upload the deobfuscation file %s. Failed with message: %s.", - "CannotDownloadTrack": "Failed to download track %s information. Failed with message: %s.", - "CannotUpdateTrack": "Failed to update track %s information. Failed with message: %s.", - "CannotReadChangeLog": "Failed to read change log %s. Failed with message: %s.", - "CannotCreateListing": "Failed to create the localized %s store listing. Failed with message: %s.", - "IncorrectVersionCodeFilter": "Version code list specified contains incorrect codes: %s", - "StoreListUpdateSucceed": "Store list metadata successfully updated" + "UploadingMetadataForLanguage": "Attempting to upload metadata in %s for language code %s" }, "restrictions": { "commands": {