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'.