Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 178 additions & 1 deletion src/apphosting/localbuilds.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import * as sinon from "sinon";
import { expect } from "chai";
import { localBuild, runUniversalMaker } from "./localbuilds";
import { localBuild, runUniversalMaker, validateLocalBuildNodeVersion } from "./localbuilds";
import * as secrets from "./secrets/index";
import { EnvMap } from "./yaml";
import * as childProcess from "child_process";
import * as utils from "../utils";

import * as universalMakerDownload from "./universalMakerDownload";
import * as fsExtra from "fs-extra";
Expand All @@ -15,7 +16,7 @@
downloadStub = sinon
.stub(universalMakerDownload, "getOrDownloadUniversalMaker")
.resolves("/path/to/universal_maker");
sinon.stub(fsExtra, "readFileSync").callsFake((pathStr: any) => {

Check warning on line 19 in src/apphosting/localbuilds.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
if (typeof pathStr === "string" && pathStr.includes("bundle.yaml")) {
return `
runConfig:
Expand All @@ -41,7 +42,7 @@
});
sinon.stub(fsExtra, "existsSync").returns(true);
sinon.stub(fsExtra, "unlinkSync");
sinon.stub(fsExtra, "readdirSync").returns(["bundle.yaml"] as any);

Check warning on line 45 in src/apphosting/localbuilds.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 45 in src/apphosting/localbuilds.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `Dirent<NonSharedBuffer>[]`
sinon.stub(fsExtra, "ensureDirSync");
sinon.stub(fsExtra, "removeSync");
sinon.stub(fsExtra, "moveSync");
Expand Down Expand Up @@ -116,13 +117,13 @@
});

it("resolves BUILD-available secrets passed in the environment map and ignores RUNTIME-only ones", async () => {
sinon.stub(childProcess, "spawnSync").callsFake((command: any, args: any, options: any) => {

Check warning on line 120 in src/apphosting/localbuilds.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 120 in src/apphosting/localbuilds.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 120 in src/apphosting/localbuilds.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
expect(process.env.MY_BUILD_SECRET).to.be.undefined;
expect(process.env.MY_PLAIN_VAR).to.be.undefined;
expect(options?.env?.MY_BUILD_SECRET).to.equal("secret-value");

Check warning on line 123 in src/apphosting/localbuilds.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .env on an `any` value
expect(options?.env?.MY_RUNTIME_SECRET).to.be.undefined;

Check warning on line 124 in src/apphosting/localbuilds.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .env on an `any` value
expect(options?.env?.MY_PLAIN_VAR).to.equal("plain-value");

Check warning on line 125 in src/apphosting/localbuilds.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .env on an `any` value
return {

Check warning on line 126 in src/apphosting/localbuilds.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe return of an `any` typed value
status: 0,
output: ["", "mock output", ""],
pid: 12345,
Expand Down Expand Up @@ -312,4 +313,180 @@
sinon.assert.calledOnce(downloadStub);
});
});

describe("validateLocalBuildNodeVersion", () => {
let logWarningStub: sinon.SinonStub;
let execSyncStub: sinon.SinonStub;
let readJsonStub: sinon.SinonStub;

beforeEach(() => {
logWarningStub = sinon.stub(utils, "logLabeledWarning");
execSyncStub = sinon.stub(childProcess, "execSync");
readJsonStub = sinon.stub(fsExtra, "readJsonSync");
});

afterEach(() => {
sinon.restore();
});

it("throws error if ABIU is disabled", () => {
const backend = {
name: "projects/my-project/locations/us-central1/backends/foo",
runtime: { value: "nodejs" },
} as any;

expect(() => validateLocalBuildNodeVersion(backend, "./")).to.throw(
"Local builds are only supported for backends with ABIU",
);
});

it("logs warning and exits early if runtime version is not extractable", () => {
const backend = {
name: "projects/my-project/locations/us-central1/backends/foo",
runtime: { value: "invalid-runtime-string" },
} as any;

validateLocalBuildNodeVersion(backend, "./");

expect(logWarningStub).to.have.been.calledWith(
"apphosting",
sinon.match("Unable to extract Node.js major version from the backend runtime"),
);
expect(execSyncStub).to.not.have.been.called;
});

it("warns about package.json engines not being used for local build execution", () => {
const backend = {
name: "projects/my-project/locations/us-central1/backends/foo",
runtime: { value: "nodejs22" },
} as any;

execSyncStub.returns("v22.15.0");
readJsonStub.returns({
engines: { node: "22" },
});

validateLocalBuildNodeVersion(backend, "./");

expect(logWarningStub).to.have.been.calledOnceWith(
"apphosting",
sinon.match('local builds do NOT use the "engines" field'),
);
});

it("warns if package.json engines range does not satisfy the target version", () => {
const backend = {
name: "projects/my-project/locations/us-central1/backends/foo",
runtime: { value: "nodejs22" },
} as any;

execSyncStub.returns("v22.15.0");
readJsonStub.returns({
engines: { node: "20" },
});

validateLocalBuildNodeVersion(backend, "./");

expect(logWarningStub).to.have.been.calledTwice;
expect(logWarningStub.secondCall).to.have.been.calledWith(
"apphosting",
sinon.match("does not satisfy your backend's target ABIU runtime version"),
);
});
Comment thread
falahat marked this conversation as resolved.

it("does not warn on minor/patch constraints in engines if target major is satisfied", () => {
const backend = {
name: "projects/my-project/locations/us-central1/backends/foo",
runtime: { value: "nodejs22" },
} as any;

execSyncStub.returns("v22.15.0");
readJsonStub.returns({
engines: { node: "^22.15.0" },
});

validateLocalBuildNodeVersion(backend, "./");

// Should only log the informational "engines not used for local build execution" warning
expect(logWarningStub).to.have.been.calledOnce;
expect(logWarningStub.firstCall).to.have.been.calledWith(
"apphosting",
sinon.match('local builds do NOT use the "engines" field'),
);
});

it("handles complex logical OR engines ranges correctly", () => {
const backend = {
name: "projects/my-project/locations/us-central1/backends/foo",
runtime: { value: "nodejs22" },
} as any;

execSyncStub.returns("v22.15.0");

// Case 1: Overlapping OR range (18 || 22) - Should NOT warn
readJsonStub.returns({
engines: { node: "18 || 22" },
});
validateLocalBuildNodeVersion(backend, "./");
expect(logWarningStub).to.have.been.calledOnce; // Only informational warning
logWarningStub.resetHistory();

// Case 2: Non-overlapping OR range (18 || 20) - Should warn!
readJsonStub.returns({
engines: { node: "18 || 20" },
});
validateLocalBuildNodeVersion(backend, "./");
expect(logWarningStub).to.have.been.calledTwice; // Informational + mismatch warning
});

it("warns if local host Node version doesn't match the target version", () => {
const backend = {
name: "projects/my-project/locations/us-central1/backends/foo",
runtime: { value: "nodejs22" },
} as any;

execSyncStub.returns("v24.10.0");
readJsonStub.returns({});

validateLocalBuildNodeVersion(backend, "./");

expect(logWarningStub).to.have.been.calledOnceWith(
"apphosting",
sinon.match(
"Local Node.js version (v24.10.0) does not match your backend's target Node.js version",
),
);
});

it("does not log warnings when all versions are aligned", () => {
const backend = {
name: "projects/my-project/locations/us-central1/backends/foo",
runtime: { value: "nodejs22" },
} as any;

execSyncStub.returns("v22.15.0");
readJsonStub.returns({});

validateLocalBuildNodeVersion(backend, "./");

expect(logWarningStub).to.not.have.been.called;
});

it("warns if local Node.js version detection fails (e.g. node not in PATH)", () => {
const backend = {
name: "projects/my-project/locations/us-central1/backends/foo",
runtime: { value: "nodejs22" },
} as any;

execSyncStub.throws(new Error("command not found"));
readJsonStub.returns({});

validateLocalBuildNodeVersion(backend, "./");

expect(logWarningStub).to.have.been.calledOnceWith(
"apphosting",
sinon.match("Unable to detect your local Node.js version"),
);
});
});
});
93 changes: 91 additions & 2 deletions src/apphosting/localbuilds.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import * as childProcess from "child_process";
import * as fs from "fs-extra";
import * as path from "path";
import { Availability, BuildConfig, Env } from "../gcp/apphosting";
import * as semver from "semver";
import { Availability, Backend, BuildConfig, Env } from "../gcp/apphosting";

import { EnvMap } from "./yaml";
import { loadSecret } from "./secrets/index";
import { confirm } from "../prompt";
import { FirebaseError, getErrMsg } from "../error";
import { logger } from "../logger";
import { wrappedSafeLoad } from "../utils";
import { wrappedSafeLoad, logLabeledWarning } from "../utils";
import { getOrDownloadUniversalMaker } from "./universalMakerDownload";

interface UniversalMakerOutput {
Expand Down Expand Up @@ -266,3 +267,91 @@ async function toProcessEnv(projectId: string, env: EnvMap): Promise<NodeJS.Proc

return Object.fromEntries(resolvedEntries) as NodeJS.ProcessEnv;
}

/**
* Validates that the local Node.js environment and project configuration are
* compatible with the target backend's ABIU runtime settings.
*
* This performs three checks:
* 1. Confirms the backend has ABIU enabled (local builds are only supported on ABIU runtimes).
* 2. Warns if the host machine's Node.js major version differs from the target ABIU major version.
* 3. Warns if the package.json engines.node range does not satisfy the target ABIU version.
*/
export function validateLocalBuildNodeVersion(backend: Backend, projectRoot: string): void {
const runtimeValue = backend.runtime?.value ?? "";
const isLegacyRuntime = runtimeValue === "" || runtimeValue === "nodejs";
const abiuEnabled = !isLegacyRuntime && !backend.automaticBaseImageUpdatesDisabled;

// 1. Block non-ABIU runtimes
if (!abiuEnabled) {
throw new FirebaseError(
`Local builds are only supported for backends with ABIU (Automatic Base Image Updates) enabled. ` +
`Your backend is currently configured with a non-ABIU runtime ("${runtimeValue || "unspecified"}"). ` +
`Please update your backend to a versioned runtime (e.g., nodejs22) to enable local builds.`,
{ exit: 1 },
);
}

const targetMajorMatch = runtimeValue.match(/^nodejs(\d+)$/);
if (!targetMajorMatch) {
logLabeledWarning(
"apphosting",
`Unable to extract Node.js major version from the backend runtime ("${runtimeValue}"). ` +
`Skipping local Node.js version compatibility checks.`,
);
return;
}

const targetMajor = parseInt(targetMajorMatch[1], 10);

// Get the local Node.js version that will be used to build the app
let localNodeVersion: string;
try {
localNodeVersion = childProcess.execSync("node -v", { encoding: "utf8" }).trim();
} catch {
logLabeledWarning(
"apphosting",
`Unable to detect your local Node.js version (is 'node' installed and in your PATH?). ` +
`Skipping local Node.js version compatibility checks.`,
);
return;
}

// Check package.json engines
const packageJsonPath = path.join(projectRoot, "package.json");
const packageJson = fs.readJsonSync(packageJsonPath, { throws: false });
const enginesNode = packageJson?.engines?.node;

if (enginesNode) {
logLabeledWarning(
"apphosting",
`Your package.json specifies Node.js engine "${enginesNode}". ` +
`Please note that local builds do NOT use the "engines" field to resolve or download Node.js. ` +
`Instead, your local build uses your host machine's active Node.js version (${localNodeVersion}) to compile the app, ` +
`and your deployed app will run on the backend's configured ABIU runtime (${runtimeValue}).`,
);

const targetRange = `^${targetMajor}.0.0`;
if (semver.validRange(enginesNode) && !semver.intersects(targetRange, enginesNode)) {
logLabeledWarning(
"apphosting",
`The Node.js version range specified in your package.json engines ("${enginesNode}") ` +
`does not satisfy your backend's target ABIU runtime version (Node.js ${targetMajor}). ` +
`Please update your package.json engines to align with your backend configuration.`,
);
}
}

// 2. Check local vs target ABIU runtime version
const localMajorMatch = localNodeVersion.match(/^v?(\d+)/);
const localMajor = localMajorMatch ? parseInt(localMajorMatch[1], 10) : null;

if (localMajor !== null && localMajor !== targetMajor) {
logLabeledWarning(
"apphosting",
`Local Node.js version (${localNodeVersion}) does not match your backend's target Node.js version (Node.js ${targetMajor}). ` +
`This mismatch may cause runtime issues. ` +
`Please switch your local environment to Node.js ${targetMajor} to ensure build-to-run parity.`,
);
Comment thread
falahat marked this conversation as resolved.
}
}
Loading
Loading