Skip to content
Closed
Show file tree
Hide file tree
Changes from 50 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
65e0c59
Zip deploy fixes
annajowang Nov 22, 2025
ecbbe5e
Merge branch 'master' into zip-deploy
annajowang Dec 2, 2025
60adf94
use staging and specify uploading TAR
annajowang Dec 2, 2025
bcf0cd3
Improve docs
falahat Feb 2, 2026
4603750
Allow reading of simple env vars (not secrets) and passing them down …
falahat Feb 2, 2026
eb89f6b
Add some fixes for npm parsing issues
falahat Feb 3, 2026
9470c3d
Adding various local fixes/hacks to make local builds work. It includes:
falahat Feb 3, 2026
269da87
Follow-up fixes for ensuring we have account access for cloud storage…
falahat Feb 4, 2026
92a3f49
formatting fixes
falahat Feb 4, 2026
76ef53a
Merge branch 'main' of github.com:firebase/firebase-tools into zip-de…
falahat Feb 4, 2026
340020f
Get rid of env override
falahat Feb 11, 2026
d5343bd
Temporarily add a test app to easily test changes
falahat Feb 11, 2026
e17f55c
For local builds, explicitly check for apphosting.yaml files (and all…
falahat Feb 13, 2026
8341bc4
Add hono to packages
falahat Feb 13, 2026
4ed3b41
Merge branch 'main' of github.com:firebase/firebase-tools into zip-de…
falahat Feb 13, 2026
b76fa54
Merge branch 'main' of github.com:firebase/firebase-tools into zip-de…
falahat Feb 13, 2026
31fed5d
Merge branch 'zip-deploy' of github.com:firebase/firebase-tools into …
falahat Feb 13, 2026
2f653ca
Merge branch 'main' of github.com:firebase/firebase-tools into zip_de…
falahat Mar 9, 2026
b19b8bf
Get rid of test app
falahat Mar 9, 2026
611f258
Get rid of uniformBucketLevelAccess config which was not required
falahat Mar 9, 2026
37bb714
Fix tarball unit test
falahat Mar 9, 2026
1df73d7
Make local builds less hardcoded by checking if the build config is a…
falahat Mar 9, 2026
7d61148
Add a new experiment flag to control local builds (apphostinglocalbui…
falahat Mar 9, 2026
0ec1b92
Remove references to runtime flag
falahat Mar 9, 2026
2ea831d
Merge branch 'main' into zip_deploy_aryanf
falahat Mar 10, 2026
d20b46c
Merge branch 'main' of github.com:firebase/firebase-tools into zip_de…
falahat Mar 13, 2026
25262ac
Clean up the PR so that we gate by apphostinglocalbuilds experiment m…
falahat Mar 13, 2026
2f63f7a
Make the code for adding service account IAM permission more restrict…
falahat Mar 13, 2026
de4f95b
remove es2020 dependency
falahat Mar 13, 2026
7f122c2
feat: barebones local build implementation
falahat Mar 14, 2026
a4476a1
feat: reintroduce p4sa logic for local builds
falahat Mar 14, 2026
4158d52
Fix p4sa logic, add back autoinitEnvVars handling as well
falahat Mar 14, 2026
826754b
Merge branch 'main' of github.com:firebase/firebase-tools into zip_de…
falahat Mar 23, 2026
89d1b2f
Remove unrelated changes
falahat Mar 24, 2026
6829e31
Remove unrelated changes
falahat Mar 24, 2026
004e0e7
Merge branch 'zip_deploy_env_vars' of github.com:firebase/firebase-to…
falahat Mar 24, 2026
613bdcc
Reset npm-shrinkwrap file
falahat Mar 24, 2026
84a34b6
linters
falahat Mar 24, 2026
6349afc
Update src/deploy/apphosting/prepare.ts
falahat Mar 24, 2026
2f040c9
Remove dupe test
falahat Mar 24, 2026
2d7c228
Merge branch 'zip_deploy_env_vars' of github.com:firebase/firebase-to…
falahat Mar 24, 2026
ae833ee
Merge branch 'zip_deploy_env_vars' of github.com:firebase/firebase-to…
falahat Mar 24, 2026
e6b011f
Merge branch 'zip_deploy_env_vars' of github.com:firebase/firebase-to…
falahat Mar 24, 2026
8564e52
Get rid of redundant env variable
falahat Mar 24, 2026
cc78270
Merge branch 'main' into zip_deploy_env_vars
falahat Mar 24, 2026
3027dad
Merge branch 'main' of github.com:firebase/firebase-tools into zip_de…
falahat Mar 25, 2026
b5e285b
Merge branch 'zip_deploy_env_vars' of github.com:firebase/firebase-to…
falahat Mar 25, 2026
2b73285
linter
falahat Mar 25, 2026
90fb82b
fixes
falahat Mar 25, 2026
29fbd6e
Fix env var wiring
falahat Mar 25, 2026
5db54e2
Remove outdated backend code
falahat Mar 25, 2026
f12056a
Fix unit test
falahat Mar 26, 2026
b571019
Merge branch 'main' of github.com:firebase/firebase-tools into zip_de…
falahat Mar 26, 2026
7f19002
Inject auto-init env vars into both runtime env vars and build time e…
falahat Mar 26, 2026
13ed461
Make sure env vars are available during the BUILD phase if they don't…
falahat Mar 26, 2026
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
27 changes: 27 additions & 0 deletions src/apphosting/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
iamOrigin,
secretManagerOrigin,
} from "../api";
import { logger } from "../logger";
import { Backend, BackendOutputOnlyFields, API_VERSION } from "../gcp/apphosting";
import { addServiceAccountToRoles } from "../gcp/resourceManager";
import * as iam from "../gcp/iam";
Expand Down Expand Up @@ -52,7 +53,7 @@
// SSL.
const maybeNodeError = err as { cause: { code: string }; code: string };
if (
/HANDSHAKE_FAILURE/.test(maybeNodeError?.cause?.code) ||

Check warning on line 56 in src/apphosting/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Use `String#includes()` method with a string instead
"EPROTO" === maybeNodeError?.code
) {
return false;
Expand Down Expand Up @@ -294,6 +295,32 @@
await githubConnections.linkGitHubRepository(projectId, location, connectionId);
}

/**
* Ensures that the App Hosting service agent has the necessary permissions to
* manage resources in the project.
*/
export async function ensureAppHostingServiceAgentRoles(
projectId: string,
projectNumber: string,
): Promise<void> {
const p4saEmail = apphosting.serviceAgentEmail(projectNumber);
try {
await addServiceAccountToRoles(
projectId,
p4saEmail,
["roles/storage.objectViewer"],
/* skipAccountLookup= */ true,
);
} catch (err: unknown) {
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

The err variable is of type unknown. It's safer to explicitly convert it to a string using String(err) or use a utility like getErrMsg from ../error to ensure proper logging, especially if err might not always be an Error instance.

    logger.debug(`Failed to grant storage.objectViewer to ${p4saEmail}: ${String(err)}`);

logger.debug(`Failed to grant storage.objectViewer to ${p4saEmail}: ${err}`);

Check warning on line 315 in src/apphosting/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "unknown" of template literal expression
// We don't want to fail the entire prepare step if this fails, as it might
// be due to insufficient permissions to grant roles.
logWarning(
`Unable to verify App Hosting service agent permissions for ${p4saEmail}. If you encounter a PERMISSION_DENIED error during rollout, please ensure the service agent has the "Storage Object Viewer" role.`,
);
}
}

/**
* Ensures the service account is present the user has permissions to use it by
* checking the `iam.serviceAccounts.actAs` permission. If the permissions
Expand Down Expand Up @@ -337,7 +364,7 @@
* Prompts the user for a backend id and verifies that it doesn't match a pre-existing backend.
*/
export async function promptNewBackendId(projectId: string, location: string): Promise<string> {
while (true) {

Check warning on line 367 in src/apphosting/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected constant condition
const backendId = await input({
default: "my-web-app",
message: "Provide a name for your backend [1-30 characters]",
Expand Down Expand Up @@ -650,7 +677,7 @@
message: locationDisambugationPrompt,
choices: [...backendsByLocation.keys()],
});
return backendsByLocation.get(location)!;

Check warning on line 680 in src/apphosting/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
}

/**
Expand Down
21 changes: 21 additions & 0 deletions src/apphosting/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,27 @@
});
});

describe("splitEnvVars", () => {
it("should stringify numeric values", () => {
const env: AppHostingYamlConfig["env"] = {
STR: { value: "string" },
NUM: { value: 12345 as any },

Check warning on line 480 in src/apphosting/config.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 480 in src/apphosting/config.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
BUILD_AND_RUNTIME_NUM: { value: 67890 as any, availability: ["BUILD", "RUNTIME"] },

Check warning on line 481 in src/apphosting/config.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 481 in src/apphosting/config.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
Comment on lines +480 to +481
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

Using as any here for numeric values, while functional due to explicit string conversion in splitEnvVars, could be made more type-safe. Consider defining the EnvMap type to explicitly allow number for value if that's the intended input, or provide string literals if the input is always expected to be stringified before use.

Suggested change
NUM: { value: 12345 as any },
BUILD_AND_RUNTIME_NUM: { value: 67890 as any, availability: ["BUILD", "RUNTIME"] },
NUM: { value: "12345" },
BUILD_AND_RUNTIME_NUM: { value: "67890", availability: ["BUILD", "RUNTIME"] },

};

const { build, runtime } = config.splitEnvVars(env);

expect(build["BUILD_AND_RUNTIME_NUM"].value).to.equal("67890");
expect(runtime).to.deep.include({ variable: "STR", value: "string" });
expect(runtime).to.deep.include({ variable: "NUM", value: "12345" });
expect(runtime).to.deep.include({
variable: "BUILD_AND_RUNTIME_NUM",
value: "67890",
availability: ["BUILD", "RUNTIME"],
});
});
});

describe("getAppHostingConfiguration", () => {
let loadAppHostingYamlStub: sinon.SinonStub;
let listAppHostingFilesInPathStub: sinon.SinonStub;
Expand Down
27 changes: 25 additions & 2 deletions src/apphosting/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { join, dirname } from "path";
import { join, dirname, basename } from "path";
import { writeFileSync } from "fs";
import * as yaml from "yaml";
import * as clc from "colorette";
Expand All @@ -11,7 +11,6 @@
import { logger } from "../logger";
import * as csm from "../gcp/secretManager";
import { FirebaseError, getError } from "../error";
import { basename } from "path";

// Common config across all environments
export const APPHOSTING_BASE_YAML_FILE = "apphosting.yaml";
Expand Down Expand Up @@ -62,7 +61,7 @@
export function discoverBackendRoot(cwd: string): string | null {
let dir = cwd;

while (true) {

Check warning on line 64 in src/apphosting/config.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected constant condition
const files = fs.listFiles(dir);
if (files.some((file) => APPHOSTING_YAML_FILE_REGEX.test(file))) {
return dir;
Expand Down Expand Up @@ -101,7 +100,7 @@
let raw: string;
try {
raw = fs.readFile(yamlPath);
} catch (err: any) {

Check warning on line 103 in src/apphosting/config.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
if (err.code !== "ENOENT") {
throw new FirebaseError(`Unexpected error trying to load ${yamlPath}`, {
original: getError(err),
Expand Down Expand Up @@ -393,3 +392,27 @@
export function suggestedTestKeyName(variable: string): string {
return "test-" + variable.replace(/_/g, "-").toLowerCase();
}

/**
* Split a set of environment variables into build and runtime variables.
*/
export function splitEnvVars(env: EnvMap): { build: EnvMap; runtime: Env[] } {
const build: EnvMap = {};
const runtime: Env[] = [];

for (const [key, val] of Object.entries(env)) {
const envVal = { ...val };
if (envVal.value !== undefined) {
envVal.value = String(envVal.value);
}

if (val.availability?.includes("BUILD")) {
build[key] = envVal;
}
if (val.availability?.includes("RUNTIME") || !val.availability) {
runtime.push({ variable: key, ...envVal });
}
}

return { build, runtime };
}
61 changes: 52 additions & 9 deletions src/apphosting/localbuilds.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as path from "path";
import { BuildConfig, Env } from "../gcp/apphosting";
import { localBuild as localAppHostingBuild } from "@apphosting/build";
import { EnvMap } from "./yaml";

/**
* Triggers a local build of your App Hosting codebase.
Expand All @@ -17,31 +19,72 @@ import { localBuild as localAppHostingBuild } from "@apphosting/build";
export async function localBuild(
projectRoot: string,
framework: string,
env: EnvMap = {},
): Promise<{
outputFiles: string[];
annotations: Record<string, string>;
buildConfig: BuildConfig;
}> {
const apphostingBuildOutput = await localAppHostingBuild(projectRoot, framework);
// We need to inject the environment variables into the process.env
// because the build adapter uses them to build the app.
// We'll restore the original process.env after the build is done.
const originalEnv = { ...process.env };
const projectNodeModules = path.join(projectRoot, "node_modules");
const newNodePath = process.env.NODE_PATH
? `${process.env.NODE_PATH}${path.delimiter}${projectNodeModules}`
: projectNodeModules;

const addedEnv = toProcessEnv(env);
for (const [key, value] of Object.entries(addedEnv)) {
process.env[key] = value;
}
const originalNodePath = process.env.NODE_PATH;
process.env.NODE_PATH = newNodePath;

let apphostingBuildOutput;
try {
apphostingBuildOutput = await localAppHostingBuild(projectRoot, framework);
} finally {
for (const key in process.env) {
if (!(key in originalEnv)) {
delete process.env[key];
}
}
for (const [key, value] of Object.entries(originalEnv)) {
process.env[key] = value;
}
if (originalNodePath !== undefined) {
process.env.NODE_PATH = originalNodePath;
} else {
delete process.env.NODE_PATH;
}
}

const annotations: Record<string, string> = Object.fromEntries(
Object.entries(apphostingBuildOutput.metadata).map(([key, value]) => [key, String(value)]),
);

const env: Env[] | undefined = apphostingBuildOutput.runConfig.environmentVariables?.map(
({ variable, value, availability }) => ({
variable,
value,
availability,
}),
);
const discoveredEnv: Env[] | undefined =
apphostingBuildOutput.runConfig.environmentVariables?.map(
({ variable, value, availability }) => ({
variable,
value,
availability,
}),
);

return {
outputFiles: apphostingBuildOutput.outputFiles?.serverApp.include ?? [],
annotations,
buildConfig: {
runCommand: apphostingBuildOutput.runConfig.runCommand,
env: env ?? [],
env: discoveredEnv ?? [],
},
};
}

function toProcessEnv(env: EnvMap): NodeJS.ProcessEnv {
return Object.fromEntries(
Object.entries(env).map(([key, value]) => [key, value.value || ""]),
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.

high

The expression value.value || "" could unintentionally convert numeric 0 or boolean false values to an empty string. It's safer to explicitly convert value.value to a string using String(value.value) to ensure all values are correctly represented as strings in process.env.

Suggested change
Object.entries(env).map(([key, value]) => [key, value.value || ""]),
Object.entries(env).map(([key, value]) => [key, String(value.value || "")]),

) as NodeJS.ProcessEnv;
}
2 changes: 1 addition & 1 deletion src/apphosting/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FirebaseError } from "../error";
import { APPHOSTING_BASE_YAML_FILE, APPHOSTING_YAML_FILE_REGEX } from "./config";
import { WebConfig } from "../fetchWebSetup";
import { APPHOSTING_BASE_YAML_FILE, APPHOSTING_YAML_FILE_REGEX } from "./config";
import * as prompt from "../prompt";

/**
Expand Down
70 changes: 69 additions & 1 deletion src/deploy/apphosting/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Context } from "./args";
import { FirebaseError } from "../../error";
import prepare, { getBackendConfigs } from "./prepare";
import * as localbuilds from "../../apphosting/localbuilds";
import * as managementApps from "../../management/apps";
import * as experiments from "../../experiments";
import * as getProjectNumber from "../../getProjectNumber";
import * as resourceManager from "../../gcp/resourceManager";
Expand Down Expand Up @@ -68,13 +69,15 @@ describe("apphosting", () => {
.stub(apphosting, "listBackends")
.throws("Unexpected listBackends call");
sinon.stub(backend, "ensureAppHostingComputeServiceAccount").resolves();
sinon.stub(backend, "ensureAppHostingServiceAgentRoles").resolves();
sinon.stub(getProjectNumber, "getProjectNumber").resolves("123456789");
sinon.stub(apiEnabled, "ensure").resolves();
getGitRepositoryLinkStub = sinon
.stub(devconnect, "getGitRepositoryLink")
.throws("Unexpected getGitRepositoryLink call");
assertEnabledStub = sinon.stub(experiments, "assertEnabled").returns();
sinon.stub(experiments, "isEnabled").returns(true);
sinon.stub(getProjectNumber, "getProjectNumber").resolves("123456789");

addServiceAccountToRolesStub = sinon
.stub(resourceManager, "addServiceAccountToRoles")
.resolves();
Expand Down Expand Up @@ -143,6 +146,71 @@ describe("apphosting", () => {
);
});

it("injects Firebase configuration when appId is present", async () => {
const optsWithLocalBuild = {
...opts,
config: new Config({
apphosting: {
backendId: "foo",
rootDir: "/",
ignore: [],
localBuild: true,
},
}),
};
const context = initializeContext();

const webAppConfig = {
projectId: "my-project",
appId: "my-app-id",
apiKey: "my-api-key",
authDomain: "my-project.firebaseapp.com",
databaseURL: "https://my-project.firebaseio.com",
storageBucket: "my-project.appspot.com",
messagingSenderId: "123456",
measurementId: "G-123456",
};

sinon.stub(managementApps, "getAppConfig").resolves(webAppConfig);
const localBuildStub = sinon.stub(localbuilds, "localBuild").resolves({
outputFiles: ["./next/standalone"],
buildConfig: { runCommand: "npm run build", env: [] },
annotations: {},
});

listBackendsStub.onFirstCall().resolves({
backends: [
{
name: "projects/my-project/locations/us-central1/backends/foo",
appId: "my-app-id",
},
],
});

await prepare(context, optsWithLocalBuild);

expect(localBuildStub).to.be.calledWithMatch(
sinon.match.any,
"nextjs",
sinon.match({
FIREBASE_WEBAPP_CONFIG: { value: JSON.stringify(webAppConfig) },
FIREBASE_CONFIG: {
value: JSON.stringify({
databaseURL: webAppConfig.databaseURL,
storageBucket: webAppConfig.storageBucket,
projectId: webAppConfig.projectId,
}),
},
}),
);
expect(addServiceAccountToRolesStub).to.have.been.calledWith(
"my-project",
apphosting.serviceAgentEmail("123456789"),
["roles/storage.objectViewer"],
true,
);
});

it("should fail if localBuild is specified but experiment is disabled", async () => {
const optsWithLocalBuild = {
...opts,
Expand Down
Loading
Loading