From b03fcf1e23d09bfa840ca979bcd1a90adb01c0dc Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 29 Oct 2026 19:14:30 -0400 Subject: [PATCH 1/5] Build Nx and Turborepo apps from the monorepo root on CRFF Source Deploys, like App Hosting's preparer --- src/deploy/run/deploy.spec.ts | 96 ++++++++++++++++++++++++++++++++++- src/deploy/run/deploy.ts | 57 +++++++++++++++++---- 2 files changed, 142 insertions(+), 11 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index db259f7a424..380bb3e382b 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -1,5 +1,6 @@ import { expect } from "chai"; import * as fs from "fs"; +import * as path from "path"; import * as sinon from "sinon"; import * as localbuilds from "../../apphosting/localbuilds"; import * as artifactregistry from "../../gcp/artifactregistry"; @@ -10,7 +11,7 @@ import { Options } from "../../options"; import * as apphostingPrepare from "../apphosting/prepare"; import * as apphostingUtil from "../apphosting/util"; import { ServiceDeploy } from "./args"; -import { deploy, localBuildTemplate, revisionTemplate } from "./deploy"; +import { buildDirectory, deploy, localBuildTemplate, revisionTemplate } from "./deploy"; const NODEJS22 = "us-central1-docker.pkg.dev/serverless-runtimes/google-22/runtimes/nodejs22"; const DEPLOY_MESSAGE = "firebase.google.com/deploy-message"; @@ -58,13 +59,15 @@ describe("run deploy", () => { expect(createArchiveStub).to.have.been.calledWithMatch( { backendId: "web" }, - "/project/apps/web", + path.join("/project", "apps/web"), ); expect(submitBuildStub).to.have.been.calledWithMatch("my-project", "us-central1", { storageSource: { bucket: "source-bucket", object: "web.zip" }, imageUri: image, buildpackBuild: { baseImage: NODEJS22, enableAutomaticUpdates: true }, }); + const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild; + expect(environmentVariables).not.to.have.property("GOOGLE_BUILDABLE"); expect(createServiceStub).to.have.been.calledWithMatch("my-project", "us-central1", "web", { template: { containers: [{ name: "web", image, baseImageUri: NODEJS22 }] }, invokerIamDisabled: true, @@ -73,6 +76,29 @@ describe("run deploy", () => { expect(svc.deployed).to.deep.equal({ uri: "https://web.run.app" }); }); + it("builds an app in an Nx or Turborepo monorepo from the monorepo's root", async () => { + sinon.stub(fs, "existsSync").callsFake((file) => file === path.join("/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" }, + path.join("/project"), + ); + const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild; + expect(environmentVariables).to.include({ GOOGLE_BUILDABLE: "apps/web" }); + }); + + it("keeps GOOGLE_BUILDABLE from buildEnv if the user set it", async () => { + sinon.stub(fs, "existsSync").callsFake((file) => file === path.join("/project", "turbo.json")); + + await deployOne({ config: web, buildEnv: { GOOGLE_BUILDABLE: "custom/web" } }); + + const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild; + expect(environmentVariables).to.include({ GOOGLE_BUILDABLE: "custom/web" }); + }); + it("builds without a base image if the service doesn't have one", async () => { await deployOne({ config: web }); @@ -279,6 +305,72 @@ describe("run deploy", () => { }); }); + 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(path.join("/project", "web", "package.json")); + expect(buildDirectory("/project", "web")).to.deep.equal({ + dir: path.join("/project", "web"), + app: "", + }); + expect(buildDirectory("/project")).to.deep.equal({ dir: path.join("/project"), app: "" }); + expect(buildDirectory("/project", ".")).to.deep.equal({ + dir: path.join("/project"), + app: "", + }); + }); + + it("builds an app in a Turborepo or Nx monorepo from the monorepo's root", () => { + withFiles(path.join("/project", "turbo.json")); + expect(buildDirectory("/project", "apps/web")).to.deep.equal({ + dir: path.join("/project"), + app: "apps/web", + }); + + sinon.restore(); + withFiles(path.join("/project", "nx.json")); + expect(buildDirectory("/project", "apps/web")).to.deep.equal({ + dir: path.join("/project"), + app: "apps/web", + }); + }); + + it("uses the closest folder above the app with nx.json or turbo.json", () => { + withFiles( + path.join("/project", "turbo.json"), + path.join("/project", "monorepo", "turbo.json"), + ); + expect(buildDirectory("/project", "monorepo/apps/web")).to.deep.equal({ + dir: path.join("/project", "monorepo"), + app: "apps/web", + }); + }); + + it("builds from the app's folder if the app is the monorepo's root", () => { + withFiles(path.join("/project", "apps/web", "nx.json")); + expect(buildDirectory("/project", "apps/web")).to.deep.equal({ + dir: path.join("/project", "apps/web"), + app: "", + }); + }); + + it("doesn't look for a monorepo outside the project", () => { + withFiles(path.join("/", "turbo.json")); + expect(buildDirectory("/project", "apps/web")).to.deep.equal({ + dir: path.join("/project", "apps/web"), + app: "", + }); + expect(buildDirectory("/project", "../other")).to.deep.equal({ + dir: path.join("/project", "../other"), + app: "", + }); + }); + }); + describe("revisionTemplate", () => { const existing = { name: "projects/my-project/locations/us-central1/services/web", diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 33706c828e0..bcb12bd0b53 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -29,6 +29,9 @@ const ROLLOUT_TIMEOUT_MS = 10 * 60 * 1000; */ 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"] as const; + /** What a local build produced. */ export interface LocalBuildOutput { /** The built files to upload, relative to the scratch directory the build ran in. */ @@ -39,6 +42,14 @@ export interface LocalBuildOutput { env: NonNullable; } +/** Where a build on Cloud Build runs. */ +export interface BuildDirectory { + /** The directory to upload and build from. */ + dir: string; + /** The app's folder in `dir`, with forward slashes. Empty if the app is `dir` itself. */ + app: string; +} + /** Builds each service, locally or on Cloud Build, then rolls it out. */ export async function deploy(context: Context, options: Options, payload: Payload): Promise { for (const svc of payload.run?.services || []) { @@ -57,12 +68,37 @@ async function deployRemoteBuild( svc: ServiceDeploy, ): Promise { const { projectId } = context; - const source = await uploadSource(projectId, options, svc.config); - const image = await buildImage(projectId, svc, source); + const { dir, app } = buildDirectory(options.config.projectDir, svc.config.rootDir); + const source = await uploadSource(projectId, options, svc.config, dir); + const image = await buildImage(projectId, svc, source, app); const template = revisionTemplate(svc, image, options.message as string | undefined); svc.deployed = await rollOut(projectId, svc, template); } +/** + * Returns where to build the service on Cloud Build, the way App Hosting decides it. An app in an + * Nx or Turborepo monorepo builds from the monorepo's root, so that it has the root's lockfile and + * shared packages. Any other app builds from its own folder. Only folders in the project count. + * Exported for testing. + */ +export function buildDirectory(projectDir: string, rootDir = ""): BuildDirectory { + const appDir = path.join(projectDir, rootDir); + const appPath = path.relative(projectDir, appDir); + if (appPath === ".." || appPath.startsWith(`..${path.sep}`)) { + // The app is outside the project, so don't look for a monorepo above it. + return { dir: appDir, app: "" }; + } + // Check the app's folder, then each folder above it, up to the project's root. + const folders = appPath ? appPath.split(path.sep) : []; + for (let depth = folders.length; depth >= 0; depth--) { + const dir = path.join(projectDir, ...folders.slice(0, depth)); + if (MONOREPO_CONFIG_FILES.some((file) => fs.existsSync(path.join(dir, file)))) { + return { dir, app: folders.slice(depth).join("/") }; + } + } + return { dir: appDir, app: "" }; +} + /** * Builds the service on this machine, uploads the output, then rolls it out. Cloud Run runs the * output as is, so nothing is built on Cloud Build. @@ -132,20 +168,17 @@ async function buildLocally( return { outputFiles, runCommand: buildConfig.runCommand, env }; } -/** Zips the service's source and uploads it to the region's source bucket. */ +/** Zips `dir`, the directory to build from, and uploads it to the region's source bucket. */ async function uploadSource( projectId: string, options: Options, config: RunSingle, + dir: string, ): Promise { const { serviceId, region } = config; const bucketName = await sourceBucket(projectId, options, region); - const cfg = toAppHostingConfig(config); // The zip is a tmp file, so it's deleted when the CLI exits. - const archive = await createSourceDeployArchive( - cfg, - path.join(options.config.projectDir, cfg.rootDir), - ); + const archive = await createSourceDeployArchive(toAppHostingConfig(config), dir); logLabeledBullet("run", `Uploading source for service ${serviceId} in ${region}...`); const { bucket, object } = await gcs.uploadObject( { file: archive, stream: fs.createReadStream(archive) }, @@ -201,11 +234,15 @@ async function sourceBucket(projectId: string, options: Options, region: string) }); } -/** Builds the uploaded source into an image on Cloud Build and returns the image's URI. */ +/** + * Builds the uploaded source into an image on Cloud Build and returns the image's URI. `app` is the + * app's folder in the upload, or empty if the upload is just the app (see buildDirectory). + */ async function buildImage( projectId: string, svc: ServiceDeploy, source: runv2.StorageSource, + app: string, ): Promise { const { serviceId, region } = svc.config; await artifactregistry.ensureDockerRepository(projectId, region, "cloud-run-source-deploy"); @@ -213,6 +250,8 @@ async function buildImage( logLabeledBullet("run", `Building service ${serviceId} in ${region}...`); const buildpackBuild: runv2.BuildpacksBuild = { environmentVariables: { + // If the upload is a monorepo, tell the buildpacks which folder is the app. + ...(app ? { GOOGLE_BUILDABLE: app } : {}), // Cloud Build only takes plain values, so pass only the non-secret env vars. Prepare already // rejects secrets for builds on Cloud Build. ...nonSecretValues(svc.buildEnv || {}), From b558d6583c7152451b776df7e7386e048281ae7a Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 2 Nov 2026 10:12:57 -0500 Subject: [PATCH 2/5] Name the build folder and app folder in Cloud Run source deploys BuildDirectory's dir and app are now buildFolder and appFolder, and its doc shows the difference with three layouts: an app at the top of the project, a self-contained app next to functions/, and an app in a Turborepo. --- src/deploy/run/deploy.spec.ts | 37 +++++++------- src/deploy/run/deploy.ts | 94 +++++++++++++++++++++++------------ 2 files changed, 83 insertions(+), 48 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 380bb3e382b..b6e8a070043 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -314,28 +314,31 @@ describe("run deploy", () => { it("builds an app from its own folder if it isn't in a monorepo", () => { withFiles(path.join("/project", "web", "package.json")); expect(buildDirectory("/project", "web")).to.deep.equal({ - dir: path.join("/project", "web"), - app: "", + buildFolder: path.join("/project", "web"), + appFolder: "", + }); + expect(buildDirectory("/project")).to.deep.equal({ + buildFolder: path.join("/project"), + appFolder: "", }); - expect(buildDirectory("/project")).to.deep.equal({ dir: path.join("/project"), app: "" }); expect(buildDirectory("/project", ".")).to.deep.equal({ - dir: path.join("/project"), - app: "", + buildFolder: path.join("/project"), + appFolder: "", }); }); it("builds an app in a Turborepo or Nx monorepo from the monorepo's root", () => { withFiles(path.join("/project", "turbo.json")); expect(buildDirectory("/project", "apps/web")).to.deep.equal({ - dir: path.join("/project"), - app: "apps/web", + buildFolder: path.join("/project"), + appFolder: "apps/web", }); sinon.restore(); withFiles(path.join("/project", "nx.json")); expect(buildDirectory("/project", "apps/web")).to.deep.equal({ - dir: path.join("/project"), - app: "apps/web", + buildFolder: path.join("/project"), + appFolder: "apps/web", }); }); @@ -345,28 +348,28 @@ describe("run deploy", () => { path.join("/project", "monorepo", "turbo.json"), ); expect(buildDirectory("/project", "monorepo/apps/web")).to.deep.equal({ - dir: path.join("/project", "monorepo"), - app: "apps/web", + buildFolder: path.join("/project", "monorepo"), + appFolder: "apps/web", }); }); it("builds from the app's folder if the app is the monorepo's root", () => { withFiles(path.join("/project", "apps/web", "nx.json")); expect(buildDirectory("/project", "apps/web")).to.deep.equal({ - dir: path.join("/project", "apps/web"), - app: "", + buildFolder: path.join("/project", "apps/web"), + appFolder: "", }); }); it("doesn't look for a monorepo outside the project", () => { withFiles(path.join("/", "turbo.json")); expect(buildDirectory("/project", "apps/web")).to.deep.equal({ - dir: path.join("/project", "apps/web"), - app: "", + buildFolder: path.join("/project", "apps/web"), + appFolder: "", }); expect(buildDirectory("/project", "../other")).to.deep.equal({ - dir: path.join("/project", "../other"), - app: "", + buildFolder: path.join("/project", "../other"), + appFolder: "", }); }); }); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index bcb12bd0b53..c7e532a15a7 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -42,12 +42,42 @@ export interface LocalBuildOutput { env: NonNullable; } -/** Where a build on Cloud Build runs. */ +/** + * Where a build on Cloud Build runs: + * - The app folder is the service's rootDir, the folder with the app's package.json. + * - The build folder is the folder that's uploaded and built. For an app in an Nx or Turborepo + * monorepo, it's the monorepo's root, so the build gets the root's lockfile and shared packages. + * For any other app, it's the app folder. + * + * For example: + * + * my-app/ <- build folder and app folder + * firebase.json + * package.json + * app/ + * + * my-project/ + * firebase.json + * functions/ + * web/ <- build folder and app folder + * package.json + * + * my-turborepo/ <- build folder + * firebase.json + * turbo.json + * package.json + * apps/ + * web/ <- app folder + * package.json + */ export interface BuildDirectory { - /** The directory to upload and build from. */ - dir: string; - /** The app's folder in `dir`, with forward slashes. Empty if the app is `dir` itself. */ - app: string; + /** The build folder's path. */ + buildFolder: string; + /** + * The app folder's path in the build folder, with forward slashes, like "apps/web". Empty if the + * app folder is the build folder. + */ + appFolder: string; } /** Builds each service, locally or on Cloud Build, then rolls it out. */ @@ -68,35 +98,36 @@ async function deployRemoteBuild( svc: ServiceDeploy, ): Promise { const { projectId } = context; - const { dir, app } = buildDirectory(options.config.projectDir, svc.config.rootDir); - const source = await uploadSource(projectId, options, svc.config, dir); - const image = await buildImage(projectId, svc, source, app); + const { buildFolder, appFolder } = buildDirectory(options.config.projectDir, svc.config.rootDir); + const source = await uploadSource(projectId, options, svc.config, buildFolder); + const image = await buildImage(projectId, svc, source, appFolder); const template = revisionTemplate(svc, image, options.message as string | undefined); svc.deployed = await rollOut(projectId, svc, template); } /** - * Returns where to build the service on Cloud Build, the way App Hosting decides it. An app in an - * Nx or Turborepo monorepo builds from the monorepo's root, so that it has the root's lockfile and - * shared packages. Any other app builds from its own folder. Only folders in the project count. - * Exported for testing. + * Finds the service's build folder and app folder (see the examples on BuildDirectory), with the + * same detection logic as Firebase App Hosting: the build folder is the closest folder at or above + * the app folder that has nx.json or turbo.json, like my-turborepo/. If there's none, like in + * my-app/ and my-project/, the app folder is the build folder. Only folders in the project count, + * so an app outside the project builds from its own folder. Exported for testing. */ export function buildDirectory(projectDir: string, rootDir = ""): BuildDirectory { - const appDir = path.join(projectDir, rootDir); - const appPath = path.relative(projectDir, appDir); - if (appPath === ".." || appPath.startsWith(`..${path.sep}`)) { + const appFolderPath = path.join(projectDir, rootDir); + const appFolderInProject = path.relative(projectDir, appFolderPath); + if (appFolderInProject === ".." || appFolderInProject.startsWith(`..${path.sep}`)) { // The app is outside the project, so don't look for a monorepo above it. - return { dir: appDir, app: "" }; + return { buildFolder: appFolderPath, appFolder: "" }; } - // Check the app's folder, then each folder above it, up to the project's root. - const folders = appPath ? appPath.split(path.sep) : []; - for (let depth = folders.length; depth >= 0; depth--) { - const dir = path.join(projectDir, ...folders.slice(0, depth)); - if (MONOREPO_CONFIG_FILES.some((file) => fs.existsSync(path.join(dir, file)))) { - return { dir, app: folders.slice(depth).join("/") }; + // Check the app folder, then each folder above it, up to the project's folder. + const segments = appFolderInProject ? appFolderInProject.split(path.sep) : []; + for (let depth = segments.length; depth >= 0; depth--) { + const folder = path.join(projectDir, ...segments.slice(0, depth)); + if (MONOREPO_CONFIG_FILES.some((file) => fs.existsSync(path.join(folder, file)))) { + return { buildFolder: folder, appFolder: segments.slice(depth).join("/") }; } } - return { dir: appDir, app: "" }; + return { buildFolder: appFolderPath, appFolder: "" }; } /** @@ -168,17 +199,17 @@ async function buildLocally( return { outputFiles, runCommand: buildConfig.runCommand, env }; } -/** Zips `dir`, the directory to build from, and uploads it to the region's source bucket. */ +/** Zips the build folder (see BuildDirectory) and uploads it to the region's source bucket. */ async function uploadSource( projectId: string, options: Options, config: RunSingle, - dir: string, + buildFolder: string, ): Promise { const { serviceId, region } = config; const bucketName = await sourceBucket(projectId, options, region); // The zip is a tmp file, so it's deleted when the CLI exits. - const archive = await createSourceDeployArchive(toAppHostingConfig(config), dir); + const archive = await createSourceDeployArchive(toAppHostingConfig(config), buildFolder); logLabeledBullet("run", `Uploading source for service ${serviceId} in ${region}...`); const { bucket, object } = await gcs.uploadObject( { file: archive, stream: fs.createReadStream(archive) }, @@ -235,14 +266,15 @@ async function sourceBucket(projectId: string, options: Options, region: string) } /** - * Builds the uploaded source into an image on Cloud Build and returns the image's URI. `app` is the - * app's folder in the upload, or empty if the upload is just the app (see buildDirectory). + * Builds the uploaded build folder into an image on Cloud Build and returns the image's URI. + * `appFolder` is the app folder's path in the build folder, or empty if they're the same folder + * (see BuildDirectory). */ async function buildImage( projectId: string, svc: ServiceDeploy, source: runv2.StorageSource, - app: string, + appFolder: string, ): Promise { const { serviceId, region } = svc.config; await artifactregistry.ensureDockerRepository(projectId, region, "cloud-run-source-deploy"); @@ -250,8 +282,8 @@ async function buildImage( logLabeledBullet("run", `Building service ${serviceId} in ${region}...`); const buildpackBuild: runv2.BuildpacksBuild = { environmentVariables: { - // If the upload is a monorepo, tell the buildpacks which folder is the app. - ...(app ? { GOOGLE_BUILDABLE: app } : {}), + // In a monorepo, tell the buildpacks which folder in the build folder is the app. + ...(appFolder ? { GOOGLE_BUILDABLE: appFolder } : {}), // Cloud Build only takes plain values, so pass only the non-secret env vars. Prepare already // rejects secrets for builds on Cloud Build. ...nonSecretValues(svc.buildEnv || {}), From dbf0cfba710cc69f8f02c28d35debc83e88acddd Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 2 Nov 2026 14:04:36 -0500 Subject: [PATCH 3/5] Define buildDirectory in apphosting/util.ts Local builds use it too (in the next PR of the stack), so it starts where it ends up instead of moving there later. isInDirectory, which the local build copy also needs, comes with it. --- src/deploy/apphosting/util.spec.ts | 88 ++++++++++++++++++++++++++++++ src/deploy/apphosting/util.ts | 74 +++++++++++++++++++++++++ src/deploy/run/deploy.spec.ts | 71 +----------------------- src/deploy/run/deploy.ts | 79 +++------------------------ 4 files changed, 172 insertions(+), 140 deletions(-) diff --git a/src/deploy/apphosting/util.spec.ts b/src/deploy/apphosting/util.spec.ts index 84ef48a406c..993bcce6088 100644 --- a/src/deploy/apphosting/util.spec.ts +++ b/src/deploy/apphosting/util.spec.ts @@ -187,4 +187,92 @@ describe("util", () => { expect(files).to.include("apphosting.yaml"); }); }); + + describe("buildDirectory", () => { + afterEach(() => sinon.restore()); + + /** 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(path.join("/project", "web", "package.json")); + expect(util.buildDirectory("/project", "web")).to.deep.equal({ + buildFolder: path.join("/project", "web"), + appFolder: "", + }); + expect(util.buildDirectory("/project")).to.deep.equal({ + buildFolder: path.join("/project"), + appFolder: "", + }); + expect(util.buildDirectory("/project", ".")).to.deep.equal({ + buildFolder: path.join("/project"), + appFolder: "", + }); + }); + + it("builds an app in a Turborepo or Nx monorepo from the monorepo's root", () => { + withFiles(path.join("/project", "turbo.json")); + expect(util.buildDirectory("/project", "apps/web")).to.deep.equal({ + buildFolder: path.join("/project"), + appFolder: "apps/web", + }); + + sinon.restore(); + withFiles(path.join("/project", "nx.json")); + expect(util.buildDirectory("/project", "apps/web")).to.deep.equal({ + buildFolder: path.join("/project"), + appFolder: "apps/web", + }); + }); + + it("uses the closest folder above the app with nx.json or turbo.json", () => { + withFiles( + path.join("/project", "turbo.json"), + path.join("/project", "monorepo", "turbo.json"), + ); + expect(util.buildDirectory("/project", "monorepo/apps/web")).to.deep.equal({ + buildFolder: path.join("/project", "monorepo"), + appFolder: "apps/web", + }); + }); + + it("builds from the app's folder if the app is the monorepo's root", () => { + withFiles(path.join("/project", "apps/web", "nx.json")); + expect(util.buildDirectory("/project", "apps/web")).to.deep.equal({ + buildFolder: path.join("/project", "apps/web"), + appFolder: "", + }); + }); + + it("doesn't look for a monorepo outside the project", () => { + withFiles(path.join("/", "turbo.json")); + expect(util.buildDirectory("/project", "apps/web")).to.deep.equal({ + buildFolder: path.join("/project", "apps/web"), + appFolder: "", + }); + expect(util.buildDirectory("/project", "../other")).to.deep.equal({ + buildFolder: path.join("/project", "../other"), + appFolder: "", + }); + }); + }); + + describe("isInDirectory", () => { + it("is true for the directory itself and anything in it", () => { + expect(util.isInDirectory(path.join("/project"), path.join("/project"))).to.be.true; + expect(util.isInDirectory(path.join("/project", "apps/web"), path.join("/project"))).to.be + .true; + // A folder named "..foo" is in the project, even though its name starts with "..". + expect(util.isInDirectory(path.join("/project", "..foo"), path.join("/project"))).to.be.true; + }); + + it("is false for anything outside the directory", () => { + expect(util.isInDirectory(path.join("/"), path.join("/project"))).to.be.false; + expect(util.isInDirectory(path.join("/other"), path.join("/project"))).to.be.false; + // A sibling whose name starts with the directory's name isn't in it. + expect(util.isInDirectory(path.join("/project-other"), path.join("/project"))).to.be.false; + }); + }); }); diff --git a/src/deploy/apphosting/util.ts b/src/deploy/apphosting/util.ts index b409ea5307b..305a9dd014f 100644 --- a/src/deploy/apphosting/util.ts +++ b/src/deploy/apphosting/util.ts @@ -150,6 +150,80 @@ export function resolveIgnorePatterns( ignore.push(".local_build_*"); return ignore; } + +/** Files that mark the root of an Nx or Turborepo monorepo, the monorepos App Hosting supports. */ +const MONOREPO_CONFIG_FILES = ["nx.json", "turbo.json"] as const; + +/** + * Where an app builds: + * - The app folder is the backend's or service's rootDir, the folder with the app's package.json. + * - The build folder is the folder the build runs in, and what Cloud Run source deploys upload. For + * an app in an Nx or Turborepo monorepo, it's the monorepo's root, so the build gets the root's + * lockfile and shared packages. For any other app, it's the app folder. + * + * For example: + * + * my-app/ <- build folder and app folder + * firebase.json + * package.json + * app/ + * + * my-project/ + * firebase.json + * functions/ + * web/ <- build folder and app folder + * package.json + * + * my-turborepo/ <- build folder + * firebase.json + * turbo.json + * package.json + * apps/ + * web/ <- app folder + * package.json + */ +export interface BuildDirectory { + /** The build folder's path. */ + buildFolder: string; + /** + * The app folder's path in the build folder, with forward slashes, like "apps/web". Empty if the + * app folder is the build folder. + */ + appFolder: string; +} + +/** + * Finds an app's build folder and app folder (see the examples on BuildDirectory), with the same + * detection logic as Firebase App Hosting: the build folder is the closest folder at or above the + * app folder that has nx.json or turbo.json, like my-turborepo/. If there's none, like in my-app/ + * and my-project/, the app folder is the build folder. Only folders in the project count, so an + * app outside the project builds from its own folder. + */ +export function buildDirectory(projectDir: string, rootDir = ""): BuildDirectory { + const appFolderPath = path.join(projectDir, rootDir); + if (!isInDirectory(appFolderPath, projectDir)) { + // The app is outside the project, so don't look for a monorepo above it. + return { buildFolder: appFolderPath, appFolder: "" }; + } + // Check the app folder, then each folder above it, up to the project's folder. + const appFolderInProject = path.relative(projectDir, appFolderPath); + const segments = appFolderInProject ? appFolderInProject.split(path.sep) : []; + for (let depth = segments.length; depth >= 0; depth--) { + const folder = path.join(projectDir, ...segments.slice(0, depth)); + if (MONOREPO_CONFIG_FILES.some((file) => fs.existsSync(path.join(folder, file)))) { + return { buildFolder: folder, appFolder: segments.slice(depth).join("/") }; + } + } + return { buildFolder: appFolderPath, appFolder: "" }; +} + +/** Returns whether `child` is `parent` or a directory in it. */ +export function isInDirectory(child: string, parent: string): boolean { + const relative = path.relative(parent, child); + // path.relative returns an absolute path if there's no relative one, e.g. on another drive. + return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); +} + async function pipeAsync(from: archiver.Archiver, to: fs.WriteStream): Promise { from.pipe(to); await from.finalize(); diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index b6e8a070043..70ab8f90359 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -11,7 +11,7 @@ import { Options } from "../../options"; import * as apphostingPrepare from "../apphosting/prepare"; import * as apphostingUtil from "../apphosting/util"; import { ServiceDeploy } from "./args"; -import { buildDirectory, deploy, localBuildTemplate, revisionTemplate } from "./deploy"; +import { deploy, localBuildTemplate, revisionTemplate } from "./deploy"; const NODEJS22 = "us-central1-docker.pkg.dev/serverless-runtimes/google-22/runtimes/nodejs22"; const DEPLOY_MESSAGE = "firebase.google.com/deploy-message"; @@ -305,75 +305,6 @@ describe("run deploy", () => { }); }); - 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(path.join("/project", "web", "package.json")); - expect(buildDirectory("/project", "web")).to.deep.equal({ - buildFolder: path.join("/project", "web"), - appFolder: "", - }); - expect(buildDirectory("/project")).to.deep.equal({ - buildFolder: path.join("/project"), - appFolder: "", - }); - expect(buildDirectory("/project", ".")).to.deep.equal({ - buildFolder: path.join("/project"), - appFolder: "", - }); - }); - - it("builds an app in a Turborepo or Nx monorepo from the monorepo's root", () => { - withFiles(path.join("/project", "turbo.json")); - expect(buildDirectory("/project", "apps/web")).to.deep.equal({ - buildFolder: path.join("/project"), - appFolder: "apps/web", - }); - - sinon.restore(); - withFiles(path.join("/project", "nx.json")); - expect(buildDirectory("/project", "apps/web")).to.deep.equal({ - buildFolder: path.join("/project"), - appFolder: "apps/web", - }); - }); - - it("uses the closest folder above the app with nx.json or turbo.json", () => { - withFiles( - path.join("/project", "turbo.json"), - path.join("/project", "monorepo", "turbo.json"), - ); - expect(buildDirectory("/project", "monorepo/apps/web")).to.deep.equal({ - buildFolder: path.join("/project", "monorepo"), - appFolder: "apps/web", - }); - }); - - it("builds from the app's folder if the app is the monorepo's root", () => { - withFiles(path.join("/project", "apps/web", "nx.json")); - expect(buildDirectory("/project", "apps/web")).to.deep.equal({ - buildFolder: path.join("/project", "apps/web"), - appFolder: "", - }); - }); - - it("doesn't look for a monorepo outside the project", () => { - withFiles(path.join("/", "turbo.json")); - expect(buildDirectory("/project", "apps/web")).to.deep.equal({ - buildFolder: path.join("/project", "apps/web"), - appFolder: "", - }); - expect(buildDirectory("/project", "../other")).to.deep.equal({ - buildFolder: path.join("/project", "../other"), - appFolder: "", - }); - }); - }); - describe("revisionTemplate", () => { const existing = { name: "projects/my-project/locations/us-central1/services/web", diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index c7e532a15a7..710f43bf485 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -14,7 +14,11 @@ import { logger } from "../../logger"; import { Options } from "../../options"; import { cloneDeep, logLabeledBullet } from "../../utils"; import { prepareLocalBuildScratchDirectory } from "../apphosting/prepare"; -import { createLocalBuildTarArchive, createSourceDeployArchive } from "../apphosting/util"; +import { + buildDirectory, + createLocalBuildTarArchive, + createSourceDeployArchive, +} from "../apphosting/util"; import { Context, Payload, ServiceDeploy } from "./args"; import { nonSecretValues, secretNames, toLocalBuildEnv } from "./buildEnv"; import { FIREBASE_APP_ANNOTATION, mainContainer } from "./util"; @@ -29,9 +33,6 @@ const ROLLOUT_TIMEOUT_MS = 10 * 60 * 1000; */ 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"] as const; - /** What a local build produced. */ export interface LocalBuildOutput { /** The built files to upload, relative to the scratch directory the build ran in. */ @@ -42,44 +43,6 @@ export interface LocalBuildOutput { env: NonNullable; } -/** - * Where a build on Cloud Build runs: - * - The app folder is the service's rootDir, the folder with the app's package.json. - * - The build folder is the folder that's uploaded and built. For an app in an Nx or Turborepo - * monorepo, it's the monorepo's root, so the build gets the root's lockfile and shared packages. - * For any other app, it's the app folder. - * - * For example: - * - * my-app/ <- build folder and app folder - * firebase.json - * package.json - * app/ - * - * my-project/ - * firebase.json - * functions/ - * web/ <- build folder and app folder - * package.json - * - * my-turborepo/ <- build folder - * firebase.json - * turbo.json - * package.json - * apps/ - * web/ <- app folder - * package.json - */ -export interface BuildDirectory { - /** The build folder's path. */ - buildFolder: string; - /** - * The app folder's path in the build folder, with forward slashes, like "apps/web". Empty if the - * app folder is the build folder. - */ - appFolder: string; -} - /** Builds each service, locally or on Cloud Build, then rolls it out. */ export async function deploy(context: Context, options: Options, payload: Payload): Promise { for (const svc of payload.run?.services || []) { @@ -105,31 +68,6 @@ async function deployRemoteBuild( svc.deployed = await rollOut(projectId, svc, template); } -/** - * Finds the service's build folder and app folder (see the examples on BuildDirectory), with the - * same detection logic as Firebase App Hosting: the build folder is the closest folder at or above - * the app folder that has nx.json or turbo.json, like my-turborepo/. If there's none, like in - * my-app/ and my-project/, the app folder is the build folder. Only folders in the project count, - * so an app outside the project builds from its own folder. Exported for testing. - */ -export function buildDirectory(projectDir: string, rootDir = ""): BuildDirectory { - const appFolderPath = path.join(projectDir, rootDir); - const appFolderInProject = path.relative(projectDir, appFolderPath); - if (appFolderInProject === ".." || appFolderInProject.startsWith(`..${path.sep}`)) { - // The app is outside the project, so don't look for a monorepo above it. - return { buildFolder: appFolderPath, appFolder: "" }; - } - // Check the app folder, then each folder above it, up to the project's folder. - const segments = appFolderInProject ? appFolderInProject.split(path.sep) : []; - for (let depth = segments.length; depth >= 0; depth--) { - const folder = path.join(projectDir, ...segments.slice(0, depth)); - if (MONOREPO_CONFIG_FILES.some((file) => fs.existsSync(path.join(folder, file)))) { - return { buildFolder: folder, appFolder: segments.slice(depth).join("/") }; - } - } - return { buildFolder: appFolderPath, appFolder: "" }; -} - /** * Builds the service on this machine, uploads the output, then rolls it out. Cloud Run runs the * output as is, so nothing is built on Cloud Build. @@ -199,7 +137,7 @@ async function buildLocally( return { outputFiles, runCommand: buildConfig.runCommand, env }; } -/** Zips the build folder (see BuildDirectory) and uploads it to the region's source bucket. */ +/** Zips the service's source and uploads it to the region's source bucket. */ async function uploadSource( projectId: string, options: Options, @@ -208,8 +146,9 @@ async function uploadSource( ): Promise { const { serviceId, region } = config; const bucketName = await sourceBucket(projectId, options, region); + const cfg = toAppHostingConfig(config); // The zip is a tmp file, so it's deleted when the CLI exits. - const archive = await createSourceDeployArchive(toAppHostingConfig(config), buildFolder); + const archive = await createSourceDeployArchive(cfg, buildFolder); logLabeledBullet("run", `Uploading source for service ${serviceId} in ${region}...`); const { bucket, object } = await gcs.uploadObject( { file: archive, stream: fs.createReadStream(archive) }, @@ -268,7 +207,7 @@ async function sourceBucket(projectId: string, options: Options, region: string) /** * Builds the uploaded build folder into an image on Cloud Build and returns the image's URI. * `appFolder` is the app folder's path in the build folder, or empty if they're the same folder - * (see BuildDirectory). + * (see BuildDirectory in apphosting/util.ts). */ async function buildImage( projectId: string, From 874b616c2f4dfe7978b19cb21881f705d984a31f Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 2 Nov 2026 15:59:54 -0500 Subject: [PATCH 4/5] Don't special-case an app outside the project An app's rootDir is a folder in the project, like every other path in firebase.json, so buildDirectory doesn't need a branch for one outside it, or isInDirectory. --- src/deploy/apphosting/util.spec.ts | 23 +---------------------- src/deploy/apphosting/util.ts | 16 ++-------------- 2 files changed, 3 insertions(+), 36 deletions(-) diff --git a/src/deploy/apphosting/util.spec.ts b/src/deploy/apphosting/util.spec.ts index 993bcce6088..4e12fe7991f 100644 --- a/src/deploy/apphosting/util.spec.ts +++ b/src/deploy/apphosting/util.spec.ts @@ -246,33 +246,12 @@ describe("util", () => { }); }); - it("doesn't look for a monorepo outside the project", () => { + it("doesn't look for a monorepo above the project", () => { withFiles(path.join("/", "turbo.json")); expect(util.buildDirectory("/project", "apps/web")).to.deep.equal({ buildFolder: path.join("/project", "apps/web"), appFolder: "", }); - expect(util.buildDirectory("/project", "../other")).to.deep.equal({ - buildFolder: path.join("/project", "../other"), - appFolder: "", - }); - }); - }); - - describe("isInDirectory", () => { - it("is true for the directory itself and anything in it", () => { - expect(util.isInDirectory(path.join("/project"), path.join("/project"))).to.be.true; - expect(util.isInDirectory(path.join("/project", "apps/web"), path.join("/project"))).to.be - .true; - // A folder named "..foo" is in the project, even though its name starts with "..". - expect(util.isInDirectory(path.join("/project", "..foo"), path.join("/project"))).to.be.true; - }); - - it("is false for anything outside the directory", () => { - expect(util.isInDirectory(path.join("/"), path.join("/project"))).to.be.false; - expect(util.isInDirectory(path.join("/other"), path.join("/project"))).to.be.false; - // A sibling whose name starts with the directory's name isn't in it. - expect(util.isInDirectory(path.join("/project-other"), path.join("/project"))).to.be.false; }); }); }); diff --git a/src/deploy/apphosting/util.ts b/src/deploy/apphosting/util.ts index 305a9dd014f..16ac8e5b387 100644 --- a/src/deploy/apphosting/util.ts +++ b/src/deploy/apphosting/util.ts @@ -195,16 +195,11 @@ export interface BuildDirectory { /** * Finds an app's build folder and app folder (see the examples on BuildDirectory), with the same * detection logic as Firebase App Hosting: the build folder is the closest folder at or above the - * app folder that has nx.json or turbo.json, like my-turborepo/. If there's none, like in my-app/ - * and my-project/, the app folder is the build folder. Only folders in the project count, so an - * app outside the project builds from its own folder. + * app folder, up to the project's folder, that has nx.json or turbo.json, like my-turborepo/. If + * there's none, like in my-app/ and my-project/, the app folder is the build folder. */ export function buildDirectory(projectDir: string, rootDir = ""): BuildDirectory { const appFolderPath = path.join(projectDir, rootDir); - if (!isInDirectory(appFolderPath, projectDir)) { - // The app is outside the project, so don't look for a monorepo above it. - return { buildFolder: appFolderPath, appFolder: "" }; - } // Check the app folder, then each folder above it, up to the project's folder. const appFolderInProject = path.relative(projectDir, appFolderPath); const segments = appFolderInProject ? appFolderInProject.split(path.sep) : []; @@ -217,13 +212,6 @@ export function buildDirectory(projectDir: string, rootDir = ""): BuildDirectory return { buildFolder: appFolderPath, appFolder: "" }; } -/** Returns whether `child` is `parent` or a directory in it. */ -export function isInDirectory(child: string, parent: string): boolean { - const relative = path.relative(parent, child); - // path.relative returns an absolute path if there's no relative one, e.g. on another drive. - return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); -} - async function pipeAsync(from: archiver.Archiver, to: fs.WriteStream): Promise { from.pipe(to); await from.finalize(); From 553efb8849db5b12adb96d095deccd7eaf3d1c37 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 3 Nov 2026 17:40:02 -0500 Subject: [PATCH 5/5] Throw if rootDir points outside the project directory --- src/deploy/apphosting/util.spec.ts | 5 +++++ src/deploy/apphosting/util.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/deploy/apphosting/util.spec.ts b/src/deploy/apphosting/util.spec.ts index 4e12fe7991f..213bb39cb60 100644 --- a/src/deploy/apphosting/util.spec.ts +++ b/src/deploy/apphosting/util.spec.ts @@ -6,6 +6,7 @@ import * as tar from "tar"; import * as util from "./util"; import * as sinon from "sinon"; import { AppHostingSingle } from "../../firebaseConfig"; +import { FirebaseError } from "../../error"; import { logger } from "../../logger"; describe("util", () => { @@ -253,5 +254,9 @@ describe("util", () => { appFolder: "", }); }); + + it("throws if rootDir points outside the project", () => { + expect(() => util.buildDirectory("/project", "../web")).to.throw(FirebaseError, /rootDir/); + }); }); }); diff --git a/src/deploy/apphosting/util.ts b/src/deploy/apphosting/util.ts index 16ac8e5b387..0f5d1573a44 100644 --- a/src/deploy/apphosting/util.ts +++ b/src/deploy/apphosting/util.ts @@ -200,8 +200,11 @@ export interface BuildDirectory { */ export function buildDirectory(projectDir: string, rootDir = ""): BuildDirectory { const appFolderPath = path.join(projectDir, rootDir); - // Check the app folder, then each folder above it, up to the project's folder. const appFolderInProject = path.relative(projectDir, appFolderPath); + if (appFolderInProject.startsWith("..")) { + throw new FirebaseError(`rootDir "${rootDir}" must be inside the project directory.`); + } + // Check the app folder, then each folder above it, up to the project's folder. const segments = appFolderInProject ? appFolderInProject.split(path.sep) : []; for (let depth = segments.length; depth >= 0; depth--) { const folder = path.join(projectDir, ...segments.slice(0, depth));