Skip to content

Commit

Permalink
Merge pull request #1 from weilinzung/feat/delete-max-preview-channels
Browse files Browse the repository at this point in the history
feat(totalPreviewChannelLimit): add option to manage preview channels to avoid hitting the channel quota
  • Loading branch information
weilinzung authored Dec 10, 2024
2 parents 7a831e3 + 76185cb commit 31ac987
Show file tree
Hide file tree
Showing 10 changed files with 553 additions and 171 deletions.
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npm test && npm run lint-staged
1 change: 1 addition & 0 deletions .prettierrc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
# limitations under the License.

singleQuote: false
trailingComma: none
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
196 changes: 163 additions & 33 deletions bin/action.min.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 31ac987

Please sign in to comment.