Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/appdistribution/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
});
}

async updateReleaseNotes(releaseName: string, releaseNotes: string): Promise<void> {
async updateReleaseNotes(releaseName: string, releaseNotes?: string): Promise<void> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would you ever call updateReleaseNotes with releaseNotes set to null?

Copy link
Author

@jrothfeder jrothfeder Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree but this is how the code already worked.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code around parameter processing needs to be refactored to simplify all of this. All I did was move it to a shared-library. Would you be comfortable with addressing the parameter processing in a separate PR?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think leave this the same i.e. don't make releaseNotes nullable and then in the code where you call it, only call it if releaseNotes is defined. I feel like that isn't too much of a change and can easily be addressed here.

if (!releaseNotes) {
utils.logWarning("no release notes specified, skipping");
return;
Expand Down Expand Up @@ -110,15 +110,15 @@

try {
await this.appDistroV1Client.post(`/${releaseName}:distribute`, data);
} catch (err: any) {

Check warning on line 113 in src/appdistribution/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
let errorMessage = err.message;

Check warning on line 114 in src/appdistribution/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .message on an `any` value

Check warning on line 114 in src/appdistribution/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
const errorStatus = err?.context?.body?.error?.status;

Check warning on line 115 in src/appdistribution/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .context on an `any` value

Check warning on line 115 in src/appdistribution/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
if (errorStatus === "FAILED_PRECONDITION") {
errorMessage = "invalid testers";
} else if (errorStatus === "INVALID_ARGUMENT") {
errorMessage = "invalid groups";
}
throw new FirebaseError(`failed to distribute to testers/groups: ${errorMessage}`, {

Check warning on line 121 in src/appdistribution/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "any" of template literal expression
exit: 1,
});
}
Expand Down Expand Up @@ -149,7 +149,7 @@
queryParams,
});
} catch (err) {
throw new FirebaseError(`Client request failed to list testers ${err}`);

Check warning on line 152 in src/appdistribution/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "unknown" of template literal expression
}

for (const t of apiResponse.body.testers ?? []) {
Expand Down Expand Up @@ -215,7 +215,7 @@
listGroupsResponse.groups.push(...(apiResponse.body.groups ?? []));
pageToken = apiResponse.body.nextPageToken;
} catch (err) {
throw new FirebaseError(`Client failed to list groups ${err}`);

Check warning on line 218 in src/appdistribution/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "unknown" of template literal expression
}
} while (pageToken);
return listGroupsResponse;
Expand Down
230 changes: 229 additions & 1 deletion src/appdistribution/distribution.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,186 @@
import * as fs from "fs-extra";
import { FirebaseError, getErrMsg } from "../error";
import { logger } from "../logger";
import * as pathUtil from "path";
import * as utils from "../utils";
import {
AabInfo,
IntegrationState,
UploadReleaseResult,
TestDevice,
ReleaseTest,
LoginCredential,
} from "../appdistribution/types";
import { AppDistributionClient } from "./client";
import { FirebaseError, getErrMsg, getErrStatus } from "../error";

export enum DistributionFileType {
IPA = "ipa",
APK = "apk",
AAB = "aab",
}

const TEST_MAX_POLLING_RETRIES = 40;
const TEST_POLLING_INTERVAL_MILLIS = 30_000;

