Skip to content

Staging - Deploy PR #226

Staging - Deploy PR

Staging - Deploy PR #226

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:
workflow_run:
workflows:
- 'Staging - Build PR'
types:
- completed
permissions:
actions: read
contents: read
deployments: write
pull-requests: read
statuses: write
# IMPORTANT: Intentionally OMIT a `concurrency` configuration from this workflow's
# top-level as we do not have any guarantee of identifying values being available
# within the `github.event` context for PRs from forked repos!
#
# The implication of this shortcoming is that we may have multiple workflow runs
# of this running at the same time for different commits within the same PR.
# However, once they reach the `concurrency` configurations deeper down within
# this workflow's jobs, then we can expect concurrent short-circuiting to begin.
env:
CONTEXT_NAME: '${{ github.workflow }} / deploy (${{ github.event.workflow_run.event }})'
ACTIONS_RUN_LOG: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
BUILD_ACTIONS_RUN_ID: ${{ github.event.workflow_run.id }}
BUILD_ACTIONS_RUN_LOG: https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}
jobs:
debug:
runs-on: ubuntu-latest
steps:
- name: Dump full context for debugging
env:
GITHUB_CONTEXT: ${{ toJSON(github) }}
run: echo "$GITHUB_CONTEXT"
pr-metadata:
# This is needed because the workflow we depend on
# (see on.workflow_run.workflows) might be running from pushes on
# main. That's because it needs to do that to popular the cache.
if: >
${{ github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
outputs:
number: ${{ steps.pr.outputs.number }}
url: ${{ steps.pr.outputs.url }}
state: ${{ steps.pr.outputs.state }}
head_sha: ${{ steps.pr.outputs.head_sha }}
head_branch: ${{ steps.pr.outputs.head_branch }}
head_label: ${{ steps.pr.outputs.head_label }}
head_ref: ${{ steps.pr.outputs.head_ref }}
steps:
- name: Find the originating pull request
id: pr
uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d
env:
BUILD_ACTIONS_RUN_ID: ${{ env.BUILD_ACTIONS_RUN_ID }}
with:
script: |
// Curious about what version of node you get
console.log('Node version:', process.version)
// In order to find out the PR info for a forked repo, we must query
// the API for more info based on the originating workflow run
const { BUILD_ACTIONS_RUN_ID } = process.env
const { owner, repo } = context.repo
const { data: run } = await github.actions.getWorkflowRun({
owner,
repo,
run_id: BUILD_ACTIONS_RUN_ID,
})
// Gather PR-identifying information from the workflow run
const {
head_branch: headBranch,
head_sha: headSha,
head_repository: {
owner: { login: prRepoOwner },
name: prRepoName
}
} = run
const prIsInternal = owner === prRepoOwner && repo === prRepoName
let headLabel = `${prRepoOwner}:${headBranch}`
// If the PR is external, prefix its head branch name with the
// forked repo owner's login and their fork repo name e.g.
// "octocat/my-fork:docs". We need to include the fork repo
// name as well to account for an API issue (this will work fine
// if they don't have a different fork repo name).
if (!prIsInternal) {
headLabel = `${prRepoOwner}/${prRepoName}:${headBranch}`
}
// If the PR is external, prefix its head branch name with the
// forked repo owner's login, e.g. "octocat:docs"
const headRef = prIsInternal ? headBranch : headLabel
// Retrieve matching PRs (up to 30)
const { data: pulls } = await github.pulls.list({
owner,
repo,
head: headLabel,
sort: 'updated',
direction: 'desc',
per_page: 30
})
// Find the open PR, if any, otherwise choose the most recently updated
const targetPull = pulls.find(pr => pr.state === 'open') || pulls[0] || {}
const pullNumber = targetPull.number || 0
const pullUrl = targetPull.html_url || 'about:blank'
const pullState = targetPull.state || 'closed'
core.setOutput('number', pullNumber.toString())
core.setOutput('url', pullUrl)
core.setOutput('state', pullState)
core.setOutput('head_sha', headSha)
core.setOutput('head_branch', headBranch)
core.setOutput('head_label', headLabel)
core.setOutput('head_ref', headRef)
debug-originating-trigger:
needs: pr-metadata
runs-on: ubuntu-latest
steps:
- name: Dump info about the originating workflow run
env:
PR_NUMBER: ${{ needs.pr-metadata.outputs.number }}
PR_URL: ${{ needs.pr-metadata.outputs.url }}
PR_STATE: ${{ needs.pr-metadata.outputs.state }}
HEAD_SHA: ${{ needs.pr-metadata.outputs.head_sha }}
HEAD_BRANCH: ${{ needs.pr-metadata.outputs.head_branch }}
HEAD_LABEL: ${{ needs.pr-metadata.outputs.head_label }}
HEAD_REF: ${{ needs.pr-metadata.outputs.head_ref }}
BUILD_ACTIONS_RUN_ID: ${{ env.BUILD_ACTIONS_RUN_ID }}
BUILD_ACTIONS_RUN_LOG: ${{ env.BUILD_ACTIONS_RUN_LOG }}
run: |
echo "Originating workflow info:"
echo " - PR_NUMBER = $PR_NUMBER"
echo " - PR_URL = $PR_URL"
echo " - PR_STATE = $PR_STATE"
echo " - HEAD_SHA = $HEAD_SHA"
echo " - HEAD_BRANCH = $HEAD_BRANCH"
echo " - HEAD_LABEL = $HEAD_LABEL"
echo " - HEAD_REF = $HEAD_REF"
echo " - BUILD_ACTIONS_RUN_ID = $BUILD_ACTIONS_RUN_ID"
echo " - BUILD_ACTIONS_RUN_LOG = $BUILD_ACTIONS_RUN_LOG"
notify-of-failed-builds:
needs: pr-metadata
if: >-
${{
needs.pr-metadata.outputs.number != '0' &&
github.event.workflow_run.conclusion == 'failure' &&
(github.repository == 'github/docs-internal' || github.repository == 'github/docs')
}}
runs-on: ubuntu-latest
timeout-minutes: 1
# Specifically omitting a concurrency group here in case the build was not
# successful BECAUSE a subsequent build already canceled it
steps:
- name: Verify build workflow run was not cancelled
id: check-workflow-run
uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d
env:
BUILD_ACTIONS_RUN_ID: ${{ env.BUILD_ACTIONS_RUN_ID }}
with:
script: |
const { owner, repo } = context.repo
const { data: { jobs: buildJobs } } = await github.actions.listJobsForWorkflowRun({
owner,
repo,
run_id: process.env.BUILD_ACTIONS_RUN_ID,
filter: 'latest'
})
const wasCancelled = (
buildJobs.length > 0 &&
buildJobs.every(({ status, conclusion }) => {
return status === 'completed' && conclusion === 'cancelled'
})
)
core.setOutput('cancelled', wasCancelled.toString())
- if: ${{ steps.check-workflow-run.outputs.cancelled == 'false' }}
name: Send Slack notification if build workflow failed
uses: someimportantcompany/github-actions-slack-message@f8d28715e7b8a4717047d23f48c39827cacad340
with:
channel: ${{ secrets.DOCS_STAGING_DEPLOYMENT_FAILURES_SLACK_CHANNEL_ID }}
bot-token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }}
color: failure
text: Staging build failed for PR ${{ needs.pr-metadata.outputs.url }} at commit ${{ needs.pr-metadata.outputs.head_sha }}. See ${{ env.BUILD_ACTIONS_RUN_LOG }}. This run was ${{ env.ACTIONS_RUN_LOG }}.
check-pr-before-prepare:
needs: pr-metadata
if: >-
${{
needs.pr-metadata.outputs.number != '0' &&
github.event.workflow_run.conclusion == 'success' &&
(github.repository == 'github/docs-internal' || github.repository == 'github/docs')
}}
runs-on: ubuntu-latest
# This timeout should match or exceed the value of the timeout for Undeploy
timeout-minutes: 5
# This interrupts Build, Deploy, and pre-write Undeploy workflow runs in
# progress for this PR branch.
concurrency:
group: 'PR Staging @ ${{ needs.pr-metadata.outputs.head_label }}'
cancel-in-progress: true
outputs:
pull_request_state: ${{ steps.check-pr.outputs.state }}
steps:
- name: Check pull request state
id: check-pr
uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d
env:
PR_NUMBER: ${{ needs.pr-metadata.outputs.number }}
with:
script: |
// Equivalent of the 'await-sleep' module without the install
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
const blockingLabel = 'automated-block-deploy'
const { owner, repo } = context.repo
const startTime = Date.now()
let pullRequest = {}
let blocked = true
// Keep polling the PR until the blocking label has been removed
while (blocked) {
const { data: pr } = await github.pulls.get({
owner,
repo,
pull_number: process.env.PR_NUMBER
})
blocked = pr.labels.some(({ name }) => name === blockingLabel)
if (blocked) {
console.warn(`WARNING! PR currently has blocking label "${blockingLabel}" (after ${Date.now() - startTime} ms). Will check again soon...`)
await sleep(15000) // Wait 15 seconds and check again
} else {
console.log(`PR was unblocked (after ${Date.now() - startTime} ms)!`)
pullRequest = pr
}
}
core.setOutput('state', pullRequest.state)
prepare-for-deploy:
needs: [pr-metadata, check-pr-before-prepare]
if: ${{ needs.check-pr-before-prepare.outputs.pull_request_state == 'open' }}
runs-on: ${{ fromJSON('["ubuntu-latest", "self-hosted"]')[github.repository == 'github/docs-internal'] }}
timeout-minutes: 5
# This interrupts Build, Deploy, and pre-write Undeploy workflow runs in
# progress for this PR branch.
concurrency:
group: 'PR Staging @ ${{ needs.pr-metadata.outputs.head_label }}'
cancel-in-progress: true
outputs:
source_blob_url: ${{ steps.build-source.outputs.download_url }}
steps:
- name: Create initial status
uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d
env:
CONTEXT_NAME: ${{ env.CONTEXT_NAME }}
ACTIONS_RUN_LOG: ${{ env.ACTIONS_RUN_LOG }}
HEAD_SHA: ${{ needs.pr-metadata.outputs.head_sha }}
with:
script: |
const { CONTEXT_NAME, ACTIONS_RUN_LOG, HEAD_SHA } = process.env
const { owner, repo } = context.repo
await github.repos.createCommitStatus({
owner,
repo,
sha: HEAD_SHA,
context: CONTEXT_NAME,
state: 'pending',
description: 'The app is being deployed. See logs.',
target_url: ACTIONS_RUN_LOG
})
- name: Check out repo's default branch
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
with:
# To prevent issues with cloning early access content later
persist-credentials: 'false'
lfs: 'true'
- name: Check out LFS objects
run: git lfs checkout
- name: Setup node
uses: actions/setup-node@04c56d2f954f1e4c69436aa54cfef261a018f458
with:
node-version: 16.13.x
cache: npm
# Install any dependencies that are needed for the early access script
- if: ${{ github.repository == 'github/docs-internal' }}
name: Install temporary dependencies
run: npm install --no-save dotenv rimraf
# Install any additional dependencies *before* downloading the build artifact
- name: Install Heroku client development-only dependency
run: npm install --no-save heroku-client
- if: ${{ github.repository == 'github/docs-internal' }}
name: Clone early access
run: node script/early-access/clone-for-build.js
env:
DOCUBOT_REPO_PAT: ${{ secrets.DOCUBOT_REPO_PAT }}
GIT_BRANCH: ${{ needs.pr-metadata.outputs.head_ref }}
# Download the previously built "app.tar"
- name: Download build artifact
uses: dawidd6/action-download-artifact@af92a8455a59214b7b932932f2662fdefbd78126
with:
workflow: ${{ github.event.workflow_run.workflow_id }}
run_id: ${{ env.BUILD_ACTIONS_RUN_ID }}
name: pr_build
path: ${{ runner.temp }}
# For security reasons, only extract the tar from docs-internal
# This allows us to add search indexes and early access content to the build
- if: ${{ github.repository == 'github/docs-internal' }}
name: Extract user-changes to temp directory
run: |
mkdir $RUNNER_TEMP/app
tar -x --file=$RUNNER_TEMP/app.tar -C "$RUNNER_TEMP/app/"
# Move the LFS content into the temp directory in chunks (destructively)
- if: ${{ github.repository == 'github/docs-internal' }}
name: Move the LFS objects
run: |
git lfs ls-files --name-only | xargs -n 1 -I {} sh -c 'mkdir -p "$RUNNER_TEMP/app/$(dirname {})"; mv {} "$RUNNER_TEMP/app/$(dirname {})/"'
# Move the early access content into the temp directory (destructively)
- if: ${{ github.repository == 'github/docs-internal' }}
name: Move the early access content
run: |
mv assets/images/early-access "$RUNNER_TEMP/app/assets/images/"
mv content/early-access "$RUNNER_TEMP/app/content/"
mv data/early-access "$RUNNER_TEMP/app/data/"
- if: ${{ github.repository == 'github/docs-internal' }}
name: Create a gzipped archive (docs-internal)
run: tar -cz --file app.tar.gz "$RUNNER_TEMP/app/"
# gzip the app.tar from github/docs so we're working with the same format
- if: ${{ github.repository == 'github/docs' }}
name: Create a gzipped archive (docs)
run: gzip -9 < "$RUNNER_TEMP/app.tar" > app.tar.gz
- name: Create a Heroku build source
id: build-source
uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d
env:
HEROKU_API_TOKEN: ${{ secrets.HEROKU_API_TOKEN }}
with:
script: |
const { owner, repo } = context.repo
if (owner !== 'github') {
throw new Error(`Repository owner must be 'github' but was: ${owner}`)
}
if (repo !== 'docs-internal' && repo !== 'docs') {
throw new Error(`Repository name must be either 'docs-internal' or 'docs' but was: ${repo}`)
}
const Heroku = require('heroku-client')
const heroku = new Heroku({ token: process.env.HEROKU_API_TOKEN })
try {
const { source_blob: sourceBlob } = await heroku.post('/sources')
const { put_url: uploadUrl, get_url: downloadUrl } = sourceBlob
core.setOutput('upload_url', uploadUrl)
core.setOutput('download_url', downloadUrl)
} catch (error) {
if (error.statusCode === 503) {
console.error('💀 Heroku may be down! Please check its Status page: https://status.heroku.com/')
}
throw error
}
# See: https://devcenter.heroku.com/articles/build-and-release-using-the-api#sources-endpoint
- name: Upload to the Heroku build source
env:
UPLOAD_URL: ${{ steps.build-source.outputs.upload_url }}
run: |
curl "$UPLOAD_URL" \
-X PUT \
-H 'Content-Type:' \
--data-binary @app.tar.gz
- name: Create failure status
uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d
if: ${{ failure() }}
env:
CONTEXT_NAME: ${{ env.CONTEXT_NAME }}
ACTIONS_RUN_LOG: ${{ env.ACTIONS_RUN_LOG }}
HEAD_SHA: ${{ needs.pr-metadata.outputs.head_sha }}
with:
script: |
const { CONTEXT_NAME, ACTIONS_RUN_LOG, HEAD_SHA } = process.env
const { owner, repo } = context.repo
await github.repos.createCommitStatus({
owner,
repo,
sha: HEAD_SHA,
context: CONTEXT_NAME,
state: 'error',
description: 'Failed to deploy. See logs.',
target_url: ACTIONS_RUN_LOG
})
- name: Send Slack notification if deployment preparation job failed
uses: someimportantcompany/github-actions-slack-message@f8d28715e7b8a4717047d23f48c39827cacad340
if: ${{ failure() }}
with:
channel: ${{ secrets.DOCS_STAGING_DEPLOYMENT_FAILURES_SLACK_CHANNEL_ID }}
bot-token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }}
color: failure
text: Staging preparation failed for PR ${{ needs.pr-metadata.outputs.url }} at commit ${{ needs.pr-metadata.outputs.head_sha }}. See ${{ env.ACTIONS_RUN_LOG }}.
check-pr-before-deploy:
needs: [pr-metadata, prepare-for-deploy]
runs-on: ubuntu-latest
timeout-minutes: 1
# This interrupts Build, Deploy, and pre-write Undeploy workflow runs in
# progress for this PR branch.
concurrency:
group: 'PR Staging @ ${{ needs.pr-metadata.outputs.head_label }}'
cancel-in-progress: true
outputs:
pull_request_state: ${{ steps.check-pr.outputs.state }}
steps:
- name: Check pull request state
id: check-pr
uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d
env:
PR_NUMBER: ${{ needs.pr-metadata.outputs.number }}
with:
script: |
const { owner, repo } = context.repo
const { data: pullRequest } = await github.pulls.get({
owner,
repo,
pull_number: process.env.PR_NUMBER
})
core.setOutput('state', pullRequest.state)
deploy:
needs: [pr-metadata, prepare-for-deploy, check-pr-before-deploy]
if: ${{ needs.check-pr-before-deploy.outputs.pull_request_state == 'open' }}
runs-on: ${{ fromJSON('["ubuntu-latest", "self-hosted"]')[github.repository == 'github/docs-internal'] }}
timeout-minutes: 10
# This interrupts Build, Deploy, and pre-write Undeploy workflow runs in
# progress for this PR branch.
concurrency:
group: 'PR Staging @ ${{ needs.pr-metadata.outputs.head_label }}'
cancel-in-progress: true
steps:
- name: Check out repo's default branch
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
- name: Setup node
uses: actions/setup-node@04c56d2f954f1e4c69436aa54cfef261a018f458
with:
node-version: 16.13.x
cache: npm
- name: Install dependencies
run: npm ci
- name: Deploy
id: deploy
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEROKU_API_TOKEN: ${{ secrets.HEROKU_API_TOKEN }}
HYDRO_ENDPOINT: ${{ secrets.HYDRO_ENDPOINT }}
HYDRO_SECRET: ${{ secrets.HYDRO_SECRET }}
PR_URL: ${{ needs.pr-metadata.outputs.url }}
SOURCE_BLOB_URL: ${{ needs.prepare-for-deploy.outputs.source_blob_url }}
CONTEXT_NAME: ${{ env.CONTEXT_NAME }}
ACTIONS_RUN_LOG: ${{ env.ACTIONS_RUN_LOG }}
HEAD_SHA: ${{ needs.pr-metadata.outputs.head_sha }}
ALLOWED_POLLING_FAILURES_PER_PHASE: '15'
RUN_ID: ${{ github.run_id }}
run: .github/actions-scripts/staging-deploy.js
- name: Mark the deployment as inactive if timed out
uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d
if: ${{ steps.deploy.outcome == 'cancelled' }}
env:
DEPLOYMENT_ID: ${{ steps.deploy.outputs.deploymentId }}
LOG_URL: ${{ steps.deploy.outputs.logUrl }}
with:
script: |
const { DEPLOYMENT_ID, LOG_URL } = process.env
const { owner, repo } = context.repo
if (!DEPLOYMENT_ID) {
throw new Error('A deployment wasn't created before a timeout occurred!')
}
await github.repos.createDeploymentStatus({
owner,
repo,
deployment_id: DEPLOYMENT_ID,
state: 'error',
description: 'The deployment step timed out. See workflow logs.',
log_url: LOG_URL,
// 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'.
mediaType: {
previews: ['ant-man', 'flash'],
},
})
console.log('⏲️ Deployment status: error - The deployment timed out...')
- name: Create failure status
uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d
if: ${{ failure() }}
env:
CONTEXT_NAME: ${{ env.CONTEXT_NAME }}
ACTIONS_RUN_LOG: ${{ env.ACTIONS_RUN_LOG }}
HEAD_SHA: ${{ needs.pr-metadata.outputs.head_sha }}
with:
script: |
const { CONTEXT_NAME, ACTIONS_RUN_LOG, HEAD_SHA } = process.env
const { owner, repo } = context.repo
await github.repos.createCommitStatus({
owner,
repo,
sha: HEAD_SHA,
context: CONTEXT_NAME,
state: 'error',
description: 'Failed to deploy. See logs.',
target_url: ACTIONS_RUN_LOG
})
- name: Send Slack notification if deployment job failed
uses: someimportantcompany/github-actions-slack-message@f8d28715e7b8a4717047d23f48c39827cacad340
if: ${{ failure() }}
with:
channel: ${{ secrets.DOCS_STAGING_DEPLOYMENT_FAILURES_SLACK_CHANNEL_ID }}
bot-token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }}
color: failure
text: Staging deployment failed for PR ${{ needs.pr-metadata.outputs.url }} at commit ${{ needs.pr-metadata.outputs.head_sha }}. See ${{ env.ACTIONS_RUN_LOG }}.