Staging - Deploy PR #226
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }}. |