Skip to content
This repository has been archived by the owner on Jul 2, 2024. It is now read-only.

EVG-20983: Improve deploy script error handling and remove duplicate tag push #2108

Merged
merged 15 commits into from
Nov 1, 2023
142 changes: 142 additions & 0 deletions scripts/deploy/deploy-production.test.ts
Copy link
Contributor

Choose a reason for hiding this comment

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

😍

Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import prompts from "prompts";
import { evergreenDeploy, localDeploy, ciDeploy } from "./deploy-production";
import { runDeploy } from "./utils/deploy";
import { getCommitMessages, getCurrentlyDeployedCommit } from "./utils/git";
import { tagUtils } from "./utils/git/tag";
import { isRunningOnCI } from "./utils/environment";

jest.mock("prompts");
jest.mock("./utils/git/tag");
jest.mock("./utils/git");
jest.mock("./utils/deploy");
jest.mock("./utils/environment");

describe("deploy-production", () => {
let consoleLogMock;
let processExitMock;
let consoleErrorMock;

beforeEach(() => {
consoleLogMock = jest.spyOn(console, "log").mockImplementation();
consoleErrorMock = jest.spyOn(console, "error").mockImplementation();
processExitMock = jest
.spyOn(process, "exit")
.mockImplementation(() => undefined as never);
});

afterEach(() => {
jest.clearAllMocks();
});

describe("evergreenDeploy", () => {
it("should force deploy if no new commits and user confirms", async () => {
(getCommitMessages as jest.Mock).mockReturnValue("");
(prompts as unknown as jest.Mock).mockResolvedValue({ value: true });

await evergreenDeploy();

expect(tagUtils.deleteTag).toHaveBeenCalled();
expect(tagUtils.pushTags).toHaveBeenCalled();
expect(consoleLogMock).toHaveBeenCalledWith(
"Check Evergreen for deploy progress."
);
});

it("should cancel deploy if no new commits and user denies", async () => {
(getCommitMessages as jest.Mock).mockReturnValue("");
(prompts as unknown as jest.Mock).mockResolvedValue({ value: false });
await evergreenDeploy();
expect(tagUtils.deleteTag).not.toHaveBeenCalled();
expect(tagUtils.pushTags).not.toHaveBeenCalled();
expect(consoleLogMock).toHaveBeenCalledWith(
"Deploy canceled. If systems are experiencing an outage and you'd like to push the deploy directly to S3, run yarn deploy:prod --local."
);
});

it("should deploy if new commits, user confirms and create tag succeeds", async () => {
(getCommitMessages as jest.Mock).mockReturnValue(
"getCommitMessages result"
);
(prompts as unknown as jest.Mock).mockResolvedValue({ value: true });
(tagUtils.createTagAndPush as jest.Mock).mockResolvedValue(true);
const createTagAndPushMock = jest
.spyOn(tagUtils, "createTagAndPush")
.mockImplementation(() => true);
(getCurrentlyDeployedCommit as jest.Mock).mockReturnValue(
"getCurrentlyDeployedCommit mock"
);
await evergreenDeploy();
expect(consoleLogMock).toHaveBeenCalledTimes(2);
expect(consoleLogMock).toHaveBeenCalledWith(
"Currently Deployed Commit: getCurrentlyDeployedCommit mock"
);
expect(consoleLogMock).toHaveBeenCalledWith(
"Commit messages:\ngetCommitMessages result"
);
expect(createTagAndPushMock).toBeCalledTimes(1);
});

it("return exit code 1 if an error is thrown", async () => {
const e = new Error("test error", { cause: "cause of test error" });
(getCommitMessages as jest.Mock).mockReturnValue(
"getCommitMessages result"
);
(prompts as unknown as jest.Mock).mockResolvedValue({ value: true });
(tagUtils.createTagAndPush as jest.Mock).mockImplementation(() => {
throw e;
});
expect(await evergreenDeploy());
expect(consoleErrorMock).toHaveBeenCalledWith(e);
expect(consoleLogMock).toHaveBeenCalledWith("Deploy failed.");
expect(processExitMock).toHaveBeenCalledWith(1);
});
});

describe("localDeploy", () => {
it("should run deploy when user confirms", async () => {
(prompts as unknown as jest.Mock).mockResolvedValue({ value: true });
await localDeploy();
expect(runDeploy).toHaveBeenCalled();
});

it("should not run deploy when user denies", async () => {
(prompts as unknown as jest.Mock).mockResolvedValue({ value: false });
await localDeploy();
expect(runDeploy).not.toHaveBeenCalled();
});

it("logs and error and returns exit code 1 when error is thrown", async () => {
(prompts as unknown as jest.Mock).mockResolvedValue({ value: true });
const e = new Error("error mock");
(runDeploy as jest.Mock).mockImplementation(() => {
throw e;
});
await localDeploy();
expect(consoleErrorMock).toHaveBeenCalledWith(e);
expect(consoleErrorMock).toHaveBeenCalledWith(
"Local deploy failed. Aborting."
);
expect(processExitMock).toHaveBeenCalledWith(1);
});
});

describe("ciDeploy", () => {
it("returns exit code 1 when not running in CI", async () => {
(isRunningOnCI as jest.Mock).mockReturnValue(false);
await ciDeploy();
expect(consoleErrorMock).toHaveBeenCalledWith(
new Error("Not running on CI")
);
expect(consoleErrorMock).toHaveBeenCalledWith(
"CI deploy failed. Aborting."
);
expect(processExitMock).toHaveBeenCalledWith(1);
});

it("should run deploy when running on CI", async () => {
(isRunningOnCI as jest.Mock).mockReturnValue(true);
await ciDeploy();
expect(runDeploy).toHaveBeenCalled();
});
});
});
122 changes: 52 additions & 70 deletions scripts/deploy/deploy-production.ts
Original file line number Diff line number Diff line change
@@ -1,105 +1,87 @@
import prompts from "prompts";
import { tagUtils } from "./utils/git/tag";
import { green, underline } from "../utils/colors";
import { isRunningOnCI } from "./utils/environment";
import { getCommitMessages, getCurrentlyDeployedCommit } from "./utils/git";
import { runDeploy } from "./utils/deploy";

const { createNewTag, deleteTag, getLatestTag, pushTags } = tagUtils;
const { createTagAndPush, deleteTag, getLatestTag, pushTags } = tagUtils;
/* Deploy by pushing a git tag, to be picked up and built by Evergreen, and deployed to S3. */
const evergreenDeploy = async () => {
const currentlyDeployedCommit = getCurrentlyDeployedCommit();
console.log(`Currently Deployed Commit: ${currentlyDeployedCommit}`);
try {
const currentlyDeployedCommit = getCurrentlyDeployedCommit();
console.log(`Currently Deployed Commit: ${currentlyDeployedCommit}`);

const commitMessages = getCommitMessages(currentlyDeployedCommit);
const commitMessages = getCommitMessages(currentlyDeployedCommit);

// If there are no commit messages, ask the user if they want to delete and re-push the latest tag, thereby forcing a deploy with no new commits.
if (commitMessages.length === 0) {
const latestTag = getLatestTag();
const response = await prompts({
type: "confirm",
name: "value",
message: `No new commits. Do you want to trigger a deploy on the most recent existing tag? (${latestTag})`,
initial: false,
});
// If there are no commit messages, ask the user if they want to delete and re-push the latest tag, thereby forcing a deploy with no new commits.
if (commitMessages.length === 0) {
const latestTag = getLatestTag();
const { value: shouldForceDeploy } = await prompts({
type: "confirm",
name: "value",
message: `No new commits. Do you want to trigger a deploy on the most recent existing tag? (${latestTag})`,
initial: false,
});
Comment on lines +19 to +24
Copy link
Contributor

Choose a reason for hiding this comment

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

We ran into an issue last week when the deployer just hit enter and breezed through this message. Could we maybe have it default to false so that a user needs to be a little more careful to go through with this action?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a good idea but I'll like to complete it in a separate ticket: https://jira.mongodb.org/browse/EVG-21115


const forceDeploy = response.value;
if (forceDeploy) {
console.log(`Deleting tag (${latestTag}) from remote...`);
deleteTag(latestTag);
console.log("Pushing tags...");
pushTags();
console.log("Check Evergreen for deploy progress.");
if (shouldForceDeploy) {
deleteTag(latestTag);
pushTags();
console.log("Check Evergreen for deploy progress.");
} else {
console.log(
"Deploy canceled. If systems are experiencing an outage and you'd like to push the deploy directly to S3, run yarn deploy:prod --local."
);
}
return;
}

console.log(
"Deploy canceled. If systems are experiencing an outage and you'd like to push the deploy directly to S3, run yarn deploy:prod --local."
);
return;
}

// Print all commits between the last tag and the current commit
console.log(`Commit messages:\n${commitMessages}`);
// Print all commits between the last tag and the current commit
console.log(`Commit messages:\n${commitMessages}`);

const response = await prompts({
type: "confirm",
name: "value",
message: "Are you sure you want to deploy to production?",
});
const { value: shouldCreateTagAndPush } = await prompts({
type: "confirm",
name: "value",
message: "Are you sure you want to deploy to production?",
});

if (response.value) {
try {
console.log("Creating new tag...");
createNewTag();
console.log("Pushing tags...");
pushTags();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the duplicate tag push. createNewTag calls the postversion hook which runs scripts/push-version.sh

console.log("Pushed to remote. Should be deploying soon...");
console.log(
green(
`Track deploy progress at ${underline(
"https://spruce.mongodb.com/commits/spruce?requester=git_tag_request"
)}`
)
);
} catch (err) {
console.error(err);
console.error("Creating tag failed. Aborting.");
process.exit(1);
if (shouldCreateTagAndPush) {
createTagAndPush();
}
} catch (err) {
console.error(err);
console.log("Deploy failed.");
process.exit(1);
}
};

/* Deploy by generating a production build locally and pushing it directly to S3. */
const localDeploy = async () => {
const response = await prompts({
type: "confirm",
name: "value",
message:
"Are you sure you'd like to build Spruce locally and push directly to S3? This is a high-risk operation that requires a correctly configured local environment.",
});

if (response.value) {
try {
try {
const response = await prompts({
type: "confirm",
name: "value",
message:
"Are you sure you'd like to build Spruce locally and push directly to S3? This is a high-risk operation that requires a correctly configured local environment.",
});
if (response.value) {
runDeploy();
} catch (err) {
console.error(err);
console.error("Local deploy failed. Aborting.");
process.exit(1);
}
} catch (err) {
console.error(err);
console.error("Local deploy failed. Aborting.");
process.exit(1);
}
};

/**
* `ciDeploy` is a special deploy function that is only run on CI. It does the actual deploy to S3.
*/
const ciDeploy = async () => {
if (!isRunningOnCI()) {
throw new Error("Not running on CI");
}
try {
const ciDeployOutput = runDeploy();
console.log(ciDeployOutput);
if (!isRunningOnCI()) {
throw new Error("Not running on CI");
}
runDeploy();
} catch (err) {
console.error(err);
console.error("CI deploy failed. Aborting.");
Expand Down
10 changes: 6 additions & 4 deletions scripts/deploy/utils/git/tag/mock-tag-utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { yellow } from "../../../../utils/colors";

/**
* `createNewTag` is a helper function that creates a new tag.
* `createTagAndPush` is a helper function that creates a new tag and pushes to remote.
*/
const createNewTag = () => {
console.log(yellow("Dry run mode enabled. Created new tag."));
const createTagAndPush = () => {
console.log(yellow("Dry run mode enabled. Created new tag and pushed."));
};

/**
Expand All @@ -26,9 +26,11 @@ const deleteTag = (tag: string) => {

/**
* `pushTags` is a helper function that pushes tags to the remote.
* @returns true
*/
const pushTags = () => {
console.log(yellow("Dry run mode enabled. Pushing tags."));
return true;
};

export { createNewTag, getLatestTag, deleteTag, pushTags };
export { createTagAndPush, getLatestTag, deleteTag, pushTags };
Loading