diff --git a/README.md b/README.md index c755c43..ce659c0 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,14 @@ The version of `firebase-tools` to use. If not specified, defaults to `latest`. Disable commenting in a PR with the preview URL. +### `totalPreviewChannelLimit` _{number}_ + +Specifies the maximum number of preview channels allowed to optimize resource usage or avoid exceeding Firebase Hosting’s quota. + +Once the limit is reached, the oldest channels are automatically removed to prevent errors like "429, Couldn't create channel on [project]: channel quota reached", ensuring smooth deployments. + +Currently, **50** channels are allowed per Hosting **site**, including the default "live" site. + ## Outputs Values emitted by this action that can be consumed by other actions later in your workflow diff --git a/action.yml b/action.yml index 52bff61..e94e9b1 100644 --- a/action.yml +++ b/action.yml @@ -64,6 +64,10 @@ inputs: Disable auto-commenting with the preview channel URL to the pull request default: "false" required: false + totalPreviewChannelLimit: + description: >- + Defines the maximum number of preview channels allowed in a Firebase project + required: false outputs: urls: description: The url(s) deployed to diff --git a/bin/action.min.js b/bin/action.min.js index 8324beb..251f668 100644 --- a/bin/action.min.js +++ b/bin/action.min.js @@ -92931,6 +92931,51 @@ exports.getExecOutput = getExecOutput; * See the License for the specific language governing permissions and * limitations under the License. */ +function getChannelId(configuredChannelId, ghContext) { + let tmpChannelId = ""; + if (!!configuredChannelId) { + tmpChannelId = configuredChannelId; + } else if (ghContext.payload.pull_request) { + const branchName = ghContext.payload.pull_request.head.ref.substr(0, 20); + tmpChannelId = `pr${ghContext.payload.pull_request.number}-${branchName}`; + } + // Channel IDs can only include letters, numbers, underscores, hyphens, and periods. + const invalidCharactersRegex = /[^a-zA-Z0-9_\-\.]/g; + const correctedChannelId = tmpChannelId.replace(invalidCharactersRegex, "_"); + if (correctedChannelId !== tmpChannelId) { + console.log(`ChannelId "${tmpChannelId}" contains unsupported characters. Using "${correctedChannelId}" instead.`); + } + return correctedChannelId; +} +/** + * Extracts the channel ID from the channel name + * @param channelName + * @returns channelId + * Example channelName: projects/my-project/sites/test-staging/channels/pr123-my-branch + */ +function extractChannelIdFromChannelName(channelName) { + const parts = channelName.split("/"); + const channelIndex = parts.indexOf("channels") + 1; // The part after "channels" + return parts[channelIndex]; // Returns the channel name after "channels/" +} + +/** + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const SITE_CHANNEL_QUOTA = 50; +const SITE_CHANNEL_LIVE_SITE = 1; function interpretChannelDeployResult(deployResult) { const allSiteResults = Object.values(deployResult.result); const expireTime = allSiteResults[0].expireTime; @@ -92976,7 +93021,103 @@ async function execWithCredentials(args, projectId, gacFilename, opts) { } return deployOutputBuf.length ? deployOutputBuf[deployOutputBuf.length - 1].toString("utf-8") : ""; // output from the CLI } - +async function getAllChannels(gacFilename, deployConfig) { + const { + projectId, + target, + firebaseToolsVersion + } = deployConfig; + const allChannelsText = await execWithCredentials(["hosting:channel:list", ...(target ? ["--site", target] : [])], projectId, gacFilename, { + firebaseToolsVersion + }); + const channelResults = JSON.parse(allChannelsText.trim()); + if (channelResults.status === "error") { + throw Error(channelResults.error); + } else { + return channelResults.channels || []; + } +} +function getPreviewChannelToRemove(channels, totalPreviewChannelLimit) { + let totalAllowedPreviewChannels = totalPreviewChannelLimit; + let totalPreviewChannelToSlice = totalPreviewChannelLimit; + if (totalPreviewChannelLimit >= SITE_CHANNEL_QUOTA - SITE_CHANNEL_LIVE_SITE) { + /** + * If the total number of preview channels is greater than or equal to the site channel quota, + * preview channels is the site channel quota minus the live site channel + * + * e.g. 49(total allowed preview channels) = 50(quota) - 1(live site channel) + */ + totalAllowedPreviewChannels = totalPreviewChannelLimit - SITE_CHANNEL_LIVE_SITE; + /** + * If the total number of preview channels is greater than or equal to the site channel quota, + * total preview channels to slice is the site channel quota plus the live site channel plus the current preview deploy + * + * e.g. 52(total preview channels to slice) = 50(site channel quota) + 1(live site channel) + 1 (current preview deploy) + */ + totalPreviewChannelToSlice = SITE_CHANNEL_QUOTA + SITE_CHANNEL_LIVE_SITE + 1; + } + if (channels.length > totalAllowedPreviewChannels) { + // If the total number of channels exceeds the limit, remove the preview channels + // Filter out live channel(hosting default site) and channels without an expireTime(additional sites) + const previewChannelsOnly = channels.filter(channel => { + var _channel$labels; + return (channel == null || (_channel$labels = channel.labels) == null ? void 0 : _channel$labels.type) !== "live" && !!(channel != null && channel.expireTime); + }); + if (previewChannelsOnly.length) { + // Sort preview channels by expireTime + const sortedPreviewChannels = previewChannelsOnly.sort((channelA, channelB) => { + return new Date(channelA.expireTime).getTime() - new Date(channelB.expireTime).getTime(); + }); + // Calculate the number of preview channels to remove + const sliceEnd = totalPreviewChannelToSlice > sortedPreviewChannels.length ? totalPreviewChannelToSlice - sortedPreviewChannels.length : sortedPreviewChannels.length - totalPreviewChannelToSlice; + // Remove the oldest preview channels + return sortedPreviewChannels.slice(0, sliceEnd); + } + } else { + return []; + } +} +/** + * Removes preview channels from the list of active channels if the number exceeds the configured limit + * + * This function identifies the preview channels that need to be removed based on the total limit of + * preview channels allowed (`totalPreviewChannelLimit`). + * + * It then attempts to remove those channels using the `removeChannel` function. + * Errors encountered while removing channels are logged but do not stop the execution of removing other channels. + */ +async function removePreviews({ + channels, + gacFilename, + deployConfig +}) { + const toRemove = getPreviewChannelToRemove(channels, deployConfig.totalPreviewChannelLimit); + if (toRemove.length) { + await Promise.all(toRemove.map(async channel => { + try { + await removeChannel(gacFilename, deployConfig, extractChannelIdFromChannelName(channel.name)); + } catch (error) { + console.error(`Error removing preview channel ${channel.name}:`, error); + } + })); + } +} +async function removeChannel(gacFilename, deployConfig, channelId) { + const { + projectId, + target, + firebaseToolsVersion + } = deployConfig; + const deleteChannelText = await execWithCredentials(["hosting:channel:delete", channelId, ...(target ? ["--site", target] : []), "--force"], projectId, gacFilename, { + firebaseToolsVersion + }); + const channelResults = JSON.parse(deleteChannelText.trim()); + if (channelResults.status === "error") { + throw Error(channelResults.error); + } else { + return channelResults.status || "success"; + } +} async function deployPreview(gacFilename, deployConfig) { const { projectId, @@ -93004,38 +93145,6 @@ async function deployProductionSite(gacFilename, productionDeployConfig) { return deploymentResult; } -/** - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -function getChannelId(configuredChannelId, ghContext) { - let tmpChannelId = ""; - if (!!configuredChannelId) { - tmpChannelId = configuredChannelId; - } else if (ghContext.payload.pull_request) { - const branchName = ghContext.payload.pull_request.head.ref.substr(0, 20); - tmpChannelId = `pr${ghContext.payload.pull_request.number}-${branchName}`; - } - // Channel IDs can only include letters, numbers, underscores, hyphens, and periods. - const invalidCharactersRegex = /[^a-zA-Z0-9_\-\.]/g; - const correctedChannelId = tmpChannelId.replace(invalidCharactersRegex, "_"); - if (correctedChannelId !== tmpChannelId) { - console.log(`ChannelId "${tmpChannelId}" contains unsupported characters. Using "${correctedChannelId}" instead.`); - } - return correctedChannelId; -} - /** * Copyright 2020 Google LLC * @@ -93182,6 +93291,7 @@ const entryPoint = core.getInput("entryPoint"); const target = core.getInput("target"); const firebaseToolsVersion = core.getInput("firebaseToolsVersion"); const disableComment = core.getInput("disableComment"); +const totalPreviewChannelLimit = Number(core.getInput("totalPreviewChannelLimit") || "0"); async function run() { const isPullRequest = !!github.context.payload.pull_request; let finish = details => console.log(details); @@ -93232,6 +93342,26 @@ async function run() { return; } const channelId = getChannelId(configuredChannelId, github.context); + if (totalPreviewChannelLimit) { + core.startGroup(`Start counting total Firebase preview channel ${channelId}`); + const allChannels = await getAllChannels(gacFilename, { + projectId, + target, + firebaseToolsVersion, + totalPreviewChannelLimit + }); + if (allChannels.length) { + await removePreviews({ + channels: allChannels, + gacFilename, + deployConfig: { + projectId, + target, + firebaseToolsVersion + } + }); + } + } core.startGroup(`Deploying to Firebase preview channel ${channelId}`); const deployment = await deployPreview(gacFilename, { projectId, diff --git a/src/deploy.ts b/src/deploy.ts index 76c6185..2b059c0 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -15,6 +15,7 @@ */ import { exec } from "@actions/exec"; +import { extractChannelIdFromChannelName } from "./getChannelId"; export type SiteDeploy = { site: string; @@ -28,6 +29,64 @@ export type ErrorResult = { error: string; }; +export type ChannelDeleteSuccessResult = { + status: "success"; +}; + +export type ChannelTotalSuccessResult = { + status: "success"; + channels: Channel[]; +}; + +export interface Channel { + name: string; + url: string; + release: { + name: string; + version: { + name: string; + status: string; + config: { + headers: { + headers: { + "Cache-Control": string; + }; + glob: string; + }[]; + rewrites: { + glob: string; + path: string; + }[]; + }; + labels: { + "deployment-tool": string; + }; + createTime: string; + createUser: { + email: string; + }; + finalizeTime: string; + finalizeUser: { + email: string; + }; + fileCount: string; + versionBytes: string; + }; + type: string; + releaseTime: string; + releaseUser: { + email: string; + }; + }; + createTime: string; + updateTime: string; + retainedReleaseCount: number; + expireTime?: string; + labels?: { + type: "live"; + }; +} + export type ChannelSuccessResult = { status: "success"; result: { [key: string]: SiteDeploy }; @@ -45,6 +104,8 @@ type DeployConfig = { target?: string; // Optional version specification for firebase-tools. Defaults to `latest`. firebaseToolsVersion?: string; + // Optional for preview channels deployment + totalPreviewChannelLimit?: number; }; export type ChannelDeployConfig = DeployConfig & { @@ -54,6 +115,9 @@ export type ChannelDeployConfig = DeployConfig & { export type ProductionDeployConfig = DeployConfig & {}; +const SITE_CHANNEL_QUOTA = 50; +const SITE_CHANNEL_LIVE_SITE = 1; + export function interpretChannelDeployResult( deployResult: ChannelSuccessResult ): { expireTime: string; expire_time_formatted: string; urls: string[] } { @@ -125,6 +189,162 @@ async function execWithCredentials( : ""; // output from the CLI } +export async function getAllChannels( + gacFilename: string, + deployConfig: Omit, +): Promise { + const { projectId, target, firebaseToolsVersion } = deployConfig; + + const allChannelsText = await execWithCredentials( + ["hosting:channel:list", ...(target ? ["--site", target] : [])], + projectId, + gacFilename, + { firebaseToolsVersion } + ); + + const channelResults = JSON.parse(allChannelsText.trim()) as + | ChannelTotalSuccessResult + | ErrorResult; + + if (channelResults.status === "error") { + throw Error((channelResults as ErrorResult).error); + } else { + return channelResults.channels || []; + } +} + +function getPreviewChannelToRemove( + channels: Channel[], + totalPreviewChannelLimit: DeployConfig["totalPreviewChannelLimit"], +): Channel[] { + let totalAllowedPreviewChannels = totalPreviewChannelLimit; + let totalPreviewChannelToSlice = totalPreviewChannelLimit; + + if (totalPreviewChannelLimit >= SITE_CHANNEL_QUOTA - SITE_CHANNEL_LIVE_SITE) { + /** + * If the total number of preview channels is greater than or equal to the site channel quota, + * preview channels is the site channel quota minus the live site channel + * + * e.g. 49(total allowed preview channels) = 50(quota) - 1(live site channel) + */ + totalAllowedPreviewChannels = + totalPreviewChannelLimit - SITE_CHANNEL_LIVE_SITE; + + /** + * If the total number of preview channels is greater than or equal to the site channel quota, + * total preview channels to slice is the site channel quota plus the live site channel plus the current preview deploy + * + * e.g. 52(total preview channels to slice) = 50(site channel quota) + 1(live site channel) + 1 (current preview deploy) + */ + totalPreviewChannelToSlice = + SITE_CHANNEL_QUOTA + SITE_CHANNEL_LIVE_SITE + 1; + } + + if (channels.length > totalAllowedPreviewChannels) { + // If the total number of channels exceeds the limit, remove the preview channels + // Filter out live channel(hosting default site) and channels without an expireTime(additional sites) + const previewChannelsOnly = channels.filter( + (channel) => channel?.labels?.type !== "live" && !!channel?.expireTime, + ); + + if (previewChannelsOnly.length) { + // Sort preview channels by expireTime + const sortedPreviewChannels = previewChannelsOnly.sort( + (channelA, channelB) => { + return ( + new Date(channelA.expireTime).getTime() - + new Date(channelB.expireTime).getTime() + ); + }, + ); + + // Calculate the number of preview channels to remove + const sliceEnd = + totalPreviewChannelToSlice > sortedPreviewChannels.length + ? totalPreviewChannelToSlice - sortedPreviewChannels.length + : sortedPreviewChannels.length - totalPreviewChannelToSlice; + + // Remove the oldest preview channels + return sortedPreviewChannels.slice(0, sliceEnd); + } + } else { + return []; + } +} + +/** + * Removes preview channels from the list of active channels if the number exceeds the configured limit + * + * This function identifies the preview channels that need to be removed based on the total limit of + * preview channels allowed (`totalPreviewChannelLimit`). + * + * It then attempts to remove those channels using the `removeChannel` function. + * Errors encountered while removing channels are logged but do not stop the execution of removing other channels. + */ +export async function removePreviews({ + channels, + gacFilename, + deployConfig, +}: { + channels: Channel[]; + gacFilename: string; + deployConfig: Omit; +}) { + const toRemove = getPreviewChannelToRemove( + channels, + deployConfig.totalPreviewChannelLimit, + ); + + if (toRemove.length) { + await Promise.all( + toRemove.map(async (channel) => { + try { + await removeChannel( + gacFilename, + deployConfig, + extractChannelIdFromChannelName(channel.name), + ); + } catch (error) { + console.error( + `Error removing preview channel ${channel.name}:`, + error, + ); + } + }), + ); + } +} + +export async function removeChannel( + gacFilename: string, + deployConfig: Omit, + channelId: string, +): Promise { + const { projectId, target, firebaseToolsVersion } = deployConfig; + + const deleteChannelText = await execWithCredentials( + [ + "hosting:channel:delete", + channelId, + ...(target ? ["--site", target] : []), + "--force", + ], + projectId, + gacFilename, + { firebaseToolsVersion } + ); + + const channelResults = JSON.parse(deleteChannelText.trim()) as + | ChannelDeleteSuccessResult + | ErrorResult; + + if (channelResults.status === "error") { + throw Error((channelResults as ErrorResult).error); + } else { + return channelResults.status || "success"; + } +} + export async function deployPreview( gacFilename: string, deployConfig: ChannelDeployConfig diff --git a/src/getChannelId.ts b/src/getChannelId.ts index 09629ab..4ad3f6d 100644 --- a/src/getChannelId.ts +++ b/src/getChannelId.ts @@ -37,3 +37,15 @@ export function getChannelId(configuredChannelId: string, ghContext: Context) { return correctedChannelId; } + +/** + * Extracts the channel ID from the channel name + * @param channelName + * @returns channelId + * Example channelName: projects/my-project/sites/test-staging/channels/pr123-my-branch + */ +export function extractChannelIdFromChannelName(channelName: string): string { + const parts = channelName.split("/"); + const channelIndex = parts.indexOf("channels") + 1; // The part after "channels" + return parts[channelIndex]; // Returns the channel name after "channels/" +} diff --git a/src/index.ts b/src/index.ts index fe34502..505034c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,9 @@ import { deployPreview, deployProductionSite, ErrorResult, + getAllChannels, interpretChannelDeployResult, + removePreviews, } from "./deploy"; import { getChannelId } from "./getChannelId"; import { @@ -51,6 +53,9 @@ const entryPoint = getInput("entryPoint"); const target = getInput("target"); const firebaseToolsVersion = getInput("firebaseToolsVersion"); const disableComment = getInput("disableComment"); +const totalPreviewChannelLimit = Number( + getInput("totalPreviewChannelLimit") || "0" +); async function run() { const isPullRequest = !!context.payload.pull_request; @@ -115,6 +120,29 @@ async function run() { const channelId = getChannelId(configuredChannelId, context); + if (totalPreviewChannelLimit) { + startGroup(`Start counting total Firebase preview channel ${channelId}`); + + const allChannels = await getAllChannels(gacFilename, { + projectId, + target, + firebaseToolsVersion, + totalPreviewChannelLimit, + }); + + if (allChannels.length) { + await removePreviews({ + channels: allChannels, + gacFilename, + deployConfig: { + projectId, + target, + firebaseToolsVersion, + }, + }); + } + } + startGroup(`Deploying to Firebase preview channel ${channelId}`); const deployment = await deployPreview(gacFilename, { projectId,