/**
* Execute an app distribution action
*/
export async function distribute(

Check warning on line 28 in src/appdistribution/distribution.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
appName: string,
distribution: Distribution,
testCases: string[],
testDevices: TestDevice[],
releaseNotes?: string,
testers?: string[],
groups?: string[],
testNonBlocking?: boolean,
loginCredential?: LoginCredential,
) {
const requests = new AppDistributionClient();
let aabInfo: AabInfo | undefined;
if (distribution.distributionFileType() === DistributionFileType.AAB) {
try {
aabInfo = await requests.getAabInfo(appName);
} catch (err: unknown) {
if (getErrStatus(err) === 404) {
throw new FirebaseError(
`App Distribution could not find your app ${appName}. ` +
`Make sure to onboard your app by pressing the "Get started" ` +
"button on the App Distribution page in the Firebase console: " +
"https://console.firebase.google.com/project/_/appdistribution",
{ exit: 1 },
);
}
throw new FirebaseError(`failed to determine AAB info. ${getErrMsg(err)}`, { exit: 1 });
}

if (
aabInfo.integrationState !== IntegrationState.INTEGRATED &&
aabInfo.integrationState !== IntegrationState.AAB_STATE_UNAVAILABLE
) {
switch (aabInfo.integrationState) {
case IntegrationState.PLAY_ACCOUNT_NOT_LINKED: {
throw new FirebaseError("This project is not linked to a Google Play account.");
}
case IntegrationState.APP_NOT_PUBLISHED: {
throw new FirebaseError('"This app is not published in the Google Play console.');
}
case IntegrationState.NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT: {
throw new FirebaseError("App with matching package name does not exist in Google Play.");
}
case IntegrationState.PLAY_IAS_TERMS_NOT_ACCEPTED: {
throw new FirebaseError(
"You must accept the Play Internal App Sharing (IAS) terms to upload AABs.",
);
}
default: {
throw new FirebaseError(
"App Distribution failed to process the AAB: " + aabInfo.integrationState,
);
}
}
}
}

utils.logBullet("uploading binary...");
let releaseName;
try {
const operationName = await requests.uploadRelease(appName, distribution);

// The upload process is asynchronous, so poll to figure out when the upload has finished successfully
const uploadResponse = await requests.pollUploadStatus(operationName);

const release = uploadResponse.release;
switch (uploadResponse.result) {
case UploadReleaseResult.RELEASE_CREATED:
utils.logSuccess(
`uploaded new release ${release.displayVersion} (${release.buildVersion}) successfully!`,
);
break;
case UploadReleaseResult.RELEASE_UPDATED:
utils.logSuccess(
`uploaded update to existing release ${release.displayVersion} (${release.buildVersion}) successfully!`,
);
break;
case UploadReleaseResult.RELEASE_UNMODIFIED:
utils.logSuccess(
`re-uploaded already existing release ${release.displayVersion} (${release.buildVersion}) successfully!`,
);
break;
default:
utils.logSuccess(
`uploaded release ${release.displayVersion} (${release.buildVersion}) successfully!`,
);
}
utils.logSuccess(`View this release in the Firebase console: ${release.firebaseConsoleUri}`);
utils.logSuccess(`Share this release with testers who have access: ${release.testingUri}`);
utils.logSuccess(
`Download the release binary (link expires in 1 hour): ${release.binaryDownloadUri}`,
);
releaseName = uploadResponse.release.name;
} catch (err: unknown) {
if (getErrStatus(err) === 404) {
throw new FirebaseError(
`App Distribution could not find your app ${appName}. ` +
`Make sure to onboard your app by pressing the "Get started" ` +
"button on the App Distribution page in the Firebase console: " +
"https://console.firebase.google.com/project/_/appdistribution",
{ exit: 1 },
);
}
throw new FirebaseError(`Failed to upload release. ${getErrMsg(err)}`, { exit: 1 });
}

// If this is an app bundle and the certificate was originally blank fetch the updated
// certificate and print
if (aabInfo && !aabInfo.testCertificate) {
aabInfo = await requests.getAabInfo(appName);
if (aabInfo.testCertificate) {
utils.logBullet(
"After you upload an AAB for the first time, App Distribution " +
"generates a new test certificate. All AAB uploads are re-signed with this test " +
"certificate. Use the certificate fingerprints below to register your app " +
"signing key with API providers, such as Google Sign-In and Google Maps.\n" +
`MD-1 certificate fingerprint: ${aabInfo.testCertificate.hashMd5}\n` +
`SHA-1 certificate fingerprint: ${aabInfo.testCertificate.hashSha1}\n` +
`SHA-256 certificate fingerprint: ${aabInfo.testCertificate.hashSha256}`,
);
}
}

// Add release notes and distribute to testers/groups
await requests.updateReleaseNotes(releaseName, releaseNotes);
await requests.distribute(releaseName, testers, groups);

// Run automated tests
if (testDevices.length) {
utils.logBullet("starting automated test (note: this feature is in beta)");
const releaseTestPromises: Promise<ReleaseTest>[] = [];
if (!testCases.length) {
// fallback to basic automated test
releaseTestPromises.push(
requests.createReleaseTest(releaseName, testDevices, loginCredential),
);
} else {
for (const testCaseId of testCases) {
releaseTestPromises.push(
requests.createReleaseTest(
releaseName,
testDevices,
loginCredential,
`${appName}/testCases/${testCaseId}`,
),
);
}
}
const releaseTests = await Promise.all(releaseTestPromises);
utils.logSuccess(`${releaseTests.length} release test(s) started successfully`);
if (!testNonBlocking) {
await awaitTestResults(releaseTests, requests);
}
}
}

