diff --git a/scripts/deploy/deploy-production.test.ts b/scripts/deploy/deploy-production.test.ts new file mode 100644 index 0000000000..723579df13 --- /dev/null +++ b/scripts/deploy/deploy-production.test.ts @@ -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(); + }); + }); +}); diff --git a/scripts/deploy/deploy-production.ts b/scripts/deploy/deploy-production.ts index dac64cef44..1393f7f52d 100644 --- a/scripts/deploy/deploy-production.ts +++ b/scripts/deploy/deploy-production.ts @@ -1,92 +1,75 @@ 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, + }); - 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(); - 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); } }; @@ -94,12 +77,11 @@ const localDeploy = async () => { * `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."); diff --git a/scripts/deploy/utils/git/tag/mock-tag-utils.ts b/scripts/deploy/utils/git/tag/mock-tag-utils.ts index cd54f20096..e953b27a04 100644 --- a/scripts/deploy/utils/git/tag/mock-tag-utils.ts +++ b/scripts/deploy/utils/git/tag/mock-tag-utils.ts @@ -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.")); }; /** @@ -31,4 +31,4 @@ const pushTags = () => { console.log(yellow("Dry run mode enabled. Pushing tags.")); }; -export { createNewTag, getLatestTag, deleteTag, pushTags }; +export { createTagAndPush, getLatestTag, deleteTag, pushTags }; diff --git a/scripts/deploy/utils/git/tag/tag-utils.ts b/scripts/deploy/utils/git/tag/tag-utils.ts index 97d511ff9f..3fc2efe3db 100644 --- a/scripts/deploy/utils/git/tag/tag-utils.ts +++ b/scripts/deploy/utils/git/tag/tag-utils.ts @@ -1,14 +1,29 @@ import { execSync } from "child_process"; import { githubRemote } from "./constants"; +import { green, underline } from "../../../../utils/colors"; /** - * `createNewTag` is a helper function that creates a new tag. + * `createTagAndPush` is a helper function that creates a new tag. + * Pushing occurs in the postversion hook triggered by "yarn version" */ -const createNewTag = () => { - execSync("yarn version --new-version patch", { - encoding: "utf-8", - stdio: "inherit", - }); +const createTagAndPush = () => { + console.log("Creating new tag..."); + try { + execSync("yarn version --new-version patch", { + encoding: "utf-8", + stdio: "inherit", + }); + } catch (err) { + throw new Error("Creating new tag failed.", { cause: err }); + } + 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" + )}` + ) + ); }; /** @@ -16,12 +31,16 @@ const createNewTag = () => { * @returns - the latest tag */ const getLatestTag = () => { - const latestTag = execSync("git describe --tags --abbrev=0", { - encoding: "utf-8", - }) - .toString() - .trim(); - return latestTag; + try { + const latestTag = execSync("git describe --tags --abbrev=0", { + encoding: "utf-8", + }) + .toString() + .trim(); + return latestTag; + } catch (err) { + throw new Error("Getting latest tag failed.", { cause: err }); + } }; /** @@ -29,18 +48,28 @@ const getLatestTag = () => { * @param tag - the tag to delete */ 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) { + throw new Error("Deleting tag failed.", { cause: err }); + } }; /** * `pushTags` is a helper function that pushes tags to the remote. */ 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) { + throw new Error("Pushing tags failed.", { cause: err }); + } }; -export { createNewTag, getLatestTag, deleteTag, pushTags }; +export { createTagAndPush, getLatestTag, deleteTag, pushTags }; diff --git a/scripts/push-version.sh b/scripts/push-version.sh index 1c5011e38d..024bac9958 100755 --- a/scripts/push-version.sh +++ b/scripts/push-version.sh @@ -3,7 +3,13 @@ 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 ] @@ -11,4 +17,11 @@ while [ $i -gt 0 ] 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 +