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
114 changes: 114 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.

I think there might be a space in the name of this file? Either that or the github UI is playing tricks.
image

Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import prompts from "prompts";
import { evergreenDeploy, localDeploy, ciDeploy } from "./deploy-production";
import { runDeploy } from "./utils/deploy";
import { getCommitMessages } 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 consoleExitMock;
let errorMock;

beforeEach(() => {
consoleLogMock = jest.spyOn(console, "log").mockImplementation();
errorMock = jest.spyOn(console, "error").mockImplementation();
consoleExitMock = 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.createNewTag as jest.Mock).mockResolvedValue(true);
await evergreenDeploy();
expect(consoleLogMock).toHaveBeenCalledTimes(4);
});

it("return exit code 1 if an error is thrown", async () => {
(getCommitMessages as jest.Mock).mockReturnValue(new Error("error mock"));
await evergreenDeploy();
expect(consoleLogMock).toHaveBeenCalledWith("Deploy failed.");
expect(consoleExitMock).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 });
(runDeploy as jest.Mock).mockImplementation(() => {
throw new Error("error mock");
});
await localDeploy();
expect(errorMock).toHaveBeenCalledWith(new Error("error mock"));
expect(errorMock).toHaveBeenCalledWith("Local deploy failed. Aborting.");
expect(consoleExitMock).toHaveBeenCalledWith(1);
});
});

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

it("should run deploy when running on CI", async () => {
(isRunningOnCI as jest.Mock).mockReturnValue(true);
await ciDeploy();
expect(runDeploy).toHaveBeenCalled();
});
});
});
132 changes: 64 additions & 68 deletions scripts/deploy/deploy-production.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,98 +8,94 @@ import { runDeploy } from "./utils/deploy";
const { createNewTag, 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


if (shouldForceDeploy) {
deleteTag(latestTag);
pushTags();
console.log("Check Evergreen for deploy progress.");
return;
}

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.");
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: shouldCreateAndPushTag } = 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 (shouldCreateAndPushTag) {
if (createNewTag()) {
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"
)}`
)
);
} else {
console.log("Deploy failed.");
process.exit(1);
}
}
} 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
4 changes: 4 additions & 0 deletions scripts/deploy/utils/git/tag/mock-tag-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { yellow } from "../../../../utils/colors";

/**
* `createNewTag` is a helper function that creates a new tag.
* @returns true
*/
const createNewTag = () => {
console.log(yellow("Dry run mode enabled. Created new tag."));
return true;
};

/**
Expand All @@ -26,9 +28,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 };
45 changes: 36 additions & 9 deletions scripts/deploy/utils/git/tag/tag-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,21 @@ import { githubRemote } from "./constants";

/**
* `createNewTag` is a helper function that creates a new tag.
* @returns true if tag creation is successful and false otherwise.
*/
const createNewTag = () => {
execSync("yarn version --new-version patch", {
encoding: "utf-8",
stdio: "inherit",
});
console.log("Creating new tag...");
try {
execSync("yarn version --new-version patch", {
encoding: "utf-8",
stdio: "inherit",
});
} catch (err) {
console.log("Creating new tag failed.");
console.log("output", err);
return false;
}
return true;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this composition is pretty confusing. Some util functions return these booleans indicating the status while others throw errors. What criteria is used to determine what should do what? Could we be a little more consistent in how we error?
How do you feel about treating errors the way we do in go and wrapping them?
https://stackoverflow.com/questions/42754270/re-throwing-exception-in-nodejs-and-not-losing-stack-trace

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sweet, wrapping and re-throwing helps a lot.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What criteria is used to determine what should do what? Could we be a little more consistent in how we error?

I wanted to throw and handle errors closer to the GH api functions so they are easier to track down and debug. Wrapping the error and including a message and then propagating that error to a top level try/catch is better though because 1) we get the benefit of adding help text to a specific code block and 2) have all of the errors collected in the same part of the program so the exit point is consolidated.

};

/**
Expand All @@ -27,20 +36,38 @@ const getLatestTag = () => {
/**
* `deleteTag` is a helper function that deletes a tag.
* @param tag - the tag to delete
* @returns true if deleting tags is successful adn false otherwise
*/
const deleteTag = (tag: string) => {
console.log(`Deleting tag (${tag}) from remote...`);
const deleteCommand = `git push --delete ${githubRemote} ${tag}`;
execSync(deleteCommand, { stdio: "inherit", encoding: "utf-8" });
try {
execSync(deleteCommand, { stdio: "inherit", encoding: "utf-8" });
} catch (err) {
console.log("Deleting tag failed.");
console.log("output", err);
return false;
}
return true;
};

/**
* `pushTags` is a helper function that pushes tags to the remote.
* @returns true if pushing tags is successful and false otherwise.
*/
const pushTags = () => {
execSync(`git push --tags ${githubRemote}`, {
stdio: "inherit",
encoding: "utf-8",
});
console.log("Pushing tags...");
try {
execSync(`git push --tags ${githubRemote}`, {
stdio: "inherit",
encoding: "utf-8",
});
} catch (err) {
console.log("Pushing tags failed.");
console.log("output", err);
return false;
}
return true;
};

export { createNewTag, getLatestTag, deleteTag, pushTags };
17 changes: 15 additions & 2 deletions scripts/push-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,25 @@
WAIT_TIME=9
GITHUB_REMOTE=https://github.com/evergreen-ci/spruce

git push $GITHUB_REMOTE
if git push $GITHUB_REMOTE
then
echo "Successfully pushed to ${GITHUB_REMOTE}"
else
echo "Failed to push to ${GITHUB_REMOTE}"
exit 1
fi

i=$WAIT_TIME
while [ $i -gt 0 ]
do echo -en "Waiting ${i}s for Evergreen to pick up the version\r"; sleep 1; i=$((i-1))
done
echo ""

git push --tags $GITHUB_REMOTE
if git push --tags $GITHUB_REMOTE
then
echo "Successfully pushed tags to ${GITHUB_REMOTE}"
else
echo "Failed to push tags to ${GITHUB_REMOTE}"
exit 1
fi