/**
* Object representing an APK, AAB or IPA file. Used for uploading app distributions.
*/
Expand Down Expand Up @@ -58,3 +230,59 @@
return this.fileName;
}
}

async function awaitTestResults(
releaseTests: ReleaseTest[],
requests: AppDistributionClient,
): Promise<void> {
const releaseTestNames = new Set(releaseTests.map((rt) => rt.name!));

Check warning on line 238 in src/appdistribution/distribution.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
for (let i = 0; i < TEST_MAX_POLLING_RETRIES; i++) {
utils.logBullet(`${releaseTestNames.size} automated test results are pending...`);
await delay(TEST_POLLING_INTERVAL_MILLIS);
for (const releaseTestName of releaseTestNames) {
const releaseTest = await requests.getReleaseTest(releaseTestName);
if (releaseTest.deviceExecutions.every((e) => e.state === "PASSED")) {
releaseTestNames.delete(releaseTestName);
if (releaseTestNames.size === 0) {
utils.logSuccess("Automated test(s) passed!");
return;
} else {
continue;
}
}
for (const execution of releaseTest.deviceExecutions) {
switch (execution.state) {
case "PASSED":
case "IN_PROGRESS":
continue;
case "FAILED":
throw new FirebaseError(
`Automated test failed for ${deviceToString(execution.device)}: ${execution.failedReason}`,
{ exit: 1 },
);
case "INCONCLUSIVE":
throw new FirebaseError(
`Automated test inconclusive for ${deviceToString(execution.device)}: ${execution.inconclusiveReason}`,
{ exit: 1 },
);
default:
throw new FirebaseError(
`Unsupported automated test state for ${deviceToString(execution.device)}: ${execution.state}`,
{ exit: 1 },
);
}
}
}
}
throw new FirebaseError("It took longer than expected to run your test(s), please try again.", {
exit: 1,
});
}

function delay(ms: number): Promise<number> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function deviceToString(device: TestDevice): string {
return `${device.model} (${device.version}/${device.orientation}/${device.locale})`;
}
9 changes: 6 additions & 3 deletions src/appdistribution/options-parser-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { FieldHints, LoginCredential, TestDevice } from "./types";
* file and converts the input into an string[].
* Value takes precedent over file.
*/
export function parseIntoStringArray(value: string, file: string): string[] {
export function parseIntoStringArray(value: string, file = ""): string[] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not default to null or undefined?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is null or undefined preferable to the empty string in TS?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. I guess my Kotlin mind just assumed null was prefered, but maybe empty string is in TS.

// If there is no value then the file gets parsed into a string to be split
if (!value && file) {
ensureFileExists(file);
Expand Down Expand Up @@ -61,7 +61,10 @@ export function getAppName(options: any): string {
if (!options.app) {
throw new FirebaseError("set the --app option to a valid Firebase app id and try again");
}
const appId = options.app;
return toAppName(options.app);
}

export function toAppName(appId: string) {
return `projects/${appId.split(":")[1]}/apps/${appId}`;
}

Expand All @@ -70,7 +73,7 @@ export function getAppName(options: any): string {
* and converts the input into a string[] of test device strings.
* Value takes precedent over file.
*/
export function parseTestDevices(value: string, file: string): TestDevice[] {
export function parseTestDevices(value: string, file = ""): TestDevice[] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, again I think we should revisit all of this. The existing code is a bit overcomplicated with how it handles parameters.

// If there is no value then the file gets parsed into a string to be split
if (!value && file) {
ensureFileExists(file);
Expand Down
Loading
Loading