From 50fbcc62d3aefed7ae4bfdd3e091933f26dab6c1 Mon Sep 17 00:00:00 2001 From: "James M. Greene" Date: Thu, 17 Jun 2021 13:02:02 -0500 Subject: [PATCH] Deploy/undeploy PRs to/from staging via an Actions workflow (#19865) * Add 'script/deploy' to enable manual deploys to Heroku * Pass API tokens into 'deploy-to-staging' module usage * Construct Octokit instance to pass in * Get PR branch name and verify state * Reorganize * Rename option to 'octokit' * Add missing option * Actually use the convenience methods for convenience * Simplify top-level script * Top-level script revisions * Add parse-pr-url module * Add create-staging-app-name module * Remove misplaced comment * Pass in owner * Use owner param * More variables * Pass owner along more * Correct prNumber param reference * Add WIP deploy-to-staging module * Prevent 'scripts/' and '.github/actions-scripts/' files from being modified in open source repo * Extract PR author earlier * Add note about optionally supplying DOCUBOT_REPO_PAT env var * Override Heroku env var during AppSetup creation instead of later to avoid triggering a second deploy * Updates to deploy-to-staging module * Lots of updates * Add dyno start-up monitoring and warmup requests * Ignore 'script/deploy' in the repository-references test * Correct path to Octokit helper * Temporarily add a 'gha-' prefix to environment names * Log whole error if terminal. Good for Octokit errors! * Correct Octokit preview configuration * Add more logging around Heroku build and release * Added more timings to log messages * Monitor dyno states specifically from the dyno list view to avoid 404 oddities when Free dynos are dropped and non-Free dynos are added * Don't wait for AppSetup status as it includes the Build time * Updating logging since we don't see DeploymentStatus update messages in the UI =( * Refactor to extract more properties from the PR object * Add a workflow to deploy PRs to Staging * Fix workflow description * Add skeleton workflow and module for undeploying * Remove commented out code * Update undeployment module * Add '--destroy' flag to 'script/deploy' options * Add timeout and concurrency key for undeployment * Add timeout and concurrency key for deployment * Remove dangling unneeded function declaration * Add ant-man preview for inactive deployment state setting * Fix reference to pull request number * Fix reference to pull request number * Refactor to extract more properties from the PR object * Fix reference to pull request number * Remove workflow * Add workflow to undeploy closed PRs from staging * Add repository filters to jobs * Update to using actions/github-script@4.0.2 * Pass more environment variables that affect deployments * Add explicit .js extensions to local require in Actions workflow * Check out the code and install deps first, of course * Try local requires without the explicit .js extension * Use our usual version of Octokit instead of the provided 'github' instance to avoid versioning discrepancies * Explicitly pass in GITHUB_TOKEN to github-script * Point to the workflow run page as a default log_url * Exclude staging deployment workflows from the workflow linter until we can get support for the 'concurrency' key (https://github.com/cschleiden/actions-linter/issues/79) * Remove 'gha-' prefix --- .github/workflows/staging-deploy-pr.yml | 93 ++++++++++++++++++++ .github/workflows/staging-undeploy-pr.yml | 88 ++++++++++++++++++ .github/workflows/workflow-lint.yml | 2 +- script/deployment/create-staging-app-name.js | 5 +- script/deployment/deploy-to-staging.js | 13 ++- script/deployment/undeploy-from-staging.js | 7 +- 6 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/staging-deploy-pr.yml create mode 100644 .github/workflows/staging-undeploy-pr.yml diff --git a/.github/workflows/staging-deploy-pr.yml b/.github/workflows/staging-deploy-pr.yml new file mode 100644 index 0000000000..b14138afdd --- /dev/null +++ b/.github/workflows/staging-deploy-pr.yml @@ -0,0 +1,93 @@ +name: Staging - Deploy PR + +# **What it does**: To deploy PRs to a Heroku staging environment. +# **Why we have it**: To deploy with high visibility in case of failures. +# **Who does it impact**: All contributors. + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - unlocked + +jobs: + deploy: + if: ${{ github.repository == 'github/docs-internal' || github.repository == 'github/docs' }} + name: Deploy + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: staging_${{ github.head_ref }} + cancel-in-progress: false + steps: + - name: Check out repo + uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f + with: + # Enables cloning the Early Access repo later with the relevant PAT + persist-credentials: 'false' + + - name: Setup node + uses: actions/setup-node@c46424eee26de4078d34105d3de3cc4992202b1e + with: + node-version: 16.x + + - name: Get npm cache directory + id: npm-cache + run: | + echo "::set-output name=dir::$(npm config get cache)" + + - name: Cache node modules + uses: actions/cache@0781355a23dac32fd3bac414512f4b903437991a + with: + path: ${{ steps.npm-cache.outputs.dir }} + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: npm ci + + - name: Deploy + uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEROKU_API_TOKEN: ${{ secrets.HEROKU_API_TOKEN }} + DOCUBOT_REPO_PAT: ${{ secrets.DOCUBOT_REPO_PAT }} + HYDRO_ENDPOINT: ${{ secrets.HYDRO_ENDPOINT }} + HYDRO_SECRET: ${{ secrets.HYDRO_SECRET }} + with: + script: | + 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 getOctokit = require('./script/helpers/github') + const deployToStaging = require('./script/deployment/deploy-to-staging') + + // This helper uses the `GITHUB_TOKEN` implicitly! + // We're using our usual version of Octokit vs. the provided `github` + // instance to avoid versioning discrepancies. + const octokit = getOctokit() + + try { + await deployToStaging({ + herokuToken: HEROKU_API_TOKEN, + octokit, + pullRequest: context.payload.pull_request, + runId: context.runId + }) + } catch (error) { + console.error(`Failed to deploy to staging: ${error.message}`) + console.error(error) + throw error + } diff --git a/.github/workflows/staging-undeploy-pr.yml b/.github/workflows/staging-undeploy-pr.yml new file mode 100644 index 0000000000..e112cc0144 --- /dev/null +++ b/.github/workflows/staging-undeploy-pr.yml @@ -0,0 +1,88 @@ +name: Staging - Undeploy PR + +# **What it does**: To undeploy PRs from a Heroku staging environment, i.e. destroy the Heroku App. +# **Why we have it**: To save money spent on deployments for closed PRs. +# **Who does it impact**: All contributors. + +on: + pull_request: + types: + - closed + - locked + +jobs: + undeploy: + if: ${{ github.repository == 'github/docs-internal' || github.repository == 'github/docs' }} + name: Undeploy + runs-on: ubuntu-latest + timeout-minutes: 2 + concurrency: + group: staging_${{ github.head_ref }} + cancel-in-progress: true + steps: + - name: Check out repo + uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f + with: + # Enables cloning the Early Access repo later with the relevant PAT + persist-credentials: 'false' + + - name: Setup node + uses: actions/setup-node@c46424eee26de4078d34105d3de3cc4992202b1e + with: + node-version: 16.x + + - name: Get npm cache directory + id: npm-cache + run: | + echo "::set-output name=dir::$(npm config get cache)" + + - name: Cache node modules + uses: actions/cache@0781355a23dac32fd3bac414512f4b903437991a + with: + path: ${{ steps.npm-cache.outputs.dir }} + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: npm ci + + - name: Undeploy + uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEROKU_API_TOKEN: ${{ secrets.HEROKU_API_TOKEN }} + with: + script: | + 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 getOctokit = require('./script/helpers/github') + const undeployFromStaging = require('./script/deployment/undeploy-from-staging') + + // This helper uses the `GITHUB_TOKEN` implicitly! + // We're using our usual version of Octokit vs. the provided `github` + // instance to avoid versioning discrepancies. + const octokit = getOctokit() + + try { + await undeployFromStaging({ + herokuToken: HEROKU_API_TOKEN, + octokit, + pullRequest: context.payload.pull_request, + runId: context.runId + }) + } catch (error) { + console.error(`Failed to undeploy from staging: ${error.message}`) + console.error(error) + throw error + } diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index a3f3c57ede..6779e97adf 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -26,4 +26,4 @@ jobs: - name: Run linter uses: cschleiden/actions-linter@0ff16d6ac5103cca6c92e6cbc922b646baaea5be with: - workflows: '[".github/workflows/*.yml"]' + workflows: '[".github/workflows/*.yml", "!.github/workflows/staging-deploy-pr.yml", "!.github/workflows/staging-undeploy-pr.yml"]' diff --git a/script/deployment/create-staging-app-name.js b/script/deployment/create-staging-app-name.js index 06356fcef3..b1d96926f8 100644 --- a/script/deployment/create-staging-app-name.js +++ b/script/deployment/create-staging-app-name.js @@ -3,10 +3,7 @@ const slugify = require('github-slugger').slug const APP_NAME_MAX_LENGTH = 30 module.exports = function ({ repo, pullNumber, branch }) { - // - // TODO: Remove the 'gha-' prefix!!! - // - return `gha-${repo}-${pullNumber}--${slugify(branch)}` + return `${repo}-${pullNumber}--${slugify(branch)}` // Shorten the string to the max allowed length .slice(0, APP_NAME_MAX_LENGTH) // Convert underscores to dashes diff --git a/script/deployment/deploy-to-staging.js b/script/deployment/deploy-to-staging.js index 3105dacfbc..12fd87e70c 100644 --- a/script/deployment/deploy-to-staging.js +++ b/script/deployment/deploy-to-staging.js @@ -6,7 +6,13 @@ const createStagingAppName = require('./create-staging-app-name') const SLEEP_INTERVAL = 5000 const HEROKU_LOG_LINES_TO_SHOW = 25 -module.exports = async function deployToStaging ({ herokuToken, octokit, pullRequest, forceRebuild = false }) { +module.exports = async function deployToStaging ({ + herokuToken, + octokit, + pullRequest, + forceRebuild = false, + runId = null +}) { // Start a timer so we can report how long the deployment takes const startTime = Date.now() @@ -32,8 +38,9 @@ module.exports = async function deployToStaging ({ herokuToken, octokit, pullReq throw new Error(`This pull request is not open. State is: '${state}'`) } + const workflowRunLog = runId ? `https://github.com/${owner}/${repo}/actions/runs/${runId}` : null let deploymentId = null - let logUrl = null + let logUrl = workflowRunLog let appIsNewlyCreated = false const appName = createStagingAppName({ repo, pullNumber, branch }) @@ -285,7 +292,7 @@ module.exports = async function deployToStaging ({ herokuToken, octokit, pullReq // This will help us catch issues with faulty startup code and/or the package manifest. const dynoBootStartTime = Date.now() console.log('Checking Heroku dynos...') - logUrl = null + logUrl = workflowRunLog console.log('🚀 Deployment status: in_progress - Monitoring the Heroku dyno start-up...') diff --git a/script/deployment/undeploy-from-staging.js b/script/deployment/undeploy-from-staging.js index 5bead29b90..e6f51b14db 100644 --- a/script/deployment/undeploy-from-staging.js +++ b/script/deployment/undeploy-from-staging.js @@ -4,7 +4,8 @@ const createStagingAppName = require('./create-staging-app-name') module.exports = async function undeployFromStaging ({ herokuToken, octokit, - pullRequest + pullRequest, + runId = null }) { // Start a timer so we can report how long the deployment takes const startTime = Date.now() @@ -23,6 +24,9 @@ module.exports = async function undeployFromStaging ({ } } = pullRequest + const workflowRunLog = runId ? `https://github.com/${owner}/${repo}/actions/runs/${runId}` : null + const logUrl = workflowRunLog + const appName = createStagingAppName({ repo, pullNumber, branch }) try { @@ -78,6 +82,7 @@ module.exports = async function undeployFromStaging ({ deployment_id: deployment.id, state: 'inactive', description: 'The app was undeployed', + ...logUrl && { log_url: logUrl }, // The 'ant-man' preview is required for `state` values of 'inactive', as well as // the use of the `log_url`, `environment_url`, and `auto_inactive` parameters. // The 'flash' preview is required for `state` values of 'in_progress' and 'queued'.