Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
- Configured OneMCP server tools to require a Firebase project by default, with options to opt-out specific tools (such as Developer Knowledge document search).
- Fixed a bug where deploying functions with the `dartfunctions` experiment enabled could incorrectly prompt to delete existing GCF v2 functions.
- Added a warning when a functions lockfile omits peer dependencies that the Cloud Functions build server expects, and a clearer message when a build fails because `npm ci` rejected the lockfile (#5673).
- Added `outputSchema` support for local MCP tools.
- Skip functions lifecycle hooks during partial (filtered) deployments, and print instructions for running them manually.
- Added `appcheck:providers:list`, `appcheck:providers:get` and `appcheck:providers:set` to configure App Check attestation providers for an app.
Expand Down
9 changes: 9 additions & 0 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
} from "./functionsDeployHelper";
import { logLabeledBullet, logLabeledWarning } from "../../utils";
import { isDartEndpoint, classifyNonProductionEndpoints } from "./runtimes/dart/triggerSupport";
import * as nodeValidate from "./runtimes/node/validate";
import { getFunctionsConfig, prepareFunctionsUpload } from "./prepareFunctionsUpload";
import { promptForFailurePolicies, promptForMinInstances } from "./prompts";
import { needProjectId, needProjectNumber } from "../../projectUtils";
Expand Down Expand Up @@ -153,7 +154,7 @@
}

const existingSalt = haveRolesEtag ? haveRolesEtag.split("-")[0] : undefined;
const newEtag = iam.computeRolesEtag(requiredRoles!, existingSalt);

Check warning on line 157 in src/deploy/functions/prepare.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Forbidden non-null assertion

for (const endpoint of backend.allEndpoints(want)) {
endpoint.serviceAccount = managedSA;
Expand Down Expand Up @@ -192,10 +193,10 @@
if (existingManagedSA) {
try {
haveRoles = await resourcemanager.getServiceAccountRoles(projectId, managedSA);
} catch (err: any) {

Check warning on line 196 in src/deploy/functions/prepare.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
throw new FirebaseError(
`The declarative security roles for codebase ${codebase} have changed, but you do not have access to see what has changed. Please ask an IAM administrator to perform the next deploy.`,
{ original: err },

Check warning on line 199 in src/deploy/functions/prepare.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
);
}
}
Expand Down Expand Up @@ -414,6 +415,14 @@
"functions",
`preparing ${clc.bold(sourceDirName)} directory for uploading...`,
);
// Describes how the build server will treat the lockfile we are about to
// upload, so it belongs here rather than anywhere shared with the emulator
// or with commands that only inspect the source.
if (
backend.someEndpoint(wantBackend, (e) => supported.runtimeIsLanguage(e.runtime, "nodejs"))
) {
nodeValidate.warnIfLockfileOmitsPeerDeps(sourceDir, localCfg.ignore);
}
}

if (backend.someEndpoint(wantBackend, (e) => e.platform === "gcfv2" || e.platform === "run")) {
Expand Down Expand Up @@ -566,7 +575,7 @@
// Match triggers.
try {
resolvedRegion = await resolveRegionForTrigger(endpoint);
} catch (err: any) {

Check warning on line 578 in src/deploy/functions/prepare.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
logger.debug(
`Failed to resolve region for endpoint ${id}. Defaulting to ${FALLBACK_DEPLOYMENT_REGION}.`,
getErrStack(err),
Expand Down Expand Up @@ -679,7 +688,7 @@
.filter(
(ep) =>
backend.isBlockingTriggered(ep) &&
AUTH_BLOCKING_EVENTS.includes(ep.blockingTrigger.eventType as any),

Check warning on line 691 in src/deploy/functions/prepare.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 691 in src/deploy/functions/prepare.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `"providers/cloud.auth/eventTypes/user.beforeCreate" | "providers/cloud.auth/eventTypes/user.beforeSignIn" | "providers/cloud.auth/eventTypes/user.beforeSendEmail" | "providers/cloud.auth/eventTypes/user.beforeSendSms"`
) as (backend.Endpoint & backend.BlockingTriggered)[];

if (authBlockingEndpoints.length === 0) {
Expand Down Expand Up @@ -847,7 +856,7 @@
* a function and doesn't have one. To avoid repetitive nagging, only warn on the first
* deploy of the function.
*/
export async function warnIfNewGenkitFunctionIsMissingSecrets(

Check warning on line 859 in src/deploy/functions/prepare.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
have: backend.Backend,
want: backend.Backend,
options: DeployOptions,
Expand Down
126 changes: 126 additions & 0 deletions src/deploy/functions/release/reporter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@

it("prints quota errors", () => {
const rawError = new Error("Quota exceeded");
(rawError as any).status = 429;

Check warning on line 329 in src/deploy/functions/release/reporter.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 329 in src/deploy/functions/release/reporter.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .status on an `any` value
const summary: reporter.Summary = {
totalTime: 1_000,
results: [
Expand All @@ -344,6 +344,132 @@
);
});

// Captured verbatim from a failed Cloud Functions deploy, so the matcher is
// tested against the shape the Functions API actually returns.
it("prints lockfile errors", () => {
const rawError = new Error(
"Build failed: npm error code EUSAGE\nnpm error\nnpm error `npm ci` can only install " +
"packages when your package.json and package-lock.json or npm-shrinkwrap.json are in " +
"sync. Please update your lock file with `npm install` before continuing.\nnpm error\n" +
"npm error Missing: jest@29.7.0 from lock file\nnpm error Missing: @jest/core@29.7.0 " +
"from lock file",
);
const summary: reporter.Summary = {
totalTime: 1_000,
results: [
{
endpoint: ENDPOINT,
durationMs: 1_000,
error: new reporter.DeploymentError(ENDPOINT, "create", rawError),
},
],
};

reporter.printErrors(summary);
expect(infoStub).to.have.been.calledWithMatch(
"your lockfile is out of sync with package.json",
);
expect(infoStub).to.have.been.calledWithMatch("legacy-peer-deps");
});

it("finds lockfile errors nested in the original error", () => {
const rawError = new Error("Deployment failed") as Error & { original?: unknown };
rawError.original = {
message:
"npm ERR! `npm ci` can only install packages when your package.json and " +
"package-lock.json are in sync. Missing: p-limit@2.3.0 from lock file",
};
Comment on lines +375 to +381

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Avoid using as any to attach custom properties to an Error object. Using an intersection type is a cleaner and type-safe alternative that adheres to the repository style guide.

Suggested change
it("finds lockfile errors nested in the original error", () => {
const rawError = new Error("Deployment failed") as any;
rawError.original = {
message:
"npm ERR! `npm ci` can only install packages when your package.json and " +
"package-lock.json are in sync. Missing: p-limit@2.3.0 from lock file",
};
it("finds lockfile errors nested in the original error", () => {
const rawError = new Error("Deployment failed") as Error & { original?: unknown };
rawError.original = {
message:
"npm ERR! `npm ci` can only install packages when your package.json and " +
"package-lock.json are in sync. Missing: p-limit@2.3.0 from lock file",
};
References
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)

const summary: reporter.Summary = {
totalTime: 1_000,
results: [
{
endpoint: ENDPOINT,
durationMs: 1_000,
error: new reporter.DeploymentError(ENDPOINT, "create", rawError),
},
],
};

reporter.printErrors(summary);
expect(infoStub).to.have.been.calledWithMatch(
"your lockfile is out of sync with package.json",
);
});

it("matches the invalid-version shape of the same failure", () => {
const rawError = new Error(
"Build failed: npm error `npm ci` can only install packages when your package.json " +
"and package-lock.json are in sync. npm error Invalid: lock file's ms@2.1.2 does " +
"not satisfy ms@2.1.3",
);
const summary: reporter.Summary = {
totalTime: 1_000,
results: [
{
endpoint: ENDPOINT,
durationMs: 1_000,
error: new reporter.DeploymentError(ENDPOINT, "create", rawError),
},
],
};

reporter.printErrors(summary);
expect(infoStub).to.have.been.calledWithMatch(
"your lockfile is out of sync with package.json",
);
});

it("finds lockfile errors however deeply the build failure is wrapped", () => {
const rawError = new Error("Deployment failed") as Error & { original?: unknown };
rawError.original = {
original: {
context: {
body: {
error: {
message:
"Build failed: npm error `npm ci` can only install packages when your " +
"package.json and package-lock.json are in sync. Missing: jest@29.7.0 " +
"from lock file",
},
},
},
},
};
const summary: reporter.Summary = {
totalTime: 1_000,
results: [
{
endpoint: ENDPOINT,
durationMs: 1_000,
error: new reporter.DeploymentError(ENDPOINT, "create", rawError),
},
],
};

reporter.printErrors(summary);
expect(infoStub).to.have.been.calledWithMatch(
"your lockfile is out of sync with package.json",
);
});

it("does not print lockfile errors for unrelated failures", () => {
const summary: reporter.Summary = {
totalTime: 1_000,
results: [
{
endpoint: ENDPOINT,
durationMs: 1_000,
error: new reporter.DeploymentError(ENDPOINT, "create", new Error("Build failed")),
},
],
};

reporter.printErrors(summary);
expect(infoStub).to.not.have.been.calledWithMatch(
"your lockfile is out of sync with package.json",
);
});

it("prints aborted errors", () => {
const summary: reporter.Summary = {
totalTime: 1_000,
Expand Down
67 changes: 67 additions & 0 deletions src/deploy/functions/release/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,76 @@

printIamErrors(errored);
printQuotaErrors(errored);
printLockfileErrors(errored);
printAbortedErrors(errored);
}

/**
* The shape a build failure arrives in once it has been wrapped for reporting.
* Every level is optional because the nesting depends on which layer failed.
*/
interface NestedError {
message?: string;
original?: unknown;
cause?: unknown;
context?: { body?: { error?: { message?: string } } };
}

/**
* Collects every message in an error's cause chain so we can pattern match on them.
*
* Walks rather than reaching into fixed paths, since how deeply a build failure
* is wrapped depends on which layer reported it.
*/
function errorMessages(err: NestedError, depth = 0): string {
if (!err || depth > 5) {
return "";
}
return [
err.message,
err.context?.body?.error?.message,
errorMessages(err.original as NestedError, depth + 1),
errorMessages(err.cause as NestedError, depth + 1),
]
.filter(Boolean)
.join(" ");
}

/** Print errors for builds that failed because `npm ci` rejected the lockfile. */
function printLockfileErrors(results: Array<Required<DeployResult>>): void {
const hadLockfileError = results.find((r) => {
if (!(r.error instanceof DeploymentError)) {
return false;
}
const message = errorMessages(r.error);
if (!message.includes("npm ci")) {
return false;
}
// "Missing: x from lock file" and "Invalid: lock file's x does not satisfy y"
// are the two shapes npm uses for the same out-of-sync failure.
return message.includes("from lock file") || message.includes("lock file's");
});
if (!hadLockfileError) {
return;
}

logger.info("");
logger.info(
"The build failed because your lockfile is out of sync with package.json, so " +
"`npm ci` refused to install. Run " +
`${clc.bold("npm install")} in your functions directory and commit the updated lockfile.`,
);
logger.info("");
logger.info(
"If the lockfile already looks up to date, it was most likely resolved with " +
`${clc.bold("legacy-peer-deps")} enabled, which the build server does not use, so peer ` +
`dependencies it expects are absent. Check with ` +
`${clc.bold("npm config get legacy-peer-deps")} and regenerate the lockfile with the ` +
"setting off, or add an .npmrc to your functions directory containing only " +
`${clc.bold("legacy-peer-deps=true")} so the build server resolves the same way you do.`,
);
}

/** Print errors for failures to set invoker. */
function printIamErrors(results: Array<Required<DeployResult>>): void {
const iamFailures = results.filter(
Expand Down Expand Up @@ -205,7 +272,7 @@
if (!(r.error instanceof DeploymentError)) {
return false;
}
const original = r.error.original as any;

Check warning on line 275 in src/deploy/functions/release/reporter.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
const code: number | undefined =
original?.status ||
original?.code ||
Expand Down
Loading
Loading