forked from github/docs
-
Notifications
You must be signed in to change notification settings - Fork 0
/
deploy.js
executable file
·283 lines (246 loc) · 8.66 KB
/
deploy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
#!/usr/bin/env node
// [start-readme]
//
// This script is run by a GitHub Actions workflow to trigger deployments
// to Heroku for both staging and production apps.
//
// You can also run it locally if you:
// - Supply a GitHub PAT as the GITHUB_TOKEN environment variable
// - Supply a Heroku API token as the HEROKU_API_TOKEN environment variable
// - Optionally, supply a GitHub PAT as the DOCUBOT_REPO_PAT environment
// variable if you want to support content from the `docs-early-access` repo
//
// For production deployment in particular, you MUST:
// - Provide the name of the Heroku App we use for production as the
// HEROKU_PRODUCTION_APP_NAME environment variable. This must be obfuscated
// from our codebase for security reasons.
//
// ...and you SHOULD:
// - Supply the aforementioned DOCUBOT_REPO_PAT environment variable to support
// content from the `docs-early-access` repo. In most cases, you should be
// able to just set this to the same value as GITHUB_TOKEN when running this
// script locally as it just needs read access to that repo.
// - Supply our Fastly API token and Service ID as the FASTLY_TOKEN and
// FASTLY_SERVICE_ID enviroment variables, respectively, to support
// soft-purging the Fastly cache after deploying.
//
// Examples:
// - Deploy a PR to Staging and force the Heroku App to be rebuilt from scratch (by default):
// script/deploy.js --staging https://github.com/github/docs/pull/9876
//
// - Deploy a PR to Staging and DO NOT rebuild the Heroku App:
// script/deploy.js --staging https://github.com/github/docs-internal/pull/12345 --no-rebuild
//
// - Undeploy a PR from Staging by deleting the Heroku App:
// script/deploy.js --staging https://github.com/github/docs/pull/9876 --destroy
//
// - Deploy the latest from docs-internal `main` to production:
// script/deploy.js --production
//
// [end-readme]
import dotenv from 'dotenv'
import program from 'commander'
import { has } from 'lodash-es'
import yesno from 'yesno'
import getOctokit from './helpers/github.js'
import parsePrUrl from './deployment/parse-pr-url.js'
import deployToStaging from './deployment/deploy-to-staging.js'
import undeployFromStaging from './deployment/undeploy-from-staging.js'
import deployToProduction from './deployment/deploy-to-production.js'
import purgeEdgeCache from './deployment/purge-edge-cache.js'
dotenv.config()
const { GITHUB_TOKEN, HEROKU_API_TOKEN } = process.env
// Exit if GitHub Actions PAT is not found
if (!GITHUB_TOKEN) {
throw new Error('You must supply a GITHUB_TOKEN environment variable!')
}
// Exit if Heroku API token is not found
if (!HEROKU_API_TOKEN) {
throw new Error('You must supply a HEROKU_API_TOKEN environment variable!')
}
const STAGING_FLAG = '--staging'
const PRODUCTION_FLAG = '--production'
const ALLOWED_OWNER = 'github'
const ALLOWED_SOURCE_REPOS = ['docs', 'docs-internal']
const EXPECTED_PR_URL_FORMAT = `https://github.com/${ALLOWED_OWNER}/(${ALLOWED_SOURCE_REPOS.join(
'|'
)})/pull/123`
program
.description('Trigger a deployment to Heroku for either staging or production apps')
.option(PRODUCTION_FLAG, 'Deploy the latest internal main branch to Production')
.option(`${STAGING_FLAG} <PR_URL>`, 'Deploy a pull request to Staging')
.option(
'--no-rebuild',
'Do NOT force a Staging deployment to rebuild the Heroku App from scratch'
)
.option('--destroy', 'Undeploy a Staging deployment by deleting the Heroku App')
.parse(process.argv)
const opts = program.opts()
const isProduction = opts.production === true
const isStaging = has(opts, 'staging')
const prUrl = opts.staging
const forceRebuild = !isProduction && opts.rebuild !== false
const destroy = opts.destroy === true
//
// Verify CLI options
//
if (!isProduction && !isStaging) {
invalidateAndExit(
'commander.missingArgument',
`error: must specify option '${STAGING_FLAG} <PR_URL>' or '${PRODUCTION_FLAG}'`
)
}
if (isProduction && isStaging) {
invalidateAndExit(
'commander.conflictingArgument',
`error: must specify option '${STAGING_FLAG} <PR_URL>' or '${PRODUCTION_FLAG}' but not both`
)
}
if (isProduction && forceRebuild) {
invalidateAndExit(
'commander.conflictingArgument',
`error: cannot specify option '--rebuild' combined with option '${PRODUCTION_FLAG}'`
)
}
if (isProduction && destroy) {
invalidateAndExit(
'commander.conflictingArgument',
`error: cannot specify option '--destroy' combined with option '${PRODUCTION_FLAG}'`
)
}
// Extract the repository name and pull request number from the URL (if any)
const { owner, repo, pullNumber } = parsePrUrl(prUrl)
if (isStaging) {
if (owner !== ALLOWED_OWNER || !ALLOWED_SOURCE_REPOS.includes(repo) || !pullNumber) {
invalidateAndExit(
'commander.invalidArgument',
`error: option '${STAGING_FLAG}' argument '${prUrl}' is invalid.
Must match URL format '${EXPECTED_PR_URL_FORMAT}'`
)
}
}
deploy()
//
// Function definitions
//
function invalidateAndExit(errorType, message) {
program._displayError(1, errorType, message)
process.exit(1)
}
async function deploy() {
if (isProduction) {
await deployProduction()
} else if (isStaging) {
await deployStaging({ owner, repo, pullNumber, forceRebuild, destroy })
}
}
async function deployProduction() {
const { HEROKU_PRODUCTION_APP_NAME, DOCUBOT_REPO_PAT, FASTLY_TOKEN, FASTLY_SERVICE_ID } =
process.env
// Warn if Heroku App name is not found
if (!HEROKU_PRODUCTION_APP_NAME) {
console.warn(
'⚠️ You did not supply a HEROKU_PRODUCTION_APP_NAME environment variable.\nWithout it, this deployment will not end up in our production environment!'
)
}
// Warn if @docubot PAT is not found
if (!DOCUBOT_REPO_PAT) {
console.warn(
'⚠️ You did not supply a DOCUBOT_REPO_PAT environment variable.\nWithout it, this deployment will not contain any Early Access content!'
)
}
// Warn if Fastly credentials are not found
if (!FASTLY_TOKEN) {
console.warn(
'⚠️ You did not supply a FASTLY_TOKEN environment variable.\nWithout it, this deployment will not soft-purge the Fastly cache!'
)
}
if (!FASTLY_SERVICE_ID) {
console.warn(
'⚠️ You did not supply a FASTLY_SERVICE_ID environment variable.\nWithout it, this deployment will not soft-purge the Fastly cache!'
)
}
if (!process.env.FASTLY_SURROGATE_KEY) {
// Default to our current Fastly surrogate key if unspecified
process.env.FASTLY_SURROGATE_KEY = 'all-the-things'
}
// Request confirmation before deploying to production
const proceed = await yesno({
question: '\n🛑 You have selected to deploy to production. ARE YOU CERTAIN!?',
defaultValue: null,
})
if (!proceed) {
console.error('\n❌ User canceled the production deployment! Halting...')
process.exit(1)
}
// This helper uses the `GITHUB_TOKEN` implicitly
const octokit = getOctokit()
try {
await deployToProduction({
octokit,
includeDelayForPreboot: !!(FASTLY_TOKEN && FASTLY_SERVICE_ID),
})
await purgeEdgeCache()
} catch (error) {
console.error(`Failed to deploy production: ${error.message}`)
console.error(error)
process.exit(1)
}
}
async function deployStaging({ owner, repo, pullNumber, forceRebuild = false, destroy = false }) {
// Hardcode the Status context name to match Actions
const CONTEXT_NAME = 'Staging - Deploy PR / deploy (pull_request)'
// This helper uses the `GITHUB_TOKEN` implicitly
const octokit = getOctokit()
const { data: pullRequest } = await octokit.pulls.get({
owner,
repo,
pull_number: pullNumber,
})
try {
if (destroy) {
await undeployFromStaging({
octokit,
pullRequest,
})
} else {
await octokit.repos.createStatus({
owner,
repo,
sha: pullRequest.head.sha,
context: CONTEXT_NAME,
state: 'pending',
description: 'The app is being deployed. See local logs.',
})
await deployToStaging({
octokit,
pullRequest,
forceRebuild,
})
await octokit.repos.createStatus({
owner,
repo,
sha: pullRequest.head.sha,
context: CONTEXT_NAME,
state: 'success',
description: 'Successfully deployed! See local logs.',
})
}
} catch (error) {
const action = destroy ? 'undeploy from' : 'deploy to'
console.error(`Failed to ${action} staging: ${error.message}`)
console.error(error)
if (!destroy) {
await octokit.repos.createStatus({
owner,
repo,
sha: pullRequest.head.sha,
context: CONTEXT_NAME,
state: 'error',
description: 'Failed to deploy. See local logs.',
})
}
process.exit(1)
}
}
export default deploy