From f6d85c6d64db627f7678197c2cd737d502548338 Mon Sep 17 00:00:00 2001 From: Robert Sese Date: Fri, 17 Sep 2021 18:17:17 -0500 Subject: [PATCH] Security: remove Docker PR build/deploy workflows (#21599) * Remove Docker PR build/deploy workflows * Remove supporting Docker deploy script --- .github/workflows/staging-build-pr-docker.yml | 92 --- .../workflows/staging-deploy-pr-docker.yml | 342 ---------- script/deployment/deploy-to-staging-docker.js | 609 ------------------ 3 files changed, 1043 deletions(-) delete mode 100644 .github/workflows/staging-build-pr-docker.yml delete mode 100644 .github/workflows/staging-deploy-pr-docker.yml delete mode 100644 script/deployment/deploy-to-staging-docker.js diff --git a/.github/workflows/staging-build-pr-docker.yml b/.github/workflows/staging-build-pr-docker.yml deleted file mode 100644 index 6e9075b418..0000000000 --- a/.github/workflows/staging-build-pr-docker.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Staging - Build PR Docker - -# **What it does**: Builds PRs before deploying them. -# **Why we have it**: Because it's not safe to share our deploy secrets with forked repos: https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ -# **Who does it impact**: All contributors. - -on: - pull_request: - types: - - opened - - reopened - - synchronize - -permissions: - contents: read - -jobs: - build: - if: >- - ${{ - (github.repository == 'github/docs-internal' || github.repository == 'github/docs') && - startsWith(github.head_ref, 'docker-') - }} - name: Build - runs-on: ubuntu-latest - timeout-minutes: 5 - concurrency: - group: staging_docker_${{ github.head_ref }} - cancel-in-progress: true - steps: - - name: Check out repo - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f - - # Make sure only approved files are changed if it's in github/docs - - name: Check changed files - if: ${{ github.repository == 'github/docs' && github.event.pull_request.user.login != 'Octomerger' }} - uses: dorny/paths-filter@eb75a1edc117d3756a18ef89958ee59f9500ba58 - id: filter - with: - # Base branch used to get changed files - base: 'main' - - # Enables setting an output in the format in `${FILTER_NAME}_files - # with the names of the matching files formatted as JSON array - list-files: json - - # Returns list of changed files matching each filter - filters: | - notAllowed: - - '*.js' - - '*.mjs' - - '*.ts' - - '*.tsx' - - '*.json' - - '.npmrc' - - 'script/**' - - 'Dockerfile*' - - # When there are changes to files we can't accept - - name: Fail when disallowed files are changed - if: ${{ steps.filter.outputs.notAllowed == 'true' }} - run: exit 1 - - - name: Create an archive - run: | - tar -c --file=app.tar \ - assets/ \ - content/ \ - stylesheets/ \ - pages/ \ - data/ \ - includes/ \ - lib/ \ - middleware/ \ - translations/ \ - server.mjs \ - package*.json \ - .npmrc \ - feature-flags.json \ - next.config.js \ - tsconfig.json \ - next-env.d.ts \ - Dockerfile - - # Upload only the files needed to run + build this application. - # We are not willing to trust the rest (e.g. script/) for the remainder - # of the deployment process. - - name: Upload build artifact - uses: actions/upload-artifact@27121b0bdffd731efa15d66772be8dc71245d074 - with: - name: pr_build_docker - path: app.tar diff --git a/.github/workflows/staging-deploy-pr-docker.yml b/.github/workflows/staging-deploy-pr-docker.yml deleted file mode 100644 index 3a450ad0b0..0000000000 --- a/.github/workflows/staging-deploy-pr-docker.yml +++ /dev/null @@ -1,342 +0,0 @@ -name: Staging - Deploy PR Docker - -# **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 Docker' - types: - - completed - -permissions: - actions: read - contents: read - deployments: write - pull-requests: read - statuses: write - -env: - EARLY_ACCESS_SCRIPT_PATH: script/early-access/clone-for-build.js - EARLY_ACCESS_SUPPORT_FILES: script/package.json - # In this specific workflow relationship, the `github.event.workflow_run.pull_requests` - # array will always contain only 1 item! Specifically, it will contain the PR associated - # with the `github.event.workflow_run.head_branch` that triggered the preceding - # `pull_request` event that triggered the "Staging - Build PR" workflow. - PR_URL: ${{ github.event.workflow_run.repository.html_url }}/pull/${{ github.event.workflow_run.pull_requests[0].number }} - BUILD_ACTIONS_RUN_LOG: https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }} - -jobs: - notify-of-failed-builds: - if: >- - ${{ - github.event.workflow_run.conclusion == 'failure' && - (github.repository == 'github/docs-internal' || github.repository == 'github/docs') && - startsWith(github.event.workflow_run.head_branch, 'docker-') - }} - 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: Send Slack notification if build workflow failed - uses: someimportantcompany/github-actions-slack-message@0b470c14b39da4260ed9e3f9a4f1298a74ccdefd - with: - channel: ${{ secrets.DOCS_STAGING_DEPLOYMENT_FAILURES_SLACK_CHANNEL_ID }} - bot-token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} - color: failure - text: Staging build (docker) failed for PR ${{ env.PR_URL }} at commit ${{ github.event.workflow_run.head_sha }}. See ${{ env.BUILD_ACTIONS_RUN_LOG }} - - check-pr-before-prepare: - if: >- - ${{ - github.event.workflow_run.conclusion == 'success' && - (github.repository == 'github/docs-internal' || github.repository == 'github/docs') && - startsWith(github.event.workflow_run.head_branch, 'docker-') - }} - runs-on: ubuntu-latest - timeout-minutes: 1 - concurrency: - group: staging_docker_${{ github.event.workflow_run.head_branch }} - 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 - with: - script: | - const { owner, repo } = context.repo - const { data: pullRequest } = await github.pulls.get({ - owner, - repo, - pull_number: ${{ github.event.workflow_run.pull_requests[0].number }} - }) - core.setOutput('state', pullRequest.state) - - prepare: - needs: check-pr-before-prepare - if: ${{ needs.check-pr-before-prepare.outputs.pull_request_state == 'open' }} - env: - HEROKU_API_KEY: ${{ secrets.HEROKU_API_TOKEN }} - runs-on: ubuntu-latest - timeout-minutes: 20 - concurrency: - group: staging_docker_${{ github.event.workflow_run.head_branch }} - cancel-in-progress: true - outputs: - source_blob_url: ${{ steps.build-source.outputs.download_url }} - app_name: ${{ steps.create-app.outputs.app_name}} - docker_image_id: ${{ steps.image-id.outputs.image_id}} - steps: - - name: Check out repo's default branch - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f - with: - # For enhanced security: https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ - persist-credentials: 'false' - - - name: Setup node - uses: actions/setup-node@38d90ce44d5275ad62cc48384b3d8a58c500bb5f - with: - node-version: 16.8.x - cache: npm - - - name: Install dependencies - run: npm ci - - - if: ${{ github.repository == 'github/docs-internal' }} - name: Clone early access - run: node ${{ env.EARLY_ACCESS_SCRIPT_PATH }} - env: - DOCUBOT_REPO_PAT: ${{ secrets.DOCUBOT_REPO_PAT }} - GIT_BRANCH: ${{ github.event.workflow_run.head_branch }} - - - if: ${{ github.repository == 'github/docs-internal' }} - name: Create an archive for early access - run: | - tar -c --file=early-access.tar \ - assets/ \ - content/ \ - data/ - - # Download the previously built "app.tar" - - name: Download build artifact - uses: dawidd6/action-download-artifact@b9571484721e8187f1fd08147b497129f8972c74 - with: - workflow: ${{ github.event.workflow_run.workflow_id }} - run_id: ${{ github.event.workflow_run.id }} - name: pr_build - path: ./ - - # Append "early-access.tar" into "app.tar" - - if: ${{ github.repository == 'github/docs-internal' }} - name: Concatenate the archives - run: tar -A --file=app.tar early-access.tar - - - name: Create app - id: create-app - uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HEROKU_API_TOKEN: ${{ secrets.HEROKU_API_TOKEN }} - HYDRO_ENDPOINT: ${{ secrets.HYDRO_ENDPOINT }} - HYDRO_SECRET: ${{ secrets.HYDRO_SECRET }} - with: - script: | - const esm = require('esm') - require = esm({}) - - const { default: createApp } = require('./script/deployment/create-app.js') - const { default: parsePrUrl } = require('./script/deployment/parse-pr-url.js') - const { default: getOctokit } = require('./script/helpers/github') - - // 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 { - const { owner, repo, pullNumber } = parsePrUrl(process.env.PR_URL) - if (!owner || !repo || !pullNumber) { - throw new Error(`'pullRequestUrl' input must match URL format 'https://github.com/github/(docs|docs-internal)/pull/123' but was '${process.env.PR_URL}'`) - } - - const { data: pullRequest } = await octokit.pulls.get({ - owner, - repo, - pull_number: pullNumber - }) - - const appName = await createApp(pullRequest) - core.setOutput('app_name', appName) - } catch(err) { - console.log(`Failed to create app: ${err}`) - throw(err) - } - - - name: Extract user-changes to tmp directory - run: | - tar -x --file=app.tar -C ${{ runner.temp }}/ - - - name: Build, tag, push, and release the Docker image - run: | - docker image build -f ${{ runner.temp }}/Dockerfile --target production_early_access -t registry.heroku.com/${{ steps.create-app.outputs.app_name}}/web ${{ runner.temp }} - heroku container:login - docker push registry.heroku.com/${{ steps.create-app.outputs.app_name }}/web - heroku container:release web --app=${{ steps.create-app.outputs.app_name }} - - # This command will fail for a brand new app since they don't have a - # process type yet so we need to scale after `heroku container:release`. - - name: Scale Heroku App size - run: | - heroku ps:scale --app=${{ steps.create-app.outputs.app_name}} web=1:Standard-2X - - # https://devcenter.heroku.com/articles/container-registry-and-runtime#getting-a-docker-image-id - - name: Get Docker Image ID - id: image-id - run: | - echo "::set-output name=image_id::$(docker image inspect registry.heroku.com/${{ steps.create-app.outputs.app_name }}/web --format={{.Id}})" - exit 1 # Stop at this point, don't move on to prepare job - - - name: Send Slack notification if workflow fails - uses: someimportantcompany/github-actions-slack-message@0b470c14b39da4260ed9e3f9a4f1298a74ccdefd - if: ${{ failure() }} - with: - channel: ${{ secrets.DOCS_STAGING_DEPLOYMENT_FAILURES_SLACK_CHANNEL_ID }} - bot-token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} - color: failure - text: Staging preparation (docker) failed for PR ${{ env.PR_URL }} at commit ${{ github.sha }}. See https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - - check-pr-before-deploy: - needs: prepare - runs-on: ubuntu-latest - timeout-minutes: 1 - concurrency: - group: staging_docker_${{ github.event.workflow_run.head_branch }} - 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 - with: - script: | - const { owner, repo } = context.repo - const { data: pullRequest } = await github.pulls.get({ - owner, - repo, - pull_number: ${{ github.event.workflow_run.pull_requests[0].number }} - }) - core.setOutput('state', pullRequest.state) - - deploy: - needs: [prepare, check-pr-before-deploy] - if: ${{ needs.check-pr-before-deploy.outputs.pull_request_state == 'open' }} - runs-on: ubuntu-latest - timeout-minutes: 10 - concurrency: - group: staging_docker_${{ github.event.workflow_run.head_branch }} - cancel-in-progress: true - steps: - - name: Check out repo's default branch - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f - - - name: Setup node - uses: actions/setup-node@38d90ce44d5275ad62cc48384b3d8a58c500bb5f - with: - node-version: 16.8.x - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Install one-off development-only dependencies - run: npm install --no-save --include=optional esm - - - name: Deploy - id: deploy - uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d - 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: ${{ env.PR_URL }} - SOURCE_BLOB_URL: ${{ needs.prepare.outputs.source_blob_url }} - APP_NAME: ${{ needs.prepare.outputs.app_name }} - DOCKER_IMAGE_ID: ${{ needs.prepare.outputs.docker_image_id }} - 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!') - } - - // Workaround to allow us to load ESM files with `require(...)` - const esm = require('esm') - require = esm({}) - - const { default: parsePrUrl } = require('./script/deployment/parse-pr-url') - const { default: getOctokit } = require('./script/helpers/github') - const { default: deployToStaging } = require('./script/deployment/deploy-to-staging-docker') - - // 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 { - const { PR_URL, SOURCE_BLOB_URL } = process.env - const { owner, repo, pullNumber } = parsePrUrl(PR_URL) - if (!owner || !repo || !pullNumber) { - throw new Error(`'pullRequestUrl' input must match URL format 'https://github.com/github/(docs|docs-internal)/pull/123' but was '${PR_URL}'`) - } - - const { data: pullRequest } = await octokit.pulls.get({ - owner, - repo, - pull_number: pullNumber - }) - - await deployToStaging({ - octokit, - pullRequest, - forceRebuild: false, - // These parameters will ONLY be set by Actions - sourceBlobUrl: SOURCE_BLOB_URL, - runId: context.runId - }) - } catch (error) { - console.error(`Failed to deploy to staging: ${error.message}`) - console.error(error) - throw error - } - - - name: Mark the deployment as inactive if timed out - if: ${{ steps.deploy.outcome == 'cancelled' }} - uses: actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d - with: - script: | - // TODO: Find the relevant deployment - // TODO: Create a new deployment status for it as "inactive" - return 'TODO' - - - name: Send Slack notification if workflow fails - uses: someimportantcompany/github-actions-slack-message@0b470c14b39da4260ed9e3f9a4f1298a74ccdefd - 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 ${{ env.PR_URL }} at commit ${{ github.sha }}. See https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/script/deployment/deploy-to-staging-docker.js b/script/deployment/deploy-to-staging-docker.js deleted file mode 100644 index 447ed410c9..0000000000 --- a/script/deployment/deploy-to-staging-docker.js +++ /dev/null @@ -1,609 +0,0 @@ -#!/usr/bin/env node -import sleep from 'await-sleep' -import got from 'got' -import Heroku from 'heroku-client' -import createStagingAppName from './create-staging-app-name.js' - -const SLEEP_INTERVAL = 5000 -const HEROKU_LOG_LINES_TO_SHOW = 25 - -// Allow for a few 404 (Not Found) or 429 (Too Many Requests) responses from the -// semi-unreliable Heroku API when we're polling for status updates -const ALLOWED_MISSING_RESPONSE_COUNT = 5 - -export default async function deployToStaging({ - octokit, - pullRequest, - forceRebuild = false, - // These parameters will only be set by Actions - sourceBlobUrl = null, - runId = null, -}) { - // Start a timer so we can report how long the deployment takes - const startTime = Date.now() - - // Extract some important properties from the PR - const { - number: pullNumber, - base: { - repo: { - name: repo, - owner: { login: owner }, - }, - }, - state, - head: { ref: branch, sha }, - user: author, - } = pullRequest - - // Verify the PR is still open - if (state !== 'open') { - throw new Error(`This pull request is not open. State is: '${state}'`) - } - - // Put together application configuration variables - const isPrivateRepo = owner === 'github' && repo === 'docs-internal' - const isPrebuilt = !!sourceBlobUrl - const { DOCUBOT_REPO_PAT, HYDRO_ENDPOINT, HYDRO_SECRET } = process.env - const appConfigVars = { - // Track the git branch - GIT_BRANCH: branch, - // If prebuilt: prevent the Heroku Node.js buildpack from installing devDependencies - NPM_CONFIG_PRODUCTION: isPrebuilt.toString(), - // If prebuilt: prevent the Heroku Node.js buildpack from using `npm ci` as it would - // delete all of the vendored "node_modules/" directory. - USE_NPM_INSTALL: isPrebuilt.toString(), - // IMPORTANT: These secrets should only be set in the private repo! - // This is only required for cloning the `docs-early-access` repo - ...(isPrivateRepo && !isPrebuilt && DOCUBOT_REPO_PAT && { DOCUBOT_REPO_PAT }), - // These are required for Hydro event tracking - ...(isPrivateRepo && HYDRO_ENDPOINT && HYDRO_SECRET && { HYDRO_ENDPOINT, HYDRO_SECRET }), - } - - const workflowRunLog = runId ? `https://github.com/${owner}/${repo}/actions/runs/${runId}` : null - let deploymentId = null - let logUrl = workflowRunLog - let appIsNewlyCreated = false - - const appName = createStagingAppName({ repo, pullNumber, branch }) - const homepageUrl = `https://${appName}.herokuapp.com/` - - try { - const title = `branch '${branch}' at commit '${sha}' in the 'staging' environment as '${appName}'` - - console.log(`About to deploy ${title}...`) - - // Kick off a pending GitHub Deployment right away, so the PR author - // will have instant feedback that their work is being deployed. - const { data: deployment } = await octokit.repos.createDeployment({ - owner, - repo, - - description: `Deploying ${title}`, - - // Use a commit SHA instead of a branch name as the ref for more precise - // feedback, and also because the branch may have already been deleted. - ref: sha, - - // In the GitHub API, there can only be one active deployment per environment. - // For our many staging apps, we must use the unique appName as the environment. - environment: appName, - - // The status contexts to verify against commit status checks. If you omit - // this parameter, GitHub verifies all unique contexts before creating a - // deployment. To bypass checking entirely, pass an empty array. Defaults - // to all unique contexts. - required_contexts: [], - - // Do not try to merge the base branch into the feature branch - auto_merge: false, - }) - console.log('GitHub Deployment created', deployment) - - // Store this ID for later updating - deploymentId = deployment.id - - await octokit.repos.createDeploymentStatus({ - owner, - repo, - deployment_id: deploymentId, - state: 'in_progress', - description: 'Deploying the app...', - // 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: in_progress - Preparing to deploy the app...') - - // Time to talk to Heroku... - const heroku = new Heroku({ token: process.env.HEROKU_API_TOKEN }) - let appSetup = null - let build = null - - // Is there already a Heroku App for this PR? - let appExists = true - try { - await heroku.get(`/apps/${appName}`) - } catch (error) { - appExists = false - } - - // If there is an existing app but we want to forcibly rebuild, delete the app first - if (appExists && forceRebuild) { - console.log('🚀 Deployment status: in_progress - Destroying existing Heroku app...') - - try { - await heroku.delete(`/apps/${appName}`) - appExists = false - - console.log(`Heroku app '${appName}' deleted for forced rebuild`) - } catch (error) { - throw new Error( - `Failed to delete Heroku app '${appName}' for forced rebuild. Error: ${error}` - ) - } - } - - if (!sourceBlobUrl) { - try { - sourceBlobUrl = await getTarballUrl({ - octokit, - owner, - repo, - sha, - }) - } catch (error) { - throw new Error(`Failed to generate source blob URL. Error: ${error}`) - } - } - - // If an app does not exist, create one! - // This action will also trigger a build as a by-product. - if (!appExists) { - appIsNewlyCreated = true - - console.log(`Heroku app '${appName}' does not exist. Creating a new AppSetup...`) - - console.log('🚀 Deployment status: in_progress - Creating a new Heroku app...') - - const appSetupStartTime = Date.now() - try { - appSetup = await heroku.post('/app-setups', { - body: { - app: { - name: appName, - }, - source_blob: { - url: sourceBlobUrl, - }, - - // Pass some environment variables to staging apps via Heroku - // config variables. - overrides: { - env: appConfigVars, - }, - }, - }) - console.log('Heroku AppSetup created', appSetup) - - // This probably will not be available yet - build = appSetup.build - } catch (error) { - throw new Error(`Failed to create Heroku app '${appName}'. Error: ${error}`) - } - - // Add PR author (if staff) as a collaborator on the new staging app - try { - if (author.site_admin === true) { - await heroku.post(`/apps/${appName}/collaborators`, { - body: { - user: `${author.login}@github.com`, - // We don't want an email invitation for every new staging app - silent: true, - }, - }) - console.log(`Added PR author @${author.login} as a Heroku app collaborator`) - } - } catch (error) { - // It's fine if this fails, it shouldn't block the app from deploying! - console.warn( - `Warning: failed to add PR author as a Heroku app collaborator. Error: ${error}` - ) - } - - // A new Build is created as a by-product of creating an AppSetup. - // Poll until there is a Build object attached to the AppSetup. - let setupAcceptableErrorCount = 0 - while (!build || !build.id) { - await sleep(SLEEP_INTERVAL) - try { - appSetup = await heroku.get(`/app-setups/${appSetup.id}`) - build = appSetup.build - } catch (error) { - // Allow for a few bad responses from the Heroku API - if (error.statusCode === 404 || error.statusCode === 429) { - setupAcceptableErrorCount += 1 - if (setupAcceptableErrorCount <= ALLOWED_MISSING_RESPONSE_COUNT) { - continue - } - } - throw new Error(`Failed to get AppSetup status. Error: ${error}`) - } - - console.log( - `AppSetup status: ${appSetup.status} (after ${Math.round( - (Date.now() - appSetupStartTime) / 1000 - )} seconds)` - ) - } - - console.log('Heroku build detected', build) - } else { - // If the app does exist, just manually trigger a new build - console.log(`Heroku app '${appName}' already exists.`) - - console.log('Updating Heroku app configuration variables...') - - // Reconfigure environment variables - // https://devcenter.heroku.com/articles/platform-api-reference#config-vars-update - try { - await heroku.patch(`/apps/${appName}/config-vars`, { - body: appConfigVars, - }) - } catch (error) { - throw new Error(`Failed to update Heroku app configuration variables. Error: ${error}`) - } - - console.log('Reconfigured') - console.log('Building Heroku app...') - - try { - build = await heroku.post(`/apps/${appName}/builds`, { - body: { - source_blob: { - url: sourceBlobUrl, - }, - }, - }) - } catch (error) { - throw new Error(`Failed to create Heroku build. Error: ${error}`) - } - - console.log('Heroku build created', build) - } - - const buildStartTime = Date.now() // Close enough... - const buildId = build.id - logUrl = build.output_stream_url - - console.log('🚀 Deployment status: in_progress - Building a new Heroku slug...') - - // Poll until the Build's status changes from "pending" to "succeeded" or "failed". - let buildAcceptableErrorCount = 0 - while (!build || build.status === 'pending' || !build.release || !build.release.id) { - await sleep(SLEEP_INTERVAL) - try { - build = await heroku.get(`/apps/${appName}/builds/${buildId}`) - } catch (error) { - // Allow for a few bad responses from the Heroku API - if (error.statusCode === 404 || error.statusCode === 429) { - buildAcceptableErrorCount += 1 - if (buildAcceptableErrorCount <= ALLOWED_MISSING_RESPONSE_COUNT) { - continue - } - } - throw new Error(`Failed to get build status. Error: ${error}`) - } - console.log( - `Heroku build status: ${(build || {}).status} (after ${Math.round( - (Date.now() - buildStartTime) / 1000 - )} seconds)` - ) - } - - if (build.status !== 'succeeded') { - throw new Error( - `Failed to build after ${Math.round( - (Date.now() - buildStartTime) / 1000 - )} seconds. See Heroku logs for more information:\n${logUrl}` - ) - } - - console.log( - `Finished Heroku build after ${Math.round((Date.now() - buildStartTime) / 1000)} seconds.`, - build - ) - - const releaseStartTime = Date.now() // Close enough... - let releaseId = build.release.id - let release = null - - // Poll until the associated Release's status changes from "pending" to "succeeded" or "failed". - let releaseAcceptableErrorCount = 0 - while (!release || release.status === 'pending') { - await sleep(SLEEP_INTERVAL) - try { - const result = await heroku.get(`/apps/${appName}/releases/${releaseId}`) - - // Update the deployment status but only on the first retrieval - if (!release) { - logUrl = result.output_stream_url - - console.log('Heroku Release created', result) - - console.log('🚀 Deployment status: in_progress - Releasing the built Heroku slug...') - } - - release = result - } catch (error) { - // Allow for a few bad responses from the Heroku API - if (error.statusCode === 404 || error.statusCode === 429) { - releaseAcceptableErrorCount += 1 - if (releaseAcceptableErrorCount <= ALLOWED_MISSING_RESPONSE_COUNT) { - continue - } - } - throw new Error(`Failed to get release status. Error: ${error}`) - } - - console.log( - `Release status: ${(release || {}).status} (after ${Math.round( - (Date.now() - releaseStartTime) / 1000 - )} seconds)` - ) - } - - if (release.status !== 'succeeded') { - throw new Error( - `Failed to release after ${Math.round( - (Date.now() - releaseStartTime) / 1000 - )} seconds. See Heroku logs for more information:\n${logUrl}` - ) - } - - console.log( - `Finished Heroku release after ${Math.round( - (Date.now() - releaseStartTime) / 1000 - )} seconds.`, - release - ) - - // Monitor dyno state for this release to ensure it reaches "up" rather than crashing. - // 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 = workflowRunLog - - console.log('🚀 Deployment status: in_progress - Monitoring the Heroku dyno start-up...') - - // Keep checking while there are still dynos in non-terminal states - let newDynos = [] - let dynoAcceptableErrorCount = 0 - while (newDynos.length === 0 || newDynos.some((dyno) => dyno.state === 'starting')) { - await sleep(SLEEP_INTERVAL) - try { - const dynoList = await heroku.get(`/apps/${appName}/dynos`) - const dynosForThisRelease = dynoList.filter((dyno) => dyno.release.id === releaseId) - - // To track them afterward - newDynos = dynosForThisRelease - - // Dynos for this release OR a newer release - const relevantDynos = dynoList.filter((dyno) => dyno.release.version >= release.version) - - // If this Heroku app was just newly created, often a secondary release - // is requested to enable automatically managed SSL certificates. The - // release description will read: - // "Enable allow-multiple-sni-endpoints feature" - // - // If that is the case, we need to update to monitor that secondary - // release instead. - if (relevantDynos.length > 0 && dynosForThisRelease.length === 0) { - // If the app is NOT newly created, fail fast! - if (!appIsNewlyCreated) { - throw new Error('The dynos for this release disappeared unexpectedly') - } - - // Check for the secondary release - let nextRelease = null - try { - nextRelease = await heroku.get(`/apps/${appName}/releases/${release.version + 1}`) - } catch (error) { - throw new Error( - `Could not find a secondary release to explain the disappearing dynos. Error: ${error}` - ) - } - - if (nextRelease) { - if (nextRelease.description === 'Enable allow-multiple-sni-endpoints feature') { - // Track dynos for the next release instead - release = nextRelease - releaseId = nextRelease.id - - console.warn('Switching to monitor secondary release...') - - // Allow the loop to repeat to fetch the dynos for the secondary release - } else { - // Otherwise, assume another release replaced this one but it - // PROBABLY would've succeeded...? - newDynos.forEach((dyno) => { - dyno.state = 'up' - }) - } - } - // else just keep monitoring and hope for the best - } - - console.log( - `Dyno states: ${JSON.stringify(newDynos.map((dyno) => dyno.state))} (after ${Math.round( - (Date.now() - dynoBootStartTime) / 1000 - )} seconds)` - ) - } catch (error) { - // Allow for a few bad responses from the Heroku API - if (error.statusCode === 404 || error.statusCode === 429) { - dynoAcceptableErrorCount += 1 - if (dynoAcceptableErrorCount <= ALLOWED_MISSING_RESPONSE_COUNT) { - continue - } - } - throw new Error(`Failed to find dynos for this release. Error: ${error}`) - } - } - - const crashedDynos = newDynos.filter((dyno) => ['crashed', 'restarting'].includes(dyno.state)) - const runningDynos = newDynos.filter((dyno) => dyno.state === 'up') - - // If any dynos crashed on start-up, fail the deployment - if (crashedDynos.length > 0) { - const errorMessage = `At least ${crashedDynos.length} Heroku dyno(s) crashed on start-up!` - - console.error(errorMessage) - - // Attempt to dump some of the Heroku log here for debugging - try { - const logSession = await heroku.post(`/apps/${appName}/log-sessions`, { - body: { - dyno: crashedDynos[0].name, - lines: HEROKU_LOG_LINES_TO_SHOW, - tail: false, - }, - }) - - logUrl = logSession.logplex_url - - const logText = await got(logUrl).text() - console.error( - `Here are the last ${HEROKU_LOG_LINES_TO_SHOW} lines of the Heroku log:\n\n${logText}` - ) - } catch (error) { - // Don't fail because of this error - console.error(`Failed to retrieve the Heroku logs for the crashed dynos. Error: ${error}`) - } - - throw new Error(errorMessage) - } - - console.log( - `At least ${runningDynos.length} Heroku dyno(s) are ready after ${Math.round( - (Date.now() - dynoBootStartTime) / 1000 - )} seconds.` - ) - - // Send a series of requests to trigger the server warmup routines - console.log('🚀 Deployment status: in_progress - Triggering server warmup routines...') - - const warmupStartTime = Date.now() - console.log(`Making warmup requests to: ${homepageUrl}`) - try { - await got(homepageUrl, { - timeout: 10000, // Maximum 10 second timeout per request - retry: { - limit: 7, // About 2 minutes 7 seconds of delay, plus active request time for 8 requests - statusCodes: [404].concat(got.defaults.options.retry.statusCodes), // 404 is extra - }, - hooks: { - beforeRetry: [ - (options, error = {}, retryCount = '?') => { - const statusCode = error.statusCode || (error.response || {}).statusCode || -1 - console.log( - `Retrying after warmup request attempt #${retryCount} (${statusCode}) after ${Math.round( - (Date.now() - warmupStartTime) / 1000 - )} seconds...` - ) - }, - ], - }, - }) - console.log( - `Warmup requests passed after ${Math.round((Date.now() - warmupStartTime) / 1000)} seconds` - ) - } catch (error) { - throw new Error( - `Warmup requests failed after ${Math.round( - (Date.now() - warmupStartTime) / 1000 - )} seconds. Error: ${error}` - ) - } - - // Report success! - const successMessage = `Deployment succeeded after ${Math.round( - (Date.now() - startTime) / 1000 - )} seconds.` - console.log(successMessage) - - await octokit.repos.createDeploymentStatus({ - owner, - repo, - deployment_id: deploymentId, - state: 'success', - description: successMessage, - ...(logUrl && { log_url: logUrl }), - environment_url: homepageUrl, - // 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: success - ${successMessage}`) - console.log(`Visit the newly deployed app at: ${homepageUrl}`) - } catch (error) { - // Report failure! - const failureMessage = `Deployment failed after ${Math.round( - (Date.now() - startTime) / 1000 - )} seconds. See logs for more information.` - console.error(failureMessage) - - try { - if (deploymentId) { - await octokit.repos.createDeploymentStatus({ - owner, - repo, - deployment_id: deploymentId, - state: 'error', - description: failureMessage, - ...(logUrl && { log_url: logUrl }), - environment_url: homepageUrl, - // 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 - ${failureMessage}` + (logUrl ? ` Logs: ${logUrl}` : '') - ) - } - } catch (error) { - console.error(`Failed to finalize GitHub DeploymentStatus as a failure. Error: ${error}`) - } - - // Re-throw the error to bubble up - throw error - } -} - -async function getTarballUrl({ octokit, owner, repo, sha }) { - // Get a URL for the tarballed source code bundle - const { - headers: { location: tarballUrl }, - } = await octokit.repos.downloadTarballArchive({ - owner, - repo, - ref: sha, - // Override the underlying `node-fetch` module's `redirect` option - // configuration to prevent automatically following redirects. - request: { - redirect: 'manual', - }, - }) - return tarballUrl -}