1
0
mirror of synced 2025-12-20 10:28:40 -05:00
Files
docs/script/deploy.js
James M. Greene bb0455962e Do not use build.status as a looping condition for Heroku deployment (#21909)
* Do not use `build.status` of 'pending' as a looping condition for Heroku deployment
* Don't wait for `appSetup.status` either
* Fix incorrect Octokit method usage in local deploy script
* Bump the number of allowable errors from 5 to 10
* More logging!
* Add an environment variable for easily increasing the number of allowed Heroku failures per phase of polling
2021-10-06 12:57:30 -05:00

282 lines
8.6 KiB
JavaScript
Executable File

#!/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
// Exit if Heroku App name is not found
if (!HEROKU_PRODUCTION_APP_NAME) {
throw new Error('You must supply a HEROKU_PRODUCTION_APP_NAME environment variable!')
}
// 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.createCommitStatus({
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.createCommitStatus({
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.createCommitStatus({
owner,
repo,
sha: pullRequest.head.sha,
context: CONTEXT_NAME,
state: 'error',
description: 'Failed to deploy. See local logs.',
})
}
process.exit(1)
}
}
export default deploy