diff --git a/src/deploy/apphosting/prepare.spec.ts b/src/deploy/apphosting/prepare.spec.ts index a20831f0186..c55eebaa0fa 100644 --- a/src/deploy/apphosting/prepare.spec.ts +++ b/src/deploy/apphosting/prepare.spec.ts @@ -24,6 +24,8 @@ import * as apphostingUtils from "../../apphosting/utils"; import { AppHostingYamlConfig, EnvMap } from "../../apphosting/yaml"; import { Options } from "../../options"; import { AppHostingSingle } from "../../firebaseConfig"; +import * as fs from "fs"; +import * as fsAsync from "../../fsAsync"; const BASE_OPTS = { cwd: "/", @@ -88,6 +90,12 @@ describe("apphosting", () => { addServiceAccountToRolesStub = sinon .stub(resourceManager, "addServiceAccountToRoles") .resolves(); + + sinon.stub(fs, "existsSync").returns(false); + sinon.stub(fs, "mkdirSync").returns(undefined); + sinon.stub(fs, "rmSync").returns(undefined); + sinon.stub(fs, "copyFileSync").returns(undefined); + sinon.stub(fsAsync, "readdirRecursive").resolves([]); }); afterEach(() => { diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index 4862cda5e75..dea854e6623 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -1,4 +1,7 @@ +import * as fs from "fs"; import * as path from "path"; +import * as fsAsync from "../../fsAsync"; +import { resolveIgnorePatterns } from "./util"; import { doSetupSourceDeploy, ensureAppHostingComputeServiceAccount, @@ -192,10 +195,15 @@ export default async function (context: Context, options: Options): Promise { + // Resolve ignores for local builds, skipping default node_modules ignore + const ignore = resolveIgnorePatterns(cfg, rootDir, /* skipDefaultNodeModules= */ true); + ignore.push("local_build"); // Always ignore the build directory itself + + // Warn if node_modules is explicitly ignored + if (cfg.ignore?.includes("node_modules")) { + logLabeledWarning( + "apphosting", + `You have included 'node_modules' in your ignore list for local builds. This might cause the build to fail if dependencies are missing in the build directory.`, + ); + } + + // Create local_build dir + if (fs.existsSync(localBuildDir)) { + fs.rmSync(localBuildDir, { recursive: true, force: true }); + } + fs.mkdirSync(localBuildDir, { recursive: true }); + + // Copy files respecting ignores + const filesToCopy = await fsAsync.readdirRecursive({ + path: rootDir, + ignore: ignore, + isGitIgnore: true, + }); + + for (const file of filesToCopy) { + const relativePath = path.relative(rootDir, file.name); + const destPath = path.join(localBuildDir, relativePath); + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.copyFileSync(file.name, destPath); + } +} diff --git a/src/deploy/apphosting/util.spec.ts b/src/deploy/apphosting/util.spec.ts index 9ade32530bd..ae773c49c7e 100644 --- a/src/deploy/apphosting/util.spec.ts +++ b/src/deploy/apphosting/util.spec.ts @@ -4,6 +4,7 @@ import * as path from "path"; import * as tmp from "tmp"; import * as tar from "tar"; import * as util from "./util"; +import { AppHostingSingle } from "../../firebaseConfig"; describe("util", () => { let tmpDir: tmp.DirResult; @@ -78,5 +79,90 @@ describe("util", () => { expect(files).to.include("dist/index.js"); expect(files).to.not.include("apphosting.yaml"); }); + + it("should respect ignore patterns in config", async () => { + fs.writeFileSync(path.join(distDir, "index.js"), "console.log('hello')"); + fs.writeFileSync(path.join(distDir, "ignored.txt"), "ignore me"); + + const config = { + backendId: "test-backend", + rootDir: "", + ignore: ["**/ignored.txt"], + }; + + const tarballPath: string = await util.createLocalBuildTarArchive( + config, + rootDir, + path.relative(rootDir, distDir), + ); + + const files: string[] = []; + tar.list({ + file: tarballPath, + sync: true, + onentry: (entry: { path: string }) => files.push(entry.path), + }); + + expect(files).to.include("dist/index.js"); + expect(files).to.not.include("dist/ignored.txt"); + }); + + it("should use default ignores when config.ignore is missing", async () => { + fs.writeFileSync(path.join(distDir, "index.js"), "console.log('hello')"); + const nodeModulesDir = path.join(distDir, "node_modules"); + fs.mkdirSync(nodeModulesDir); + fs.writeFileSync(path.join(nodeModulesDir, "some-file.js"), "console.log('vendor')"); + + const configWithoutIgnore: AppHostingSingle = { + backendId: "test-backend", + rootDir: "", + ignore: undefined as unknown as string[], + }; + + const tarballPath: string = await util.createLocalBuildTarArchive( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + configWithoutIgnore, + rootDir, + path.relative(rootDir, distDir), + ); + + const files: string[] = []; + tar.list({ + file: tarballPath, + sync: true, + onentry: (entry: { path: string }) => files.push(entry.path), + }); + + expect(files).to.include("dist/index.js"); + expect(files).to.not.include("dist/node_modules/some-file.js"); + }); + + it("should respect .gitignore patterns", async () => { + fs.writeFileSync(path.join(distDir, "index.js"), "console.log('hello')"); + fs.writeFileSync(path.join(distDir, "gitignored.txt"), "ignore me"); + fs.writeFileSync(path.join(distDir, ".gitignore"), "gitignored.txt"); + + const config = { + backendId: "test-backend", + rootDir: "", + ignore: [], + }; + + const tarballPath: string = await util.createLocalBuildTarArchive( + config, + rootDir, + path.relative(rootDir, distDir), + ); + + const files: string[] = []; + tar.list({ + file: tarballPath, + sync: true, + onentry: (entry: { path: string }) => files.push(entry.path), + }); + + expect(files).to.include("dist/index.js"); + expect(files).to.not.include("dist/gitignored.txt"); + }); }); }); diff --git a/src/deploy/apphosting/util.ts b/src/deploy/apphosting/util.ts index b96d76e42e2..c06d2044225 100644 --- a/src/deploy/apphosting/util.ts +++ b/src/deploy/apphosting/util.ts @@ -28,7 +28,7 @@ export async function createLocalBuildTarArchive( const tmpFile = tmp.fileSync({ prefix: `${config.backendId}-`, postfix: ".tar.gz" }).name; const targetDir = targetSubDir ? path.join(rootDir, targetSubDir) : rootDir; - const ignore = ["firebase-debug.log", "firebase-debug.*.log", ".git"]; + const ignore = resolveIgnorePatterns(config, targetDir); const rdrFiles = await fsAsync.readdirRecursive({ path: targetDir, ignore: ignore, @@ -90,10 +90,7 @@ export async function createSourceDeployArchive( const targetDir = targetSubDir ? path.join(rootDir, targetSubDir) : rootDir; // We must ignore firebase-debug.log or weird things happen if you're in the public dir when you deploy. - const ignore = config.ignore || ["node_modules", ".git"]; - ignore.push("firebase-debug.log", "firebase-debug.*.log"); - const gitIgnorePatterns = parseGitIgnorePatterns(targetDir); - ignore.push(...gitIgnorePatterns); + const ignore = resolveIgnorePatterns(config, targetDir); try { const files = await fsAsync.readdirRecursive({ path: targetDir, @@ -110,13 +107,33 @@ export async function createSourceDeployArchive( await pipeAsync(archive, fileStream); } catch (err: unknown) { throw new FirebaseError( - `Could not read source directory. Remove links and shortcuts and try again. Original: ${err}`, + `Could not read source directory. Remove links and shortcuts and try again. Original: ${String(err)}`, { original: err as Error, exit: 1 }, ); } return tmpFile; } +/** + * Resolves the ignore patterns for App Hosting deployments. + * Merges config ignores, defaults, and .gitignore patterns. + */ +export function resolveIgnorePatterns( + config: AppHostingSingle, + targetDir: string, + skipDefaultNodeModules = false, +): string[] { + const ignore = config.ignore + ? [...config.ignore] + : skipDefaultNodeModules + ? [".git"] + : ["node_modules", ".git"]; + ignore.push("firebase-debug.log", "firebase-debug.*.log"); + const gitIgnorePatterns = parseGitIgnorePatterns(targetDir); + ignore.push(...gitIgnorePatterns); + return ignore; +} + function parseGitIgnorePatterns(projectRoot: string, gitIgnorePath = ".gitignore"): string[] { const absoluteFilePath = path.resolve(projectRoot, gitIgnorePath); if (!fs.existsSync(absoluteFilePath)) {