fix(functions): warn when a functions lockfile omits peer dependencies - #10918
fix(functions): warn when a functions lockfile omits peer dependencies#10918IzaakGough wants to merge 7 commits into
Conversation
…ild server The Cloud Functions builder runs `npm ci` with npm's default legacy-peer-deps=false. A package-lock.json resolved on a machine with the setting enabled omits the peer dependencies npm would otherwise install, so the build fails with "Missing: <package> from lock file" even though the local install succeeded. In a stock `firebase init functions` project the trigger is jest, a peer dependency of the scaffolded firebase-functions-test devDependency, which is why untouched new projects hit it. Users had no way to see this before a multi-minute Cloud Build round trip, and the failure surfaced as several hundred lines of raw npm output with no indication of the cause. The workaround that spread instead was deleting package-lock.json, which works only because the builder then falls back to `npm install`. Two changes: - Validate before upload. If the source dir has a package-lock.json, npm reports legacy-peer-deps=true, and no .npmrc is being shipped to carry the setting along, warn and name both fixes. Skipped entirely when there is no lockfile, and never fails a deploy: if npm cannot be run the check stays quiet. - Map the build failure. When a deployment error carries the `npm ci` lockfile text, print the explanation and the fix instead of leaving the user with the raw dump. Verified against real deploys: an identical lockfile fails the build without functions/.npmrc and succeeds with it. Fixes #5673
Wiz Scan Summary
To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio. |
There was a problem hiding this comment.
Code Review
This pull request adds warnings and clearer error messages when Cloud Functions deployments fail due to npm lockfile mismatches, specifically when legacy-peer-deps is enabled locally but not on the build server. It introduces local validation to warn users before deployment and enhances error reporting to print actionable troubleshooting steps if npm ci fails. The review feedback highlights several violations of the repository's style guide regarding the use of any as an escape hatch in reporter.ts, validate.ts, and the test suite, suggesting type-safe alternatives such as custom interfaces, intersection types, and proper error narrowing.
| /** Collects every message in an error's cause chain so we can pattern match on them. */ | ||
| function errorMessages(err: unknown): string { | ||
| const original = (err as DeploymentError)?.original as any; | ||
| return [ | ||
| (err as Error)?.message, | ||
| original?.message, | ||
| original?.original?.message, | ||
| original?.context?.body?.error?.message, | ||
| ] | ||
| .filter(Boolean) | ||
| .join(" "); | ||
| } |
There was a problem hiding this comment.
Using as any violates the repository style guide rule against using any as an escape hatch. Since err is guaranteed to be a DeploymentError when called from printLockfileErrors, we can type the parameter more precisely and define a clean interface for the nested error structure to achieve full type safety.
interface DetailedError {
message?: string;
original?: DetailedError;
context?: {
body?: {
error?: {
message?: string;
};
};
};
}
/** Collects every message in an error's cause chain so we can pattern match on them. */
function errorMessages(err: DeploymentError): string {
const original = err.original as DetailedError | undefined;
return [
err.message,
original?.message,
original?.original?.message,
original?.context?.body?.error?.message,
]
.filter(Boolean)
.join(" ");
}References
- Never use
anyorunknownas an escape hatch. Define proper interfaces/types or use type guards. (link)
| try { | ||
| return /^\s*legacy-peer-deps\s*=/m.test(fs.readFileSync(npmrc, "utf8")); | ||
| } catch (e: any) { | ||
| logger.debug("Failed to read .npmrc in functions source directory:", e.message); | ||
| return false; | ||
| } |
There was a problem hiding this comment.
Avoid using any in the catch clause. Typing the caught error as unknown and narrowing it (or using safe checks) is more type-safe and adheres to the repository style guide.
try {
return /^\s*legacy-peer-deps\s*=/m.test(fs.readFileSync(npmrc, "utf8"));
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
logger.debug("Failed to read .npmrc in functions source directory:", message);
return false;
}References
- Never use
anyorunknownas an escape hatch. Define proper interfaces/types or use type guards. (link)
| 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", | ||
| }; |
There was a problem hiding this comment.
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.
| 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
- Never use
anyorunknownas an escape hatch. Define proper interfaces/types or use type guards. (link)
…orter Replaces the any casts flagged in review with a NestedError interface and narrows errorMessages to DeploymentError, which is what printLockfileErrors already guarantees before calling it.
…the functions dir Two defects found in review: Running the CLI under `npm run` or `npx` leaks the outer project's resolved npm config into our environment, and npm_config_legacy_peer_deps and npm_config_local_prefix beat cwd in the child. In a monorepo whose root .npmrc sets the flag, that warned on a functions lockfile that was resolved correctly. Strip npm_config_* from the child env so it resolves from sourceDir alone. The warning also sat in the Node delegate's validate(), which the emulator runs from discoverTriggers on every debounced file change. It describes how the build server will treat an uploaded lockfile, so it belongs on the deploy path in prepare, not on a path where nothing is uploaded.
Reading npm config missed the common case: `npm install --legacy-peer-deps` writes the same broken lockfile but leaves no trace in config, so the warning stayed silent for the users it was for, while firing on users whose lockfile was fine. Inspect the lockfile for peer dependencies it does not contain instead, which catches it however the setting was applied and drops the npm subprocess. Also honors functions.ignore before assuming a shipped .npmrc reaches the build, reads npm-shrinkwrap.json, parses the .npmrc value rather than matching the key, and leads the build failure message with the far more common cause, a lockfile that is simply out of date.
…upload Second review round. `.npmrc` quotes its values and accepts a bare key, both of which read as enabled to npm but not to us, so a user who followed the warning's own advice kept getting warned. Ignore globs were matched with different options than the packaging code uses, so the two could disagree about whether the file ships. The check also sat in loadCodebases, which `functions:lifecycle:list` and the discovery command share, so it now runs in the upload phase where it belongs and keys off the endpoints' runtime rather than the build's optional one. Adds the untested shapes: workspaces, scoped names, ignore globs, truncation, and a build failure payload captured from a real deploy.
Fixes #5673.
The problem
The build server runs
npm ciwith npm's defaultlegacy-peer-deps=false. A lockfile resolved with that setting enabled omits the peer dependencies npm would otherwise install, so the build fails withMissing: <package> from lock fileeven though the local install succeeded.A stock
firebase init functionsproject is enough to hit it:jestis a peer dependency of the scaffoldedfirebase-functions-test, andnpm civalidates the whole tree before dropping devDependencies, so a dev-only package fails a production deploy.Until now the only signal was a multi-minute Cloud Build round trip returning several hundred lines of raw npm output. The workaround that spread through the issue was deleting
package-lock.json, which only works because the builder then falls back tonpm install.The change
Warn before upload.
warnIfLockfileOmitsPeerDepsreads the lockfile and reports non-optional peer dependencies it does not contain, resolving each peer the way npm does. Reading the artifact rather than npm's config matters:npm install --legacy-peer-depsproduces exactly this lockfile while leaving nothing behind in config, so a config-based check is blind to it and simultaneously warns people whose lockfile is fine. Runs on the deploy path only, spawns nothing, and is wrapped so it can never fail a deploy.The warning is suppressed when the functions directory ships an
.npmrcenablinglegacy-peer-deps, since that file is uploaded and the build server honors it, unlessfunctions.ignoreexcludes it.Explain the failure when it happens anyway.
printLockfileErrorsmatches npm's out-of-sync error and leads with the far more common cause, a lockfile that is simply out of date, mentioninglegacy-peer-depssecond.Verification
Reproduced against real deploys. A lockfile from
npm install --legacy-peer-depsfails the build with the issue's exact error; adding onlyfunctions/.npmrcwithlegacy-peer-deps=true, lockfile byte-identical, makes the same deploy succeed.The warning was exercised end to end against the built CLI: a lockfile missing
jestwarns whilenpm config get legacy-peer-depsreadsfalse; a clean lockfile stays silent; a shipping.npmrcsuppresses it; adding.npmrctofunctions.ignorerestores it.No false positives against firebase-tools' own 1859-entry
npm-shrinkwrap.jsonor a clean install of the samepackage.json.Scope
Does not cover the other cause in that issue thread, where the lockfile was written by a different npm major than the build server uses (
string-width-cjsand friends). The improved build failure message applies there; the pre-upload check does not.