Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 62 additions & 7 deletions bin/action.min.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -92963,15 +92971,20 @@ 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)) {
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;
}
Expand All @@ -92992,8 +93005,42 @@ 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;
}
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
});
const list = JSON.parse(listText.trim());
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 = (_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,
Expand All @@ -93006,6 +93053,14 @@ async function deployProductionSite(gacFilename, productionDeployConfig) {
force
});
const deploymentResult = JSON.parse(deploymentText);
if (deploymentResult.status === "error" && isAlreadyActiveVersionError(deploymentResult.error)) {
return {
status: "success",
result: {
hosting: target || projectId
}
};
}
return deploymentResult;
}

Expand Down
106 changes: 97 additions & 9 deletions src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Comment on lines 134 to 154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If debug is true (for example, if the action is run with debug options or during a retry), the catch block will immediately execute the else branch and throw the error, completely bypassing the isAlreadyActiveVersionError check. This means an "already active version" error will still cause the deployment to fail when debugging is active.

We should check isAlreadyActiveVersionError first, before checking if (!debug).

    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 if (!debug) {
      console.log(
        "Retrying deploy with the --debug flag for better error output"
      );
      await execWithCredentials(args, projectId, gacFilename, {
        debug: true,
        firebaseToolsVersion,
        force,
      });
    } else {
      throw e;
    }

Expand Down Expand Up @@ -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<ChannelSuccessResult> {
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 ?? "",
},
},
};
}
Comment on lines +195 to +237

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If hosting:channel:list fails (due to transient network issues, permission errors, etc.) or returns invalid JSON, getExistingChannel will throw an unhandled exception. This will cause the entire deployment action to fail, even though the actual deployment succeeded (since the "already active version" error indicates the content is already live).

To make this more robust, we should wrap the channel list retrieval in a try-catch block and fall back to returning a successful result with empty URL details if it fails.

async function getExistingChannel(
  gacFilename: string,
  deployConfig: ChannelDeployConfig
): Promise<ChannelSuccessResult> {
  const { projectId, channelId, target, firebaseToolsVersion } = deployConfig;
  const site = target ?? channelId;

  try {
    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.`
      );
      if (list.status === "error") {
        console.log(`Channel list error: ${list.error}`);
      }
    }

    const resolvedSite =
      channel?.name.match(/\/sites\/([^/]+)\/\//)?.[1] ?? site;

    return {
      status: "success",
      result: {
        [resolvedSite]: {
          site: resolvedSite,
          ...(target ? { target } : {}),
          url: channel?.url ?? "",
          expireTime: channel?.expireTime ?? "",
        },
      },
    };
  } catch (e) {
    console.log(
      `Failed to retrieve channel list: ${e.message}. Reporting success without URL details.`
    );
    return {
      status: "success",
      result: {
        [site]: {
          site,
          ...(target ? { target } : {}),
          url: "",
          expireTime: "",
        },
      },
    };
  }
}


export async function deployProductionSite(
gacFilename,
productionDeployConfig: ProductionDeployConfig
Expand All @@ -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;
}
76 changes: 76 additions & 0 deletions test/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
});
});
});
20 changes: 20 additions & 0 deletions test/samples/cliOutputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down