Skip to content

Monorepo Cloud Run Fix: Upload the whole project for Cloud Run builds on Cloud Build, like App Hosting - #11282

Open
falahat wants to merge 1 commit into
mainfrom
bapi_run_monorepo
Open

falahat wants to merge 1 commit into
mainfrom
bapi_run_monorepo

Conversation

@falahat

@falahat falahat commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Description

Follow-up to #11205. For Cloud Run source deploys on Cloud Build, firebase deploy --only run zipped only the service's rootDir, so an app in an Nx or Turborepo monorepo was built without the monorepo root's lockfile and shared packages.

This matches App Hosting's buildDirectoryContext behavior for Cloud Run source deploys:

  • Walk up from rootDir (staying within projectDir) looking for nx.json or turbo.json.
  • If found, zip the monorepo root and set GOOGLE_BUILDABLE to the app's relative path (unless already set in buildEnv).
  • Otherwise, zip only rootDir and leave GOOGLE_BUILDABLE unset so self-contained apps in subfolders with their own lockfile continue to pass buildpack detection.

Local builds and App Hosting deploys are unchanged.

Scenarios Tested

  • Unit tests in src/deploy/run/deploy.spec.ts.
  • End-to-end deploys across 6 project layouts and all 4 build paths:
Layout CRFF Source Deploy (main → this PR) CRFF Local Builds FAH Source Deploy FAH Local Builds
turbo-monorepo ✗ → ✓ ✓ ✓ ✓
control-functions-web (self-contained web/ subfolder) ✓ → ✓ ✗ ✓ ✗
standalone-canary (root app) ✓ → ✓ ✓ ✓ ✓
nested-turbo-monorepo (outer firebase.json) ✗ → ✓ ✗ ✓ ✗
nx-monorepo ✗ → ✓ ✓ ✓ ✓
npm-workspaces-no-marker ✗ → ✗ ✗ ✗ ✗
  • All cells match expected behavior. For npm-workspaces-no-marker (previously unknown on FAH Source Deploys and Local Builds):
    • Source deploys (CRFF and FAH) fail with Missing dependency lock file because neither finds nx.json or turbo.json.
    • Local builds (CRFF and FAH) fail in @apphosting/adapter-nextjs (ENOENT: .../.next/standalone/.next/routes-manifest.json) because the adapter also checks for turbo.json or nx.json when locating monorepo build output.

Sample Commands

# With "rootDir": "apps/web" on the service in firebase.json:
firebase deploy --only run:web

@falahat
falahat added this pull request to stack #11267 October 14, 2026 16:19

@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 adds support for deploying applications located within Nx or Turborepo monorepos by building them from the monorepo's root directory. It introduces a buildDirectory utility to locate the monorepo root and configures Cloud Build with the GOOGLE_BUILDABLE environment variable. Feedback on the changes suggests marking the static MONOREPO_CONFIG_FILES array as as const to align with the repository's style guide. Additionally, the reviewer recommends normalizing path separators in the new unit tests to prevent failures on Windows environments.

Comment thread src/deploy/run/deploy.ts Outdated
const NO_IMAGE = "scratch";

/** Files that mark the root of an Nx or Turborepo monorepo, the monorepos App Hosting supports. */
const MONOREPO_CONFIG_FILES = ["nx.json", "turbo.json"];

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

According to the repository style guide, static configuration arrays and lookup tables should be marked as readonly or as const to prevent accidental modification and improve type safety.

Suggested change
const MONOREPO_CONFIG_FILES = ["nx.json", "turbo.json"];
const MONOREPO_CONFIG_FILES = ["nx.json", "turbo.json"] as const;
References
  1. Mark static configuration arrays and lookup tables as readonly or as const. (link)

Comment on lines +76 to +85
it("builds an app in an Nx or Turborepo monorepo from the monorepo's root", async () => {
sinon.stub(fs, "existsSync").callsFake((file) => file === "/project/turbo.json");

await deployOne({ config: web });

// The upload has the monorepo's root files, like its lockfile and shared packages.
expect(createArchiveStub).to.have.been.calledWithMatch({ backendId: "web" }, "/project");
const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild;
expect(environmentVariables).to.include({ GOOGLE_BUILDABLE: "apps/web" });
});

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

On Windows, path separators are backslashes (\\), which will cause the fs.existsSync stub comparison and the createArchiveStub assertion to fail because they use hardcoded forward-slash paths. Normalizing the paths by replacing backslashes with forward slashes makes the test cross-platform and robust.

  it("builds an app in an Nx or Turborepo monorepo from the monorepo's root", async () => {
    sinon.stub(fs, "existsSync").callsFake((file) => String(file).replace(/\\/g, "/") === "/project/turbo.json");

    await deployOne({ config: web });

    // The upload has the monorepo's root files, like its lockfile and shared packages.
    expect(createArchiveStub).to.have.been.calledWithMatch(
      { backendId: "web" },
      sinon.match((val: string) => val.replace(/\\/g, "/") === "/project")
    );
    const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild;
    expect(environmentVariables).to.include({ GOOGLE_BUILDABLE: "apps/web" });
  });

Comment on lines +284 to +331
describe("buildDirectory", () => {
/** Pretends that only these files exist. */
function withFiles(...files: string[]): void {
sinon.stub(fs, "existsSync").callsFake((file) => files.includes(file as string));
}

it("builds an app from its own folder if it isn't in a monorepo", () => {
withFiles("/project/apps/web/package.json");
expect(buildDirectory("/project", "apps/web")).to.deep.equal({
dir: "/project/apps/web",
app: "",
});
expect(buildDirectory("/project")).to.deep.equal({ dir: "/project", app: "" });
});

it("builds an app in a monorepo from the monorepo's root", () => {
withFiles("/project/turbo.json");
expect(buildDirectory("/project", "apps/web")).to.deep.equal({
dir: "/project",
app: "apps/web",
});
});

it("uses the closest folder above the app with nx.json or turbo.json", () => {
withFiles("/project/turbo.json", "/project/frontend/nx.json");
expect(buildDirectory("/project", "frontend/apps/web")).to.deep.equal({
dir: "/project/frontend",
app: "apps/web",
});
});

it("builds from the app's folder if the app is the monorepo's root", () => {
withFiles("/project/apps/web/nx.json");
expect(buildDirectory("/project", "apps/web")).to.deep.equal({
dir: "/project/apps/web",
app: "",
});
});

it("doesn't look for a monorepo outside the project", () => {
withFiles("/turbo.json");
expect(buildDirectory("/project", "apps/web")).to.deep.equal({
dir: "/project/apps/web",
app: "",
});
expect(buildDirectory("/project", "../other")).to.deep.equal({ dir: "/other", app: "" });
});
});

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

To ensure the buildDirectory tests run successfully on Windows, we should normalize the path separators in both the withFiles stub helper and the test assertions. This avoids failures caused by the mismatch between Windows backslashes (\\) and hardcoded forward-slash paths.

  describe("buildDirectory", () => {
    /** Pretends that only these files exist. */
    function withFiles(...files: string[]): void {
      sinon.stub(fs, "existsSync").callsFake((file) => files.includes(String(file).replace(/\\/g, "/")));
    }

    function normalizeDir(res: { dir: string; app: string }) {
      return { ...res, dir: res.dir.replace(/\\/g, "/") };
    }

    it("builds an app from its own folder if it isn't in a monorepo", () => {
      withFiles("/project/apps/web/package.json");
      expect(normalizeDir(buildDirectory("/project", "apps/web"))).to.deep.equal({
        dir: "/project/apps/web",
        app: "",
      });
      expect(normalizeDir(buildDirectory("/project"))).to.deep.equal({ dir: "/project", app: "" });
    });

    it("builds an app in a monorepo from the monorepo's root", () => {
      withFiles("/project/turbo.json");
      expect(normalizeDir(buildDirectory("/project", "apps/web"))).to.deep.equal({
        dir: "/project",
        app: "apps/web",
      });
    });

    it("uses the closest folder above the app with nx.json or turbo.json", () => {
      withFiles("/project/turbo.json", "/project/frontend/nx.json");
      expect(normalizeDir(buildDirectory("/project", "frontend/apps/web"))).to.deep.equal({
        dir: "/project/frontend",
        app: "apps/web",
      });
    });

    it("builds from the app's folder if the app is the monorepo's root", () => {
      withFiles("/project/apps/web/nx.json");
      expect(normalizeDir(buildDirectory("/project", "apps/web"))).to.deep.equal({
        dir: "/project/apps/web",
        app: "",
      });
    });

    it("doesn't look for a monorepo outside the project", () => {
      withFiles("/turbo.json");
      expect(normalizeDir(buildDirectory("/project", "apps/web"))).to.deep.equal({
        dir: "/project/apps/web",
        app: "",
      });
      expect(normalizeDir(buildDirectory("/project", "../other"))).to.deep.equal({ dir: "/other", app: "" });
    });
  });

@falahat falahat changed the title Build Nx and Turborepo apps from the monorepo root on Cloud Build Monorepo Cloud Run Fix: Upload the whole project for Cloud Run builds on Cloud Build, like App Hosting Oct 14, 2026
Base automatically changed from bapi_sdk_autoinit to main October 16, 2026 18:17

This branch has not been deployed

No deployments
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.

1 participant