Skip to content

fix(extensions): skip extensions deploy when there is nothing to deploy - #10925

Draft
IzaakGough wants to merge 3 commits into
mainfrom
@invertase/fix-issue-7584
Draft

fix(extensions): skip extensions deploy when there is nothing to deploy#10925
IzaakGough wants to merge 3 commits into
mainfrom
@invertase/fix-issue-7584

Conversation

@IzaakGough

@IzaakGough IzaakGough commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #7584.

Problem

firebase deploy --only functions on a codebase with no extensions still runs the extensions deploy stage, and the first thing that stage does is check the Cloud Billing API. Under Workload Identity or a service account that fails with a 403, since ADC carries no quota project and Cloud Billing usually is not enabled on the target project.

The chain:

  1. The functions SDK always emits an extensions record in the manifest, {} when there are none.
  2. v1alpha1.ts does if (manifest.extensions), so an empty record still sets build.extensions = {}, which is truthy.
  3. functions/prepare.ts therefore calls prepareDynamicExtensions, which returns early when nothing is declared and nothing exists remotely, but prepare.ts assigns context.extensions and payload.extensions anyway.
  4. functions/deploy.ts gates on payload.extensions && context.extensions, and {} && {} is truthy, so extensions deploy runs against an empty plan and calls checkBilling.

extensions/release.ts already guards against an empty payload. extensions/deploy.ts does not, which is what lets this through.

Fix

  • functions/prepare.ts: only pass the extensions context and payload on when prepareDynamicExtensions reports that it built a plan. It now returns a boolean rather than leaving the caller to infer this from the payload contents.
  • extensions/deploy.ts: return early when there is nothing to create, update, configure or delete, before the billing check. release has a similar guard, though it tests for the payload fields being absent rather than empty.

The Extensions API check and firebaseextensions.instances.list stay on the deploy path, since those are what detect extensions deleted from code, and they already fail soft. Worth noting that the Billing API is not needed for that detection: nothing in the prepare or list path calls Cloud Billing, so the empty-payload deploy stage was the only thing requiring it.

Testing

New extensions/deploy.spec.ts covers empty payload, all-empty lists, create, delete-only and configure. The two no-op cases fail without this change. Cases that should proceed assert they reached bulkCheckProductsProvisioned, so widening the guard cannot silently turn a real deploy into a no-op. Added a case to extensions/prepare.spec.ts pinning the early-return contract that functions/prepare now depends on. Existing suites for deploy/extensions and deploy/functions pass (104 tests), tsc --noEmit and eslint are clean.

A functions deploy for a codebase that declares no extensions still ran the
extensions deploy stage, whose first action is a Cloud Billing API check. The
functions SDK always emits an extensions record in the manifest, empty when
there are none, so `build.extensions` was truthy and functions/prepare handed
the extensions stages a payload even though prepareDynamicExtensions had
returned early without building a plan.

Only pass the extensions context and payload on when a plan was actually made,
and guard extensions deploy the way release already does. Billing is now only
checked when an instance is created, updated or configured, since deleting one
does not require the Blaze plan.

Fixes #7584

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request resolves an issue where deploying functions from a codebase with no extensions incorrectly required the Cloud Billing API to be enabled. It updates the deployment flow to skip billing checks when there are no extensions to create, update, or configure, and adds comprehensive unit tests. The review feedback suggests two key improvements: first, refining the payload check in prepare.ts to explicitly check the lengths of the instance lists rather than using Object.keys(), which can be misleadingly truthy; second, replacing the use of any with the proper Options type in the test suite to align with the repository's TypeScript style guidelines.

Comment thread src/deploy/functions/prepare.ts Outdated
// prepareDynamicExtensions returns without touching the payload when there is
// nothing to deploy and nothing to delete. Only hand the extensions stages a
// plan when it actually made one, otherwise they run against an empty payload.
if (Object.keys(extPayload).length) {

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

Instead of checking Object.keys(extPayload).length, which can be truthy (e.g., 4) even when all the deployment lists are empty arrays (e.g., []), we should explicitly check if there is at least one instance to create, configure, update, or delete. This prevents passing an empty payload to subsequent stages.

    if (
      extPayload.instancesToCreate?.length ||
      extPayload.instancesToConfigure?.length ||
      extPayload.instancesToUpdate?.length ||
      extPayload.instancesToDelete?.length
    ) {

Comment on lines +1 to +13
import { expect } from "chai";
import * as sinon from "sinon";

import { deploy } from "./deploy";
import { Context, Payload } from "./args";
import * as cloudbilling from "../../gcp/cloudbilling";
import * as provisioningHelper from "../../extensions/provisioningHelper";

describe("Extensions deploy", () => {
let checkBillingEnabledStub: sinon.SinonStub;
let bulkCheckProductsProvisionedStub: sinon.SinonStub;

const options: any = { nonInteractive: true, project: "test-project" };

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 any as an escape hatch for the options variable, as it violates the repository style guide (TypeScript section, line 37). Instead, import Options from ../../options and cast the mock object as Options.

Suggested change
import { expect } from "chai";
import * as sinon from "sinon";
import { deploy } from "./deploy";
import { Context, Payload } from "./args";
import * as cloudbilling from "../../gcp/cloudbilling";
import * as provisioningHelper from "../../extensions/provisioningHelper";
describe("Extensions deploy", () => {
let checkBillingEnabledStub: sinon.SinonStub;
let bulkCheckProductsProvisionedStub: sinon.SinonStub;
const options: any = { nonInteractive: true, project: "test-project" };
import { expect } from "chai";
import * as sinon from "sinon";
import { deploy } from "./deploy";
import { Context, Payload } from "./args";
import * as cloudbilling from "../../gcp/cloudbilling";
import * as provisioningHelper from "../../extensions/provisioningHelper";
import { Options } from "../../options";
describe("Extensions deploy", () => {
let checkBillingEnabledStub: sinon.SinonStub;
let bulkCheckProductsProvisionedStub: sinon.SinonStub;
const options = { nonInteractive: true, project: "test-project" } as Options;
References
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)

Make the "was a plan prepared" signal explicit rather than inferring it from
whether prepareDynamicExtensions happened to populate the payload, so the two
modules no longer share an implicit invariant.

Drop the delete-only billing exemption. It was not needed to fix the reported
problem and was the only behaviour change affecting a real extensions deploy.

Correct the comment in extensions/deploy: release guards on the payload fields
being absent, not on them being empty, so the two guards are not equivalent.

Strengthen the tests. Deploys that should proceed now assert that they reached
bulkCheckProductsProvisioned, so widening the empty-payload guard cannot turn a
real deploy into a silent no-op, and prepareDynamicExtensions is covered on both
the no-plan and the delete-only-plan paths.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cloud Billing API check when deploying Functions with Google Workload Identity

2 participants