Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
bc390c7
Add firebase run:services:update command
falahat Sep 30, 2026
9a8b7ca
Validate service region and check all matching configs in updateService
falahat Sep 30, 2026
9491467
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Sep 30, 2026
506c7ab
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 2, 2026
c3abacd
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 2, 2026
c1173dc
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 2, 2026
0c5de81
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 2, 2026
0b89918
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 2, 2026
6c8fe1b
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 2, 2026
ba8bfb3
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 2, 2026
a31877e
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 3, 2026
8609bf1
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 3, 2026
f5ba7ec
Always rebuild in run:services:update, even if nothing changes
falahat Oct 4, 2026
2d44f6d
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 7, 2026
a606029
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 7, 2026
8298790
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 7, 2026
19a3714
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 8, 2026
fdf61f9
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 8, 2026
cb5ec4b
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 8, 2026
9ce5cf5
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 8, 2026
40958c9
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 8, 2026
ae700c3
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 8, 2026
e4ed51b
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 8, 2026
bfec6c1
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 8, 2026
89c4216
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 8, 2026
4037b81
Make run:services:update change exactly one service
falahat Oct 8, 2026
e9ddc33
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 9, 2026
84494af
Make run:services:update easier to read
falahat Oct 9, 2026
fb87b8f
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 12, 2026
5423457
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 12, 2026
1e3accb
Split run:services:update's service name into serviceId and region
falahat Oct 12, 2026
fe7831e
Merge branch 'bapi_deploy_run' into bapi_services_update
falahat Oct 12, 2026
8684afe
Merge branch 'bapi_deploy_run' into bapi_services_update
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
5 changes: 5 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { CLIClient } from "../command";
import * as experiments from "../experiments";

type CommandRunner = ((...args: any[]) => Promise<any>) & { load: () => void };

Check warning on line 4 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 4 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

/**
* Loads all commands for our parser.
*/
export function load(client: CLIClient): CLIClient {
function loadCommand(name: string): CommandRunner {
const load = () => {

Check warning on line 11 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
const { command: cmd } = require(`./${name}`);

Check warning on line 12 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
cmd.register(client);
return cmd.runner();
};
Expand Down Expand Up @@ -305,6 +305,11 @@
client.remoteconfig.experiments.get = loadCommand("remoteconfig-experiments-get");
client.remoteconfig.experiments.list = loadCommand("remoteconfig-experiments-list");
client.remoteconfig.experiments.delete = loadCommand("remoteconfig-experiments-delete");
if (experiments.isEnabled("directcloudrun")) {
client.run = {};
client.run.services = {};
client.run.services.update = loadCommand("run-services-update");
}
client.serve = loadCommand("serve");
client.setup = {};
client.setup.emulators = {};
Expand Down
23 changes: 23 additions & 0 deletions src/commands/run-services-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Command } from "../command";
import { TARGET_PERMISSIONS } from "../deploy";
import { updateService } from "../deploy/run/update";
import { Options } from "../options";
import { requireConfig } from "../requireConfig";
import { requirePermissions } from "../requirePermissions";

export const command = new Command("run:services:update <serviceId>")
.description("update the settings of a Cloud Run service, then build and deploy it")
.option(
"--region <region>",
"the service's region, if firebase.json lists the service ID in more than one region",
)
.option(
"--base-image <baseImage>",
"set the base image (e.g. nodejs22) and enable automatic base image updates",
)
.option("--clear-base-image", "clear the base image and disable automatic base image updates")
.before(requireConfig)
.before(requirePermissions, TARGET_PERMISSIONS.run)
.action((serviceId: string, options: Options) =>
updateService(serviceId, options.region as string | undefined, options),
);
2 changes: 1 addition & 1 deletion src/deploy/run/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ export const detailedHelp =
' "ignore": ["node_modules", ".git"]\n' +
" }\n" +
"}\n\n" +
"Each deploy reuses the service's current base image.";
"Base images are configured on the service with firebase run:services:update --base-image and are reused on every deploy.";
101 changes: 101 additions & 0 deletions src/deploy/run/update.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { expect } from "chai";
import * as sinon from "sinon";
import * as deployIndex from "..";
import * as runv2 from "../../gcp/runv2";
import { Options } from "../../options";
import { updateService } from "./update";

describe("updateService", () => {
const web = { serviceId: "web", region: "us-central1" };
const webEurope = { serviceId: "web", region: "europe-west1" };
let getServiceStub: sinon.SinonStub;
let deployStub: sinon.SinonStub;

beforeEach(() => {
getServiceStub = sinon.stub(runv2, "getService").resolves({
name: "projects/my-project/locations/us-central1/services/web",
} as runv2.Service);
deployStub = sinon.stub(deployIndex, "deploy").resolves();
});

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

/** Runs `firebase run:services:update <serviceId>` with these flags and firebase.json "run" config. */
function update(
serviceId: string,
flags: Record<string, unknown>,
run: unknown = web,
): Promise<void> {
const options = { project: "my-project", config: { src: { run } }, ...flags };
return updateService(
serviceId,
flags.region as string | undefined,
options as unknown as Options,
);
}

/** Asserts that update deployed only `only`, with these settings. */
function expectDeployed(only: string, settings: Record<string, unknown>): void {
expect(deployStub).to.have.been.calledOnceWith(
["run"],
sinon.match({ only }),
sinon.match(settings),
);
}

it("requires --base-image or --clear-base-image, but not both", async () => {
await expect(update("web", {})).to.be.rejectedWith(
"--base-image <baseImage> or --clear-base-image",
);
await expect(update("web", { baseImage: "nodejs22", clearBaseImage: true })).to.be.rejectedWith(
"Use either --base-image or --clear-base-image, not both.",
);
});

it("requires the service to be in firebase.json", async () => {
await expect(update("api", { baseImage: "nodejs22" })).to.be.rejectedWith(
"Cloud Run service api not detected in firebase.json.",
);
await expect(
update("web", { region: "europe-west1", baseImage: "nodejs22" }),
).to.be.rejectedWith("Cloud Run service web in europe-west1 not detected in firebase.json.");
});

it("requires the service to exist", async () => {
getServiceStub.rejects({ status: 404 });

await expect(update("web", { baseImage: "nodejs22" })).to.be.rejectedWith(
/web doesn't exist in us-central1 yet.*firebase deploy --only run:web:us-central1/,
);
expect(deployStub).not.to.have.been.called;
});

it("deploys the service with the new base image", async () => {
await update("web", { baseImage: "nodejs22" });

expectDeployed("run:web:us-central1", { baseImage: "nodejs22" });
});

it("clears the base image, and deploys even if there's none to clear", async () => {
await update("web", { clearBaseImage: true });

expectDeployed("run:web:us-central1", { baseImage: null });
});

describe("when firebase.json lists the service in two regions", () => {
it("asks which one to update", async () => {
await expect(update("web", { baseImage: "nodejs22" }, [web, webEurope])).to.be.rejectedWith(
"firebase.json lists web in 2 regions: us-central1, europe-west1. " +
"Pick one with --region, e.g. firebase run:services:update web --region us-central1",
);
expect(deployStub).not.to.have.been.called;
});

it("updates only the region it's given", async () => {
await update("web", { region: "europe-west1", baseImage: "nodejs22" }, [web, webEurope]);

expect(getServiceStub).to.have.been.calledOnceWith("my-project", "europe-west1", "web");
expectDeployed("run:web:europe-west1", { baseImage: "nodejs22" });
});
});
});
80 changes: 80 additions & 0 deletions src/deploy/run/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as clc from "colorette";
import { deploy } from "..";
import { FirebaseError } from "../../error";
import { RunSingle } from "../../firebaseConfig";
import { Options } from "../../options";
import { needProjectId } from "../../projectUtils";
import { fullServiceName, getAllServiceConfigs, getExistingService } from "./util";

/**
* Changes a service's settings by deploying it with them, like `firebase deploy` does. It always
* rebuilds, even if nothing changed: a new base image needs a new build, because images built for
* a base image leave out the OS and runtime that other images bring.
* `region` is only needed if firebase.json lists the service ID in more than one region.
*/
export async function updateService(
serviceId: string,
region: string | undefined,
options: Options,
): Promise<void> {
// Deploy reads each setting as an override: a value sets it, null clears it, and undefined keeps
// the service's current value.
const settings = {
baseImage: readFlagPair(options.baseImage, options.clearBaseImage, "base-image"),
};
if (Object.values(settings).every((value) => value === undefined)) {
throw new FirebaseError(
"Specify a setting to update: --base-image <baseImage> or --clear-base-image.",
);
}

const projectId = needProjectId(options);
const config = findServiceToUpdate(serviceId, region, options);
if (!(await getExistingService(projectId, config.region, config.serviceId))) {
const howToCreate =
`${clc.bold("firebase init run")} or ` +
clc.bold(`firebase deploy --only run:${fullServiceName(config)}`);
throw new FirebaseError(
`Cloud Run service ${config.serviceId} doesn't exist in ${config.region} yet. ` +
`Create it with ${howToCreate}, then update it.`,
);
}

await deploy(["run"], { ...options, only: `run:${fullServiceName(config)}` }, settings);
}

/** Reads a `--<flag> <value>` / `--clear-<flag>` pair as a setting override. */
function readFlagPair(value: unknown, clear: unknown, flag: string): string | null | undefined {
if (value && clear) {
throw new FirebaseError(`Use either --${flag} or --clear-${flag}, not both.`);
}
return clear ? null : (value as string | undefined);
}

/**
* Returns the service in firebase.json with this ID, in `region` if one is given. Updates change
* one service at a time, so if firebase.json lists the ID in more than one region, `region` is
* required.
*/
function findServiceToUpdate(
serviceId: string,
region: string | undefined,
options: Options,
): RunSingle {
const matches = getAllServiceConfigs(options).filter(
(c) => c.serviceId === serviceId && (!region || c.region === region),
);
if (!matches.length) {
const service = region ? `${serviceId} in ${region}` : serviceId;
throw new FirebaseError(`Cloud Run service ${service} not detected in firebase.json.`);
}
if (matches.length > 1) {
const regions = matches.map((c) => c.region);
const example = `firebase run:services:update ${serviceId} --region ${regions[0]}`;
throw new FirebaseError(
`firebase.json lists ${serviceId} in ${regions.length} regions: ${regions.join(", ")}. ` +
`Pick one with --region, e.g. ${example}`,
);
}
return matches[0];
}
Loading