Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
eaaffe1
Create a basic, initial schema for firebase.json
falahat Sep 28, 2026
03f1e55
improve testing
falahat Sep 29, 2026
a729087
Require region, remove serviceAccount, and register run in Config.KNO…
falahat Sep 30, 2026
ee94ae4
Remove unused Deployable predeploy/postdeploy from RunSingle
falahat Sep 30, 2026
5b7a7a6
Update shared GCP clients (runv2, run, artifactregistry)
falahat Sep 30, 2026
c0bbf78
Add direct_cloud_run experiment flag
falahat Sep 30, 2026
466f1a5
Add defensive optional chaining in run.ts and runv2.ts
falahat Sep 30, 2026
e338677
Rename direct_cloud_run experiment to directcloudrun
falahat Sep 30, 2026
789bf04
Add firebase init run interactive setup
falahat Sep 30, 2026
4a26461
Use directcloudrun experiment and validate rootDir is a directory
falahat Sep 30, 2026
dcd5eaf
Trim baseImage input in init run
falahat Sep 30, 2026
195aa47
Add firebase deploy --only run for remote Cloud Build source deploys
falahat Sep 30, 2026
2fa39f7
Clean up temporary source archive, guard release URI, and use directc…
falahat Sep 30, 2026
69ca644
Avoid empty template.annotations when deploy message is unset
falahat Sep 30, 2026
1086ebe
Simplify template.annotations cleanup when deploy message is unset
falahat Sep 30, 2026
76388ef
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Sep 30, 2026
47e179b
Merge branch 'main' into bapi_gcp_and_shared
falahat Sep 30, 2026
9702b99
Merge branch 'bapi_gcp_and_shared' into bapi_experiment
falahat Sep 30, 2026
bd8fa33
Merge branch 'bapi_experiment' into bapi_init_run
falahat Sep 30, 2026
95442b6
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 2, 2026
f0b1a7b
Rename masterTimeout option to pollTimeoutMs in runv2
falahat Oct 2, 2026
682c0fd
Merge branch 'bapi_gcp_and_shared' into bapi_experiment
falahat Oct 2, 2026
9079261
Merge branch 'bapi_experiment' into bapi_init_run
falahat Oct 2, 2026
51e7568
Use pollTimeoutMs option when creating and updating Cloud Run services
falahat Oct 2, 2026
6a3ec2d
Make updateMask fallback in updateService more readable
falahat Oct 2, 2026
67f2201
Merge branch 'bapi_gcp_and_shared' into bapi_experiment
falahat Oct 2, 2026
33ee8f8
Merge branch 'bapi_experiment' into bapi_init_run
falahat Oct 2, 2026
6014ef9
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 2, 2026
fbbcb6f
Add comment explaining default updateMask calculation in updateService
falahat Oct 2, 2026
d184a26
Merge branch 'bapi_gcp_and_shared' into bapi_experiment
falahat Oct 2, 2026
4d8e032
Merge branch 'bapi_experiment' into bapi_init_run
falahat Oct 2, 2026
da255cb
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 2, 2026
9c7fa98
Type Service.ingress as IngressTraffic union in runv2
falahat Oct 2, 2026
2dede4d
Merge branch 'bapi_gcp_and_shared' into bapi_experiment
falahat Oct 2, 2026
5a9f1f9
Merge branch 'bapi_experiment' into bapi_init_run
falahat Oct 2, 2026
eb2ba81
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 2, 2026
dd2973e
Merge branch 'bapi_experiment' into bapi_init_run
falahat Oct 2, 2026
e83b309
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 2, 2026
6a79374
Format updateMask call with Prettier and guard template literal in runv2
falahat Oct 2, 2026
b9938fd
Merge branch 'bapi_gcp_and_shared' into bapi_experiment
falahat Oct 2, 2026
a4430ad
Improve readability of firebase init run and remove mainContainer
falahat Oct 2, 2026
26f8593
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 2, 2026
2915dea
Remove sidecar container from deploy.spec.ts
falahat Oct 2, 2026
a621639
Use dirExistsSync from fsutils in init run promptRootDir
falahat Oct 3, 2026
527c4a7
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 3, 2026
2736051
Make Cloud Run temp-file cleanup best-effort
falahat Oct 3, 2026
c3427e8
Don't default to a base image in init run
falahat Oct 7, 2026
bb72584
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 7, 2026
b03b4c6
Merge remote-tracking branch 'upstream/main' into bapi_gcp_and_shared
falahat Oct 7, 2026
2be84ca
Merge branch 'bapi_gcp_and_shared' into bapi_experiment
falahat Oct 7, 2026
c66847b
Merge branch 'bapi_experiment' into bapi_init_run
falahat Oct 7, 2026
8a6685f
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 7, 2026
0dca8f4
Merge remote-tracking branch 'upstream/main' into bapi_gcp_and_shared
falahat Oct 7, 2026
e1dae66
Merge branch 'bapi_gcp_and_shared' into bapi_experiment
falahat Oct 7, 2026
4ad29e0
Merge branch 'bapi_experiment' into bapi_init_run
falahat Oct 7, 2026
1405979
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 7, 2026
11f67e1
Add firebase init run interactive setup
falahat Sep 30, 2026
2c9c06e
Use directcloudrun experiment and validate rootDir is a directory
falahat Sep 30, 2026
bd2ff06
Trim baseImage input in init run
falahat Sep 30, 2026
51cca8b
Improve readability of firebase init run and remove mainContainer
falahat Oct 2, 2026
1ca1eb3
Use dirExistsSync from fsutils in init run promptRootDir
falahat Oct 3, 2026
84f2bba
Don't default to a base image in init run
falahat Oct 7, 2026
74f23b7
Make upsertRunConfig easier to read
falahat Oct 8, 2026
250a602
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 8, 2026
ada86a4
Make upsertRunConfig easier to read
falahat Oct 8, 2026
292331f
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 8, 2026
54e9f05
Don't add an ignore list to an existing service in init run
falahat Oct 8, 2026
5167ae7
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 8, 2026
4a117cb
Default init run's root directory to the saved one, matching by servi…
falahat Oct 8, 2026
249478b
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 8, 2026
136598a
Only deploy the service init set up, not same-ID services in other re…
falahat Oct 8, 2026
c4a99e1
Write firebase.json once, from init, instead of also from init run's …
falahat Oct 8, 2026
5794e91
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 8, 2026
ee2298f
Merge remote-tracking branch 'upstream/main' into bapi_init_run
falahat Oct 8, 2026
4e07d95
Merge branch 'bapi_init_run' into bapi_deploy_run
falahat Oct 8, 2026
d46821e
Remove the Cloud Run deploy E2E suite
falahat Oct 8, 2026
bbdc425
Merge remote-tracking branch 'upstream/main' into bapi_deploy_run
falahat Oct 8, 2026
f2df542
Let --only run:<serviceId>:<region> pick one Cloud Run service
falahat Oct 8, 2026
83e319f
Make the Cloud Run deploy code and tests easier to read
falahat Oct 9, 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
1 change: 1 addition & 0 deletions src/checkValidTargetFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const FILTERABLE_TARGETS = new Set([
"dataconnect",
"apphosting",
"ailogic",
"run",
]);

/**
Expand Down
3 changes: 3 additions & 0 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
}

function getDeployHelp(): string {
const boldFn = (str: string) => clc.bold(str);

Check warning on line 26 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function

let targetHelp = "Deploy targets include:\n\n";
for (const [targetName, target] of Object.entries(TARGETS)) {
Expand Down Expand Up @@ -88,6 +88,9 @@
if (options.filteredTargets.includes("ailogic")) {
experiments.assertEnabled("ailogic", "deploy AI Logic resources");
}
if (options.filteredTargets.includes("run")) {
experiments.assertEnabled("directcloudrun", "deploy Cloud Run services");
}
const permissions = options.filteredTargets.reduce((perms: string[], target: string) => {
return perms.concat(TARGET_PERMISSIONS[target as (typeof VALID_DEPLOY_TARGETS)[number]]);
}, []);
Expand All @@ -95,7 +98,7 @@
})
.before((options: Options) => {
if (options.filteredTargets.includes("functions")) {
return checkServiceAccountIam(options.project!);

Check warning on line 101 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Forbidden non-null assertion
}
})
.before(async (options: Options) => {
Expand Down Expand Up @@ -131,10 +134,10 @@
}
logBullet("No Hosting site detected.");
const siteId = await pickHostingSiteName("", options);
await createSite(options.project!, siteId);

Check warning on line 137 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Forbidden non-null assertion
}
})
.before(checkValidTargetFilters)
.action((options) => {
return deploy(options.filteredTargets, options);

Check warning on line 142 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `DeployOptions`

Check warning on line 142 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .filteredTargets on an `any` value

Check warning on line 142 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `("hosting" | "functions" | "firestore" | "storage" | "database" | "dataconnect" | "apphosting" | "ailogic" | "run" | "remoteconfig" | "extensions" | "auth")[]`
});
5 changes: 5 additions & 0 deletions src/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import * as AppHostingTarget from "./apphosting";
import * as AuthTarget from "./auth";
import * as AiLogicTarget from "./ailogic";
import * as RunTarget from "./run";
import { RUN_PERMISSIONS } from "./run/prereqs";
import { prepareFrameworks } from "../frameworks";
import { Context as HostingContext } from "./hosting/context";
import { addPinnedFunctionsToOnlyString, hasPinnedFunctions } from "./hosting/prepare";
Expand All @@ -44,6 +46,7 @@
"apphosting",
"auth",
"ailogic",
"run",
] as const;

export const TARGET_PERMISSIONS: Record<(typeof VALID_DEPLOY_TARGETS)[number], string[]> = {
Expand Down Expand Up @@ -105,6 +108,7 @@
// ensureAILogicApiEnabled reads API enablement state via Service Usage.
"serviceusage.services.get",
],
run: RUN_PERMISSIONS,
};

export const TARGETS = {
Expand All @@ -119,13 +123,14 @@
apphosting: AppHostingTarget,
auth: AuthTarget,
ailogic: AiLogicTarget,
run: RunTarget,
};

export type DeployOptions = Options & { dryRun?: boolean };

type Chain = ((context: any, options: any, payload: any) => Promise<unknown>)[];

Check warning on line 131 in src/deploy/index.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 131 in src/deploy/index.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 131 in src/deploy/index.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

const chain = async function (fns: Chain, context: any, options: any, payload: any): Promise<void> {

Check warning on line 133 in src/deploy/index.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
for (const latest of fns) {
await latest(context, options, payload);
}
Expand Down
22 changes: 22 additions & 0 deletions src/deploy/run/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { RunSingle } from "../../firebaseConfig";
import * as runv2 from "../../gcp/runv2";

/** A service being deployed. prepare fills in the first fields, and deploy sets `deployed`. */
export interface ServiceDeploy {
config: RunSingle;
/** The service as it is in Cloud Run before this deploy, or undefined if it doesn't exist yet. */
existing?: runv2.Service;
baseImage?: string;
/** The service after the rollout. */
deployed?: runv2.Service;
}

export interface Payload {
run?: { services: ServiceDeploy[] };
}

export interface Context {
projectId: string;
/** Overrides the service's base image. null clears it; undefined keeps the current one. */
baseImage?: string | null;
}
171 changes: 171 additions & 0 deletions src/deploy/run/deploy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { expect } from "chai";
import * as fs from "fs";
import * as sinon from "sinon";
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 apphostingUtil from "../apphosting/util";
import { ServiceDeploy } from "./args";
import { deploy, revisionTemplate } from "./deploy";

const NODEJS22 = "us-central1-docker.pkg.dev/serverless-runtimes/google-22/runtimes/nodejs22";
const DEPLOY_MESSAGE = "firebase.google.com/deploy-message";

describe("run deploy", () => {
const options = { config: { projectDir: "/project" } } as unknown as Options;
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";
let createArchiveStub: sinon.SinonStub;
let submitBuildStub: sinon.SinonStub;
let createServiceStub: sinon.SinonStub;
let updateServiceStub: sinon.SinonStub;

beforeEach(() => {
sinon.stub(getProjectNumber, "getProjectNumber").resolves("123");
sinon.stub(gcs, "upsertBucket").resolves("source-bucket");
createArchiveStub = sinon
.stub(apphostingUtil, "createSourceDeployArchive")
.resolves("/tmp/web.zip");
sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream);
sinon
.stub(gcs, "uploadObject")
.resolves({ bucket: "source-bucket", object: "web.zip", generation: "1" });
sinon.stub(artifactregistry, "ensureDockerRepository").resolves();
submitBuildStub = sinon.stub(runv2, "submitBuild").resolves();
const deployed = { uri: "https://web.run.app" } as runv2.Service;
createServiceStub = sinon.stub(runv2, "createService").resolves(deployed);
updateServiceStub = sinon.stub(runv2, "updateService").resolves(deployed);
sinon.stub(Date, "now").returns(42);
});

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

async function deployOne(svc: ServiceDeploy): Promise<ServiceDeploy> {
await deploy({ projectId: "my-project" }, options, { run: { services: [svc] } });
return svc;
}

it("uploads the source, builds it, and creates a public service", async () => {
const svc = await deployOne({ config: web, baseImage: NODEJS22 });

expect(createArchiveStub).to.have.been.calledWithMatch(
{ backendId: "web" },
"/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 },
});
expect(createServiceStub).to.have.been.calledWithMatch("my-project", "us-central1", "web", {
template: { containers: [{ name: "web", image, baseImageUri: NODEJS22 }] },
invokerIamDisabled: true,
ingress: "INGRESS_TRAFFIC_ALL",
});
expect(svc.deployed).to.deep.equal({ uri: "https://web.run.app" });
});

it("builds without a base image if the service doesn't have one", async () => {
await deployOne({ config: web });

// Without a base image, Cloud Build can't turn on automatic base image updates.
const { buildpackBuild } = submitBuildStub.firstCall.args[2];
expect(buildpackBuild).not.to.have.property("baseImage");
expect(buildpackBuild).not.to.have.property("enableAutomaticUpdates");
});

it("rolls out a new revision of an existing service with all traffic, keeping its tags", async () => {
const existing = {
name: "projects/my-project/locations/us-central1/services/web",
template: { revision: "web-001", containers: [{ name: "web", image: "old-image" }] },
traffic: [
{ type: "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", revision: "web-001", percent: 90 },
{
type: "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION",
revision: "web-000",
tag: "preview",
percent: 10,
},
],
} as unknown as runv2.Service;

await deployOne({ config: web, existing });

expect(createServiceStub).not.to.have.been.called;
expect(updateServiceStub).to.have.been.calledOnceWithExactly(
{
name: "projects/my-project/locations/us-central1/services/web",
template: { containers: [{ name: "web", image }] },
traffic: [
{ type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 },
{ type: "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", revision: "web-000", tag: "preview" },
],
},
{ updateMask: ["template", "traffic"], pollTimeoutMs: 10 * 60 * 1000 },
);
});

describe("revisionTemplate", () => {
const existing = {
name: "projects/my-project/locations/us-central1/services/web",
template: {
revision: "web-001",
serviceAccount: "web@my-project.iam.gserviceaccount.com",
annotations: { team: "frontend", [DEPLOY_MESSAGE]: "Last deploy" },
containers: [
{
name: "web",
image: "old-image",
baseImageUri: NODEJS22,
env: [{ name: "MODE", value: "prod" }],
},
],
},
} as unknown as runv2.Service;

it("starts a new service with just the image", () => {
expect(revisionTemplate({ config: web }, image)).to.deep.equal({
containers: [{ name: "web", image }],
});
});

it("keeps the live revision's settings, but not its name", () => {
const template = revisionTemplate({ config: web, existing, baseImage: NODEJS22 }, image);

expect(template).to.deep.equal({
serviceAccount: "web@my-project.iam.gserviceaccount.com",
annotations: { team: "frontend" },
containers: [
{ name: "web", image, baseImageUri: NODEJS22, env: [{ name: "MODE", value: "prod" }] },
],
});
// The live service itself isn't changed.
expect(existing.template.revision).to.equal("web-001");
});

it("sets the base image, or clears it", () => {
const withBaseImage = revisionTemplate(
{ config: web, existing, baseImage: "nodejs24" },
image,
);
expect(withBaseImage.containers?.[0].baseImageUri).to.equal("nodejs24");

const withoutBaseImage = revisionTemplate({ config: web, existing }, image);
expect(withoutBaseImage.containers?.[0]).not.to.have.property("baseImageUri");
});

it("sets the deploy message, or clears the last one", () => {
const withMessage = revisionTemplate({ config: web, existing }, image, "Fix the login page");
expect(withMessage.annotations).to.deep.equal({
team: "frontend",
[DEPLOY_MESSAGE]: "Fix the login page",
});

const withoutMessage = revisionTemplate({ config: web, existing }, image);
expect(withoutMessage.annotations).to.deep.equal({ team: "frontend" });
});
});
});
Loading
Loading