-
Notifications
You must be signed in to change notification settings - Fork 25
EVG-20983: Improve deploy script error handling and remove duplicate tag push #2108
Changes from 7 commits
73de282
595d835
6c7c300
929c982
5e6d058
2131f24
6b23c4f
5592ca8
bc7a38e
9ea58a9
90df75e
d4075a8
7cbe8a2
27b550a
2e6a895
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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(); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the duplicate tag push. |
||
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."); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sweet, wrapping and re-throwing helps a lot. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
}; | ||
|
||
/** | ||
|
@@ -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 }; |
There was a problem hiding this comment.
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.