Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
caaf161
Create a basic, initial schema for firebase.json
falahat Sep 28, 2026
dd384b6
improve testing
falahat Sep 29, 2026
d868931
Merge branch 'main' into bapi_firebase_json
falahat Sep 29, 2026
c8fbf9c
Require region, remove serviceAccount, and register run in Config.KNO…
falahat Sep 30, 2026
254e3f5
Add direct_cloud_run experiment flag
falahat Sep 30, 2026
99b87a5
Update shared GCP clients (runv2, run, artifactregistry)
falahat Sep 30, 2026
e388293
Add firebase init run interactive setup
falahat Sep 30, 2026
2131e74
Add firebase deploy --only run for remote Cloud Build source deploys
falahat Sep 30, 2026
595ee57
Add firebase run:services:update command
falahat Sep 30, 2026
60f5d65
feat(run): add local build support and build environment variables
falahat Sep 30, 2026
d429b4d
chore(apphosting): add TODO for bundle.yaml env vars in local builds
falahat Sep 30, 2026
29355ca
Rename direct_cloud_run experiment to directcloudrun
falahat Sep 30, 2026
9f6652b
Merge branch 'bapi_experiment' into bapi_init_run
falahat Sep 30, 2026
f190309
Use directcloudrun experiment and validate rootDir is a directory
falahat Sep 30, 2026
6f9e954
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Sep 30, 2026
9f0562e
Clean up temporary source archive, guard release URI, and use directc…
falahat Sep 30, 2026
ae1258e
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Sep 30, 2026
d094393
Validate service region and check all matching configs in updateService
falahat Sep 30, 2026
8ea72f0
Merge bapi_services_update into bapi_local_builds and split runComman…
falahat Sep 30, 2026
f004a3a
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Sep 30, 2026
d0eb8fb
Trim baseImage input in init run
falahat Sep 30, 2026
aa0457a
Avoid empty template.annotations when deploy message is unset
falahat Sep 30, 2026
46ac57f
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Sep 30, 2026
5afe078
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Sep 30, 2026
d07f523
Clear localBuild in upsertRunConfig without unused destructuring
falahat Sep 30, 2026
e7ac386
Simplify template.annotations cleanup when deploy message is unset
falahat Sep 30, 2026
5fca627
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Sep 30, 2026
9fc0e9d
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Sep 30, 2026
46a8aea
Add defensive optional chaining in run.ts and runv2.ts
falahat Sep 30, 2026
697a3f1
Merge branch 'bapi_gcp_and_shared' into bapi_experiment
falahat Sep 30, 2026
ed37ac2
Merge branch 'bapi_experiment' into bapi_init_run
falahat Sep 30, 2026
601d6b9
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Sep 30, 2026
bfb2b1b
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Sep 30, 2026
67c2c7c
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Sep 30, 2026
6fe8427
Remove unused Deployable predeploy/postdeploy from RunSingle
falahat Sep 30, 2026
22aea66
Merge branch 'bapi_firebase_json' into bapi_gcp_and_shared
falahat Sep 30, 2026
5632a25
Merge branch 'bapi_gcp_and_shared' into bapi_experiment
falahat Sep 30, 2026
efd2f58
Merge branch 'bapi_experiment' into bapi_init_run
falahat Sep 30, 2026
cc1ed57
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Sep 30, 2026
2c28eef
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Sep 30, 2026
79e1d02
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Sep 30, 2026
7524ef3
Merge branch 'main' into bapi_firebase_json
falahat Sep 30, 2026
5b2495f
Merge branch 'bapi_firebase_json' into bapi_gcp_and_shared
falahat Sep 30, 2026
339f5f3
Merge branch 'bapi_gcp_and_shared' into bapi_experiment
falahat Sep 30, 2026
699cf07
Merge branch 'bapi_experiment' into bapi_init_run
falahat Sep 30, 2026
3ba264a
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Sep 30, 2026
1608f73
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Sep 30, 2026
ea0afd4
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Sep 30, 2026
113c80b
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Sep 30, 2026
b0b3e6f
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Sep 30, 2026
87edcae
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 2, 2026
36a7b1d
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 2, 2026
db9edb6
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 2, 2026
f59829a
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 2, 2026
5ad1cbf
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 2, 2026
1b46515
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 2, 2026
dcd78b8
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 2, 2026
e7f9781
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 3, 2026
c319224
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 3, 2026
1bf57bd
Make local build scratch dir cleanup best-effort
falahat Oct 3, 2026
17c4029
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 4, 2026
6007416
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 7, 2026
11191df
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 7, 2026
fb1a25f
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 7, 2026
625247c
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 8, 2026
6d20249
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 8, 2026
9a9613f
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 8, 2026
58bbeff
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 8, 2026
5ee1358
Default init run's build location to the saved localBuild setting
falahat Oct 8, 2026
eeac006
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 8, 2026
4501ba7
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 8, 2026
fbd2a8f
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 8, 2026
6bfd112
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 8, 2026
5924af4
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 8, 2026
272a9e2
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 9, 2026
ca5c791
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 9, 2026
ac7deb7
Move build env to its own PR
falahat Oct 9, 2026
276b076
Make local builds easier to read
falahat Oct 9, 2026
fd2a3de
Keep localBuild's options on one line
falahat Oct 9, 2026
f9b0ab7
Make the local build tests easier to read
falahat Oct 9, 2026
5f410b5
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 12, 2026
67bf761
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 12, 2026
f0c99c0
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 12, 2026
c379f76
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 12, 2026
f7b5a17
Split Cloud Run deploys into remote and local build paths
falahat Oct 12, 2026
da44ccb
Let a local build's env vars replace the service's values
falahat Oct 12, 2026
0b740f7
Merge branch 'bapi_services_update' into bapi_local_builds
falahat Oct 13, 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
2 changes: 2 additions & 0 deletions src/apphosting/localbuilds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
const failed = !!res.error || res.status !== 0;

// If UM fails, log results to stdout and hide otherwise.
const log = (msg: string) => (failed ? logger.info(msg) : logger.debug(msg));

Check warning on line 73 in src/apphosting/localbuilds.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function

if (res.stdout) {
log("[Universal Maker stdout]:\n" + res.stdout.toString());
Expand Down Expand Up @@ -116,6 +116,8 @@
outputFiles?: { serverApp?: { include?: string[] } };
};

// TODO: Also merge bundleData.runConfig.environmentVariables (and resource limits) from
// bundle.yaml to match the App Hosting publisher step on remote builds.
const runCommand = bundleData?.runConfig?.runCommand ?? defaultRunCommand;
const outputFiles = bundleData?.outputFiles?.serverApp?.include ?? [];
return { runCommand, outputFiles };
Expand Down Expand Up @@ -306,7 +308,7 @@
);
}

const targetMajorMatch = runtimeValue.match(/^nodejs(\d+)$/);

Check warning on line 311 in src/apphosting/localbuilds.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Use the `RegExp#exec()` method instead
if (!targetMajorMatch) {
logLabeledWarning(
"apphosting",
Expand All @@ -333,23 +335,23 @@

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

Check warning on line 338 in src/apphosting/localbuilds.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
const enginesNode = packageJson?.engines?.node;

Check warning on line 339 in src/apphosting/localbuilds.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .engines on an `any` value

Check warning on line 339 in src/apphosting/localbuilds.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value

if (enginesNode) {
logLabeledWarning(
"apphosting",
`Your package.json specifies Node.js engine "${enginesNode}". ` +

Check warning on line 344 in src/apphosting/localbuilds.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Invalid type "any" of template literal expression
`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)) {

Check warning on line 351 in src/apphosting/localbuilds.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `string | Range`

Check warning on line 351 in src/apphosting/localbuilds.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `string | Range`
logLabeledWarning(
"apphosting",
`The Node.js version range specified in your package.json engines ("${enginesNode}") ` +

Check warning on line 354 in src/apphosting/localbuilds.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Invalid type "any" of template literal expression
`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.`,
);
Expand All @@ -357,7 +359,7 @@
}

// 2. Check local vs target ABIU runtime version
const localMajorMatch = localNodeVersion.match(/^v?(\d+)/);

Check warning on line 362 in src/apphosting/localbuilds.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Use the `RegExp#exec()` method instead
const localMajor = localMajorMatch ? parseInt(localMajorMatch[1], 10) : null;

if (localMajor !== null && localMajor !== targetMajor) {
Expand Down
2 changes: 1 addition & 1 deletion src/deploy/apphosting/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ async function ensureAppHostingServiceAgentRoles(
* isolated scratch workspace in the `.local_build_<backendId>` folder that contains exactly the same
* source files that would be uploaded to Cloud Build.
*/
async function prepareLocalBuildScratchDirectory(
export async function prepareLocalBuildScratchDirectory(
rootDir: string,
localBuildScratchDir: string,
cfg: AppHostingSingle,
Expand Down
156 changes: 155 additions & 1 deletion src/deploy/run/deploy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { expect } from "chai";
import * as fs from "fs";
import * as sinon from "sinon";
import * as localbuilds from "../../apphosting/localbuilds";
import * as artifactregistry from "../../gcp/artifactregistry";
import * as runv2 from "../../gcp/runv2";
import * as gcs from "../../gcp/storage";
import * as getProjectNumber from "../../getProjectNumber";
import { Options } from "../../options";
import * as apphostingPrepare from "../apphosting/prepare";
import * as apphostingUtil from "../apphosting/util";
import { ServiceDeploy } from "./args";
import { deploy, 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";
Expand All @@ -18,6 +20,8 @@ describe("run deploy", () => {
const web = { serviceId: "web", region: "us-central1", rootDir: "apps/web" };
// Date.now() is stubbed to return 42, which tags the image.
const image = "us-central1-docker.pkg.dev/my-project/cloud-run-source-deploy/web:42";
const scratchDir = "/tmp/run-local-build-web-abc123";
const builtApp = { bucket: "source-bucket", object: "web.tar.gz" };
let createArchiveStub: sinon.SinonStub;
let submitBuildStub: sinon.SinonStub;
let createServiceStub: sinon.SinonStub;
Expand Down Expand Up @@ -108,6 +112,97 @@ describe("run deploy", () => {
);
});

describe("local builds", () => {
const localWeb = { ...web, localBuild: true };
let rmSyncStub: sinon.SinonStub;
let validateNodeStub: sinon.SinonStub;
let localBuildStub: sinon.SinonStub;
let tarArchiveStub: sinon.SinonStub;

beforeEach(() => {
sinon.stub(fs, "mkdtempSync").returns(scratchDir);
rmSyncStub = sinon.stub(fs, "rmSync");
validateNodeStub = sinon.stub(localbuilds, "validateLocalBuildNodeVersion");
sinon.stub(apphostingPrepare, "prepareLocalBuildScratchDirectory").resolves();
localBuildStub = sinon.stub(localbuilds, "localBuild").resolves({
outputFiles: [".next"],
buildConfig: { runCommand: "npm run start" },
});
tarArchiveStub = sinon
.stub(apphostingUtil, "createLocalBuildTarArchive")
.resolves("/tmp/web.tar.gz");
(gcs.uploadObject as sinon.SinonStub)
.withArgs(sinon.match({ file: "/tmp/web.tar.gz" }))
.resolves(builtApp);
});

/** Deploys web with "localBuild": true and returns the container that it rolled out. */
async function deployLocalBuild(): Promise<runv2.Container> {
await deployOne({ config: localWeb, baseImage: NODEJS22 });
return createServiceStub.firstCall.args[3].template.containers[0];
}

it("builds on this machine, in a scratch folder that it deletes afterwards", async () => {
await deployLocalBuild();

expect(validateNodeStub).to.have.been.calledWith(
{ runtime: { value: "nodejs22" } },
"/project/apps/web",
);
expect(localBuildStub).to.have.been.calledWithMatch(
"my-project",
scratchDir,
{},
{ rootDir: "apps/web" },
);
expect(rmSyncStub).to.have.been.calledWith(scratchDir);
});

it("uploads the build's output and runs it as is, without Cloud Build", async () => {
const container = await deployLocalBuild();

expect(tarArchiveStub).to.have.been.calledWithMatch(sinon.match.any, scratchDir, [".next"]);
expect(submitBuildStub).not.to.have.been.called;
expect(container).to.deep.include({
image: "scratch",
sourceCode: { cloudStorageSource: builtApp },
command: ["npm", "run", "start"],
});
});

it("keeps the env vars the app needs at runtime", async () => {
localBuildStub.resolves({
outputFiles: [".next"],
buildConfig: {
env: [
{ variable: "NODE_ENV", value: "production", availability: ["RUNTIME"] },
{ variable: "NEXT_TELEMETRY_DISABLED", value: "1", availability: ["BUILD"] },
],
},
});

const container = await deployLocalBuild();

expect(container.env).to.deep.equal([{ name: "NODE_ENV", value: "production" }]);
});

it("cleans up and deploys nothing if the local build fails", async () => {
localBuildStub.rejects(new Error("next build failed"));

await expect(deployLocalBuild()).to.be.rejectedWith("next build failed");

expect(rmSyncStub).to.have.been.calledWith(scratchDir);
expect(createServiceStub).not.to.have.been.called;
});

it("reports the local build error, not the cleanup error, when both fail", async () => {
localBuildStub.rejects(new Error("next build failed"));
rmSyncStub.throws(new Error("EBUSY: resource busy or locked"));

await expect(deployLocalBuild()).to.be.rejectedWith("next build failed");
});
});

describe("revisionTemplate", () => {
const existing = {
name: "projects/my-project/locations/us-central1/services/web",
Expand All @@ -126,6 +221,13 @@ describe("run deploy", () => {
},
} as unknown as runv2.Service;

/** The live web service, running a container with these fields. */
function webRunning(container: Partial<runv2.Container>): runv2.Service {
return {
template: { containers: [{ name: "web", image: "old-image", ...container }] },
} as runv2.Service;
}

it("starts a new service with just the image", () => {
expect(revisionTemplate({ config: web }, image)).to.deep.equal({
containers: [{ name: "web", image }],
Expand Down Expand Up @@ -167,5 +269,57 @@ describe("run deploy", () => {
const withoutMessage = revisionTemplate({ config: web, existing }, image);
expect(withoutMessage.annotations).to.deep.equal({ team: "frontend" });
});

it("switches a service from a local build back to an image", () => {
const fromLocalBuild = webRunning({
image: "scratch",
command: ["npm", "run", "start"],
sourceCode: { cloudStorageSource: builtApp },
});

const template = revisionTemplate({ config: web, existing: fromLocalBuild }, image);

expect(template).to.deep.equal({ containers: [{ name: "web", image }] });
});

describe("localBuildTemplate", () => {
it("runs a local build's output with the build's start command", () => {
// The command is split on whitespace, so stray spaces don't become empty arguments.
const build = { outputFiles: [".next"], runCommand: " npm run start ", env: [] };

const template = localBuildTemplate({ config: web, existing }, build, builtApp);

expect(template.containers?.[0]).to.deep.include({
image: "scratch",
sourceCode: { cloudStorageSource: builtApp },
command: ["npm", "run", "start"],
});
});

it("sets the env vars the build reports, replacing the service's values for the same names", () => {
const service = webRunning({
env: [
{ name: "MODE", value: "prod" },
{ name: "API_URL", value: "https://api.example.com" },
],
});
const build = {
outputFiles: [".next"],
env: [
{ name: "MODE", value: "development" },
{ name: "NODE_ENV", value: "production" },
],
};

const template = localBuildTemplate({ config: web, existing: service }, build, builtApp);

// API_URL isn't from the build, so it's kept.
expect(template.containers?.[0].env).to.deep.equal([
{ name: "API_URL", value: "https://api.example.com" },
{ name: "MODE", value: "development" },
{ name: "NODE_ENV", value: "production" },
]);
});
});
});
});
Loading
Loading