From f8f3e161869c4ad0d54db6a416fc5cc73d35e620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Stelzm=C3=BCller?= Date: Tue, 30 Jun 2026 09:12:50 +0200 Subject: [PATCH 1/2] bugfix: handle already-active error as success (#461) --- bin/action.min.js | 87 +++++++++++++++++++++++++++--- src/deploy.ts | 106 +++++++++++++++++++++++++++++++++---- test/deploy.test.ts | 76 ++++++++++++++++++++++++++ test/samples/cliOutputs.ts | 20 +++++++ 4 files changed, 273 insertions(+), 16 deletions(-) diff --git a/bin/action.min.js b/bin/action.min.js index 7291ada8..befe4571 100644 --- a/bin/action.min.js +++ b/bin/action.min.js @@ -92931,6 +92931,14 @@ exports.getExecOutput = getExecOutput; * See the License for the specific language governing permissions and * limitations under the License. */ +// Firebase returns this (FAILED_PRECONDITION) when the version being released is +// already the live/active version for the target. The upload + finalize already +// succeeded and the site is serving the correct content, so it's a successful +// no-op rather than a failure. +const ALREADY_ACTIVE_VERSION = /is the current active version/i; +function isAlreadyActiveVersionError(message) { + return typeof message === "string" && ALREADY_ACTIVE_VERSION.test(message); +} function interpretChannelDeployResult(deployResult) { const allSiteResults = Object.values(deployResult.result); const expireTime = allSiteResults[0].expireTime; @@ -92963,15 +92971,24 @@ async function execWithCredentials(args, projectId, gacFilename, opts) { } }); } catch (e) { - console.log(Buffer.concat(deployOutputBuf).toString("utf-8")); + const output = Buffer.concat(deployOutputBuf).toString("utf-8"); + console.log(output); console.log(e.message); if (!debug) { - console.log("Retrying deploy with the --debug flag for better error output"); - await execWithCredentials(args, projectId, gacFilename, { - debug: true, - firebaseToolsVersion, - force - }); + if (isAlreadyActiveVersionError(output) || isAlreadyActiveVersionError(e.message)) { + // The deployed version is already the current active version: the + // upload + finalize already succeeded, so treat this as a successful + // no-op. Skip the --debug retry and let the caller interpret the + // captured output below. + console.log("The deployed version is already the current active version; treating as a successful no-op deploy."); + } else { + console.log("Retrying deploy with the --debug flag for better error output"); + await execWithCredentials(args, projectId, gacFilename, { + debug: true, + firebaseToolsVersion, + force + }); + } } else { throw e; } @@ -92992,8 +93009,52 @@ async function deployPreview(gacFilename, deployConfig) { force }); const deploymentResult = JSON.parse(deploymentText.trim()); + if (deploymentResult.status === "error" && isAlreadyActiveVersionError(deploymentResult.error)) { + return await getExistingChannel(gacFilename, deployConfig); + } return deploymentResult; } +// When a channel deploy reports that the version is already the current active +// version, the channel is already serving the correct content. The deploy error +// payload doesn't include the channel URL or expire time (the preview URL embeds +// an opaque Firebase-generated hash), so read them back from the API and rebuild +// the ChannelSuccessResult that the rest of the action expects. +async function getExistingChannel(gacFilename, deployConfig) { + var _ref, _channel$name$match$, _channel$name$match, _channel$url, _channel$expireTime; + const { + projectId, + channelId, + target, + firebaseToolsVersion + } = deployConfig; + const listText = await execWithCredentials(["hosting:channel:list", ...(target ? ["--site", target] : [])], projectId, gacFilename, { + firebaseToolsVersion + } // NOTE: no force flag — hosting:channel:list rejects --force + ); + const list = JSON.parse(listText.trim()); + const channel = list.status === "success" ? list.result.channels.find(c => c.name.endsWith(`/channels/${channelId}`)) : undefined; + if (!channel) { + // Couldn't read the channel back; still treat the deploy as a success (the + // version is already live) but without URL/expire details. + console.log(`Could not find channel "${channelId}" when reading back the already-active deploy; reporting success without URL details.`); + } + // Derive the site key from the channel resource name, e.g. + // projects/

/sites//channels/ + const site = (_ref = (_channel$name$match$ = channel == null || (_channel$name$match = channel.name.match(/\/sites\/([^/]+)\//)) == null ? void 0 : _channel$name$match[1]) != null ? _channel$name$match$ : target) != null ? _ref : channelId; + return { + status: "success", + result: { + [site]: { + site, + ...(target ? { + target + } : {}), + url: (_channel$url = channel == null ? void 0 : channel.url) != null ? _channel$url : "", + expireTime: (_channel$expireTime = channel == null ? void 0 : channel.expireTime) != null ? _channel$expireTime : "" + } + } + }; +} async function deployProductionSite(gacFilename, productionDeployConfig) { const { projectId, @@ -93006,6 +93067,18 @@ async function deployProductionSite(gacFilename, productionDeployConfig) { force }); const deploymentResult = JSON.parse(deploymentText); + if (deploymentResult.status === "error" && isAlreadyActiveVersionError(deploymentResult.error)) { + // The version is already the live version: the upload + finalize succeeded + // and the site is serving the correct content, so treat it as a successful + // no-op. index.ts builds the production URL from target/projectId and does + // not read this result body, so a minimal success result is sufficient. + return { + status: "success", + result: { + hosting: target || projectId + } + }; + } return deploymentResult; } diff --git a/src/deploy.ts b/src/deploy.ts index f7a419b7..b25b0798 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -40,6 +40,23 @@ export type ProductionSuccessResult = { }; }; +type ChannelListResult = { + status: "success"; + result: { + channels: Array<{ name: string; url: string; expireTime: string }>; + }; +}; + +// Firebase returns this (FAILED_PRECONDITION) when the version being released is +// already the live/active version for the target. The upload + finalize already +// succeeded and the site is serving the correct content, so it's a successful +// no-op rather than a failure. +const ALREADY_ACTIVE_VERSION = /is the current active version/i; + +export function isAlreadyActiveVersionError(message: unknown): boolean { + return typeof message === "string" && ALREADY_ACTIVE_VERSION.test(message); +} + type DeployConfig = { projectId: string; target?: string; @@ -110,18 +127,28 @@ async function execWithCredentials( } ); } catch (e) { - console.log(Buffer.concat(deployOutputBuf).toString("utf-8")); + const output = Buffer.concat(deployOutputBuf).toString("utf-8"); + console.log(output); console.log(e.message); if (!debug) { - console.log( - "Retrying deploy with the --debug flag for better error output" - ); - await execWithCredentials(args, projectId, gacFilename, { - debug: true, - firebaseToolsVersion, - force, - }); + if ( + isAlreadyActiveVersionError(output) || + isAlreadyActiveVersionError(e.message) + ) { + console.log( + "The deployed version is already the current active version; treating as a successful no-op deploy." + ); + } else { + console.log( + "Retrying deploy with the --debug flag for better error output" + ); + await execWithCredentials(args, projectId, gacFilename, { + debug: true, + firebaseToolsVersion, + force, + }); + } } else { throw e; } @@ -155,9 +182,60 @@ export async function deployPreview( | ChannelSuccessResult | ErrorResult; + if ( + deploymentResult.status === "error" && + isAlreadyActiveVersionError(deploymentResult.error) + ) { + return await getExistingChannel(gacFilename, deployConfig); + } + return deploymentResult; } +async function getExistingChannel( + gacFilename: string, + deployConfig: ChannelDeployConfig +): Promise { + const { projectId, channelId, target, firebaseToolsVersion } = deployConfig; + + const listText = await execWithCredentials( + ["hosting:channel:list", ...(target ? ["--site", target] : [])], + projectId, + gacFilename, + { firebaseToolsVersion } + ); + + const list = JSON.parse(listText.trim()) as ChannelListResult | ErrorResult; + + const channel = + list.status === "success" + ? list.result.channels.find((c) => + c.name.endsWith(`/channels/${channelId}`) + ) + : undefined; + + if (!channel) { + console.log( + `Could not find channel "${channelId}" when reading back the already-active deploy; reporting success without URL details.` + ); + } + + const site = + channel?.name.match(/\/sites\/([^/]+)\//)?.[1] ?? target ?? channelId; + + return { + status: "success", + result: { + [site]: { + site, + ...(target ? { target } : {}), + url: channel?.url ?? "", + expireTime: channel?.expireTime ?? "", + }, + }, + }; +} + export async function deployProductionSite( gacFilename, productionDeployConfig: ProductionDeployConfig @@ -176,5 +254,15 @@ export async function deployProductionSite( | ProductionSuccessResult | ErrorResult; + if ( + deploymentResult.status === "error" && + isAlreadyActiveVersionError(deploymentResult.error) + ) { + return { + status: "success", + result: { hosting: target || projectId }, + }; + } + return deploymentResult; } diff --git a/test/deploy.test.ts b/test/deploy.test.ts index 43f09d50..1a679825 100644 --- a/test/deploy.test.ts +++ b/test/deploy.test.ts @@ -3,12 +3,15 @@ import { ChannelDeployConfig, deployPreview, deployProductionSite, + isAlreadyActiveVersionError, ProductionDeployConfig, ProductionSuccessResult, } from "../src/deploy"; import * as exec from "@actions/exec"; import { + alreadyActiveVersionError, channelError, + channelListSuccess, channelMultiSiteSuccess, channelSingleSiteSuccess, liveDeployMultiSiteSuccess, @@ -76,6 +79,24 @@ async function fakeExec( ); } +async function fakeExecAlreadyActive( + mainCommand: string, + args: string[], + options: exec.ExecOptions +) { + if (args[0] === "hosting:channel:list") { + options?.listeners?.stdout( + Buffer.from(JSON.stringify(channelListSuccess), "utf8") + ); + return; + } + + options?.listeners?.stdout( + Buffer.from(JSON.stringify(alreadyActiveVersionError), "utf8") + ); + throw new Error("The process failed with exit code 1"); +} + describe("deploy", () => { it("retries with the --debug flag on error", async () => { // @ts-ignore read-only property @@ -229,4 +250,59 @@ describe("deploy", () => { expect(deployFlags).toContain("--force"); }); }); + + describe("already-active version handling", () => { + it("isAlreadyActiveVersionError matches the FAILED_PRECONDITION message", () => { + expect(isAlreadyActiveVersionError(alreadyActiveVersionError.error)).toBe( + true + ); + }); + + it("isAlreadyActiveVersionError ignores unrelated errors", () => { + expect(isAlreadyActiveVersionError(channelError.error)).toBe(false); + expect(isAlreadyActiveVersionError(undefined)).toBe(false); + }); + + it("treats an already-active production deploy as a successful no-op", async () => { + // @ts-ignore read-only property + exec.exec = jest.fn(fakeExecAlreadyActive); + + const result = (await deployProductionSite( + "my-file", + baseLiveDeployConfig + )) as ProductionSuccessResult; + + expect(result.status).toBe("success"); + // It must not retry with the --debug flag for this known error. + expect(exec.exec).toBeCalledTimes(1); + // @ts-ignore Jest adds a magic "mock" property + expect(exec.exec.mock.calls[0][1]).not.toContain("--debug"); + }); + + it("treats an already-active preview deploy as success and reads the channel back", async () => { + // @ts-ignore read-only property + exec.exec = jest.fn(fakeExecAlreadyActive); + + const result = (await deployPreview( + "my-file", + baseChannelDeployConfig + )) as ChannelSuccessResult; + + expect(result.status).toBe("success"); + + const siteResult = Object.values(result.result)[0]; + expect(siteResult.url).toBe( + "https://my-project--my-channel-abc123.web.app" + ); + expect(siteResult.expireTime).toBe("2020-10-27T21:32:57.233344586Z"); + + // First the channel deploy (which fails), then the channel list lookup — + // and no --debug retry in between. + expect(exec.exec).toBeCalledTimes(2); + // @ts-ignore Jest adds a magic "mock" property + const calls = exec.exec.mock.calls; + expect(calls[0][1]).not.toContain("--debug"); + expect(calls[1][1]).toContain("hosting:channel:list"); + }); + }); }); diff --git a/test/samples/cliOutputs.ts b/test/samples/cliOutputs.ts index 1fda8f7d..066850d5 100644 --- a/test/samples/cliOutputs.ts +++ b/test/samples/cliOutputs.ts @@ -41,6 +41,26 @@ export const channelError: ErrorResult = { "HTTP Error: 400, Channel IDs can only include letters, numbers, underscores, hyphens, and periods.", }; +export const alreadyActiveVersionError: ErrorResult = { + status: "error", + error: + "FAILED_PRECONDITION Can't release to projects/-/sites/my-project/channels/my-channel: " + + "supplied version projects/my-project/sites/my-project/versions/abc123 is the current active version", +}; + +export const channelListSuccess = { + status: "success", + result: { + channels: [ + { + name: "projects/my-project/sites/my-project/channels/my-channel", + url: "https://my-project--my-channel-abc123.web.app", + expireTime: "2020-10-27T21:32:57.233344586Z", + }, + ], + }, +}; + export const liveDeploySingleSiteSuccess: ProductionSuccessResult = { status: "success", result: { From 8ca875fc8f0e80a67c44dde00c04c7db2b17f8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Stelzm=C3=BCller?= Date: Tue, 30 Jun 2026 09:12:50 +0200 Subject: [PATCH 2/2] bugfix: handle already-active error as success (#461) --- bin/action.min.js | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/bin/action.min.js b/bin/action.min.js index befe4571..1afa3eaa 100644 --- a/bin/action.min.js +++ b/bin/action.min.js @@ -92976,10 +92976,6 @@ async function execWithCredentials(args, projectId, gacFilename, opts) { console.log(e.message); if (!debug) { if (isAlreadyActiveVersionError(output) || isAlreadyActiveVersionError(e.message)) { - // The deployed version is already the current active version: the - // upload + finalize already succeeded, so treat this as a successful - // no-op. Skip the --debug retry and let the caller interpret the - // captured output below. console.log("The deployed version is already the current active version; treating as a successful no-op deploy."); } else { console.log("Retrying deploy with the --debug flag for better error output"); @@ -93014,11 +93010,6 @@ async function deployPreview(gacFilename, deployConfig) { } return deploymentResult; } -// When a channel deploy reports that the version is already the current active -// version, the channel is already serving the correct content. The deploy error -// payload doesn't include the channel URL or expire time (the preview URL embeds -// an opaque Firebase-generated hash), so read them back from the API and rebuild -// the ChannelSuccessResult that the rest of the action expects. async function getExistingChannel(gacFilename, deployConfig) { var _ref, _channel$name$match$, _channel$name$match, _channel$url, _channel$expireTime; const { @@ -93029,17 +93020,12 @@ async function getExistingChannel(gacFilename, deployConfig) { } = deployConfig; const listText = await execWithCredentials(["hosting:channel:list", ...(target ? ["--site", target] : [])], projectId, gacFilename, { firebaseToolsVersion - } // NOTE: no force flag — hosting:channel:list rejects --force - ); + }); const list = JSON.parse(listText.trim()); const channel = list.status === "success" ? list.result.channels.find(c => c.name.endsWith(`/channels/${channelId}`)) : undefined; if (!channel) { - // Couldn't read the channel back; still treat the deploy as a success (the - // version is already live) but without URL/expire details. console.log(`Could not find channel "${channelId}" when reading back the already-active deploy; reporting success without URL details.`); } - // Derive the site key from the channel resource name, e.g. - // projects/

/sites//channels/ const site = (_ref = (_channel$name$match$ = channel == null || (_channel$name$match = channel.name.match(/\/sites\/([^/]+)\//)) == null ? void 0 : _channel$name$match[1]) != null ? _channel$name$match$ : target) != null ? _ref : channelId; return { status: "success", @@ -93068,10 +93054,6 @@ async function deployProductionSite(gacFilename, productionDeployConfig) { }); const deploymentResult = JSON.parse(deploymentText); if (deploymentResult.status === "error" && isAlreadyActiveVersionError(deploymentResult.error)) { - // The version is already the live version: the upload + finalize succeeded - // and the site is serving the correct content, so treat it as a successful - // no-op. index.ts builds the production URL from target/projectId and does - // not read this result body, so a minimal success result is sufficient. return { status: "success", result: {