diff --git a/.buildkite/assets/404-report.html b/.buildkite/assets/404-report.html new file mode 100644 index 0000000000..c0de80fc0b --- /dev/null +++ b/.buildkite/assets/404-report.html @@ -0,0 +1,212 @@ + + + + + + E2E Report - pending + + + +

404 Error | Page not found

+

E2E Report still pending...

+
+ 4 + 0 + 4 +
+ + + diff --git a/.buildkite/pipelines/pull_request/pipeline.ts b/.buildkite/pipelines/pull_request/pipeline.ts index c9d21b873f..5cb77e9d59 100644 --- a/.buildkite/pipelines/pull_request/pipeline.ts +++ b/.buildkite/pipelines/pull_request/pipeline.ts @@ -22,18 +22,12 @@ import { prettierStep, storybookStep, typeCheckStep, + firebasePreDeployStep, } from '../../steps'; -import { - bkEnv, - ChangeContext, - createDeployment, - uploadPipeline, - createDeploymentStatus, - Step, - CustomCommandStep, -} from '../../utils'; +import { bkEnv, ChangeContext, uploadPipeline, Step, CustomCommandStep } from '../../utils'; import { getBuildConfig } from '../../utils/build'; import { MetaDataKeys } from '../../utils/constants'; +import { createDeployment, createDeploymentStatus, createOrUpdateDeploymentComment } from '../../utils/deployment'; void (async () => { try { @@ -61,6 +55,7 @@ void (async () => { typeCheckStep(), storybookStep(), e2eServerStep(), + firebasePreDeployStep(), ghpDeployStep(), playwrightStep(), firebaseDeployStep(), @@ -93,8 +88,12 @@ void (async () => { const skipDeployStep = (steps.find(({ key }) => key === 'deploy_fb') as CustomCommandStep)?.skip ?? false; await setMetadata(MetaDataKeys.skipDeployment, skipDeployStep ? 'true' : 'false'); if (!skipDeployStep) { - await createDeployment(); - await createDeploymentStatus({ state: 'queued' }); + if (bkEnv.isPullRequest) { + await createOrUpdateDeploymentComment({ state: 'pending' }); + } else { + await createDeployment(); + await createDeploymentStatus({ state: 'queued' }); + } } pipeline.steps = steps; diff --git a/.buildkite/scripts/steps/e2e_server.ts b/.buildkite/scripts/steps/e2e_server.ts index 01201a5d61..4c958d68fe 100644 --- a/.buildkite/scripts/steps/e2e_server.ts +++ b/.buildkite/scripts/steps/e2e_server.ts @@ -6,12 +6,19 @@ * Side Public License, v 1. */ -import { createDeploymentStatus, exec, yarnInstall, compress, startGroup } from '../../utils'; +import { exec, yarnInstall, compress, startGroup, bkEnv } from '../../utils'; +import { createDeploymentStatus, createOrUpdateDeploymentComment } from '../../utils/deployment'; void (async () => { await yarnInstall(); - await createDeploymentStatus({ state: 'pending' }); + if (bkEnv.isPullRequest) { + await createOrUpdateDeploymentComment({ + state: 'pending', + }); + } else { + await createDeploymentStatus({ state: 'pending' }); + } startGroup('Generating e2e server files'); await exec('yarn test:e2e:generate'); diff --git a/.buildkite/scripts/steps/firebase_deploy.ts b/.buildkite/scripts/steps/firebase_deploy.ts index 565cc8ae30..a815951dc5 100644 --- a/.buildkite/scripts/steps/firebase_deploy.ts +++ b/.buildkite/scripts/steps/firebase_deploy.ts @@ -9,10 +9,13 @@ import fs from 'fs'; import path from 'path'; -import { createDeploymentStatus, firebaseDeploy, downloadArtifacts, startGroup, decompress } from '../../utils'; +import { firebaseDeploy, downloadArtifacts, startGroup, decompress, bkEnv } from '../../utils'; +import { createDeploymentStatus } from '../../utils/deployment'; void (async () => { - await createDeploymentStatus({ state: 'in_progress' }); + if (!bkEnv.isPullRequest) { + await createDeploymentStatus({ state: 'in_progress' }); + } const outDir = 'e2e_server/public'; @@ -38,6 +41,7 @@ void (async () => { }); startGroup('Check deployment files'); + const hasStorybookIndex = fs.existsSync('./e2e_server/public/index.html'); const hasE2EIndex = fs.existsSync('./e2e_server/public/e2e/index.html'); const hasE2EReportIndex = fs.existsSync('./e2e_server/public/e2e-report/index.html'); diff --git a/.buildkite/scripts/steps/firebase_pre_deploy.ts b/.buildkite/scripts/steps/firebase_pre_deploy.ts new file mode 100644 index 0000000000..269fe6298b --- /dev/null +++ b/.buildkite/scripts/steps/firebase_pre_deploy.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import fs from 'fs'; +import path from 'path'; + +import { firebaseDeploy, downloadArtifacts, startGroup, decompress, bkEnv } from '../../utils'; +import { createDeploymentStatus, createOrUpdateDeploymentComment } from '../../utils/deployment'; + +void (async () => { + if (bkEnv.isPullRequest) { + await createOrUpdateDeploymentComment({ + state: 'pending', + preDeploy: true, + }); + } else { + await createDeploymentStatus({ state: 'in_progress' }); + } + + const outDir = 'e2e_server/public'; + + const storybookSrc = '.buildkite/artifacts/storybook.gz'; + await downloadArtifacts(storybookSrc, 'build_storybook'); + await decompress({ + src: storybookSrc, + dest: outDir, + }); + + const e2eSrc = '.buildkite/artifacts/e2e_server.gz'; + await downloadArtifacts(e2eSrc, 'build_e2e'); + await decompress({ + src: e2eSrc, + dest: path.join(outDir, 'e2e'), + }); + + startGroup('Check deployment files'); + + const hasStorybookIndex = fs.existsSync('./e2e_server/public/index.html'); + const hasE2EIndex = fs.existsSync('./e2e_server/public/e2e/index.html'); + const missingFiles = [ + ['storybook', hasStorybookIndex], + ['e2e server', hasE2EIndex], + ] + .filter(([, exists]) => !exists) + .map(([f]) => f as string); + + if (missingFiles.length > 0) { + throw new Error(`Error: Missing deployment files: [${missingFiles.join(', ')}]`); + } + + // Move 404 file to /e2e-report + fs.mkdirSync('./e2e_server/public/e2e-report'); + fs.renameSync('./.buildkite/assets/404-report.html', './e2e_server/public/e2e-report/index.html'); + + await firebaseDeploy({ + preDeploy: true, + }); +})(); diff --git a/.buildkite/scripts/steps/storybook.ts b/.buildkite/scripts/steps/storybook.ts index 6c8fffc1b2..86350b9fb2 100644 --- a/.buildkite/scripts/steps/storybook.ts +++ b/.buildkite/scripts/steps/storybook.ts @@ -6,12 +6,19 @@ * Side Public License, v 1. */ -import { bkEnv, compress, createDeploymentStatus, exec, startGroup, yarnInstall } from '../../utils'; +import { bkEnv, compress, exec, startGroup, yarnInstall } from '../../utils'; +import { createDeploymentStatus, createOrUpdateDeploymentComment } from '../../utils/deployment'; void (async () => { await yarnInstall(); - await createDeploymentStatus({ state: 'pending' }); + if (bkEnv.isPullRequest) { + await createOrUpdateDeploymentComment({ + state: 'pending', + }); + } else { + await createDeploymentStatus({ state: 'pending' }); + } startGroup('Building storybook'); await exec('yarn build', { diff --git a/.buildkite/steps/firebase_pre_deploy.ts b/.buildkite/steps/firebase_pre_deploy.ts new file mode 100644 index 0000000000..2bb4552ca4 --- /dev/null +++ b/.buildkite/steps/firebase_pre_deploy.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createStep, CustomCommandStep, commandStepDefaults } from '../utils'; + +export const firebasePreDeployStep = createStep(() => { + return { + ...commandStepDefaults, + label: ':firebase: Pre Deploy - firebase', + key: 'pre_deploy_fb', + allow_dependency_failure: false, + depends_on: ['build_storybook', 'build_e2e'], + commands: ['npx ts-node .buildkite/scripts/steps/firebase_pre_deploy.ts'], + env: { + ECH_CHECK_ID: 'pre_deploy_fb', + }, + }; +}); diff --git a/.buildkite/steps/index.ts b/.buildkite/steps/index.ts index 3589b2d4cc..9b1d10fb18 100644 --- a/.buildkite/steps/index.ts +++ b/.buildkite/steps/index.ts @@ -14,5 +14,6 @@ export * from './prettier'; export * from './playwright'; export * from './storybook'; export * from './e2e_server'; +export * from './firebase_pre_deploy'; export * from './firebase_deploy'; export * from './ghp_deploy'; diff --git a/.buildkite/utils/build.ts b/.buildkite/utils/build.ts index ccc1a31257..53b24afceb 100644 --- a/.buildkite/utils/build.ts +++ b/.buildkite/utils/build.ts @@ -31,6 +31,7 @@ export const getBuildConfig = (): BuildConfig => { { name: 'API', id: 'api' }, { name: 'Build - e2e server', id: 'build_e2e' }, { name: 'Build - Storybook', id: 'build_storybook' }, + { name: 'Pre Deploy - firebase', id: 'pre_deploy_fb' }, { name: 'Eslint', id: 'eslint' }, { name: 'Prettier', id: 'prettier' }, { name: 'Deploy - firebase', id: 'deploy_fb' }, diff --git a/.buildkite/utils/buildkite.ts b/.buildkite/utils/buildkite.ts index 0bfa28332a..189e790cd6 100644 --- a/.buildkite/utils/buildkite.ts +++ b/.buildkite/utils/buildkite.ts @@ -158,13 +158,68 @@ interface JobStep { key: string; } +interface PreviousDeployCommitShaResponse { + data: { + pipeline: { + builds: { + edges: [ + { + node: { + commit: string; + jobs: { + edges: [ + { + node: { + passed: boolean; + }; + }, + ]; + }; + }; + }, + ]; + }; + }; + }; +} + +/** + * Returns previously successful firebase deploy commit sha for the current branch + */ +export async function getPreviousDeployCommitSha(): Promise { + const { data } = await buildkiteGQLQuery(`query getPreviousDeployCommitSha { + pipeline(slug: "${bkEnv.organizationSlug}/${bkEnv.pipelineSlug}") { + builds(first: 1, branch: "${bkEnv.branch}", state: PASSED) { + edges { + node { + commit + jobs(first: 1, step: { key: "deploy_fb" }, state: FINISHED) { + edges { + node { + ... on JobTypeCommand { + passed + } + } + } + } + } + } + } + } +}`); + + const { commit, jobs } = data?.pipeline?.builds?.edges?.[0]?.node ?? {}; + + return jobs?.edges?.[0]?.node?.passed ? commit : null; +} + export async function getBuildJobs(stepKey?: string): Promise { const buildId = bkEnv.buildId; if (!buildId) throw new Error(`Error: no job id found [${buildId}]`); const jobQuery = stepKey ? `first: 100, step: { key: "${stepKey}" }` : 'first: 100'; - const { data } = await buildkiteGQLQuery(`query getBuildJobs { + const { data } = await buildkiteGQLQuery(`query getBuildJobs { build(uuid: "${buildId}") { jobs(${jobQuery}) { edges { @@ -194,7 +249,7 @@ export async function getBuildJobs(stepKey?: string): Promise { ); } -interface BuildJobsReponse { +interface BuildJobsResponse { data: { build: { jobs: { diff --git a/.buildkite/utils/constants.ts b/.buildkite/utils/constants.ts index 436576f578..032a356988 100644 --- a/.buildkite/utils/constants.ts +++ b/.buildkite/utils/constants.ts @@ -12,6 +12,9 @@ export const MetaDataKeys = { skipDeployment: 'skipDeployment', deploymentId: 'deploymentId', + deploymentCommentId: 'deploymentCommentId', + deploymentPreviousSha: 'deploymentPreviousSha', + deploymentStatus: 'deploymentStatus', deploymentUrl: 'deploymentUrl', }; diff --git a/.buildkite/utils/deployment.ts b/.buildkite/utils/deployment.ts new file mode 100644 index 0000000000..46e242c74f --- /dev/null +++ b/.buildkite/utils/deployment.ts @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RestEndpointMethodTypes } from '@octokit/rest'; +import { getMetadata, setMetadata, metadataExists } from 'buildkite-agent-node'; +import { Optional } from 'utility-types'; + +import { bkEnv, getPreviousDeployCommitSha } from './buildkite'; +import { getNumber } from './common'; +import { MetaDataKeys } from './constants'; +import { getDeploymentUrl } from './firebase'; +import { octokit, defaultGHOptions, getComment, commentByKey } from './github'; +import { OctokitParameters } from './types'; + +export interface UpdateDeploymentCommentOptions { + sha?: string; + state: 'pending' | 'success' | 'failure'; + deploymentUrl?: string; + previousSha?: string; + errorCmd?: string; + errorMsg?: string; + jobLink?: string; + preDeploy?: boolean; +} + +export async function createOrUpdateDeploymentComment(options: UpdateDeploymentCommentOptions) { + const { state, preDeploy, sha = bkEnv.commit! } = options; + const skipDeployment = await getMetadata(MetaDataKeys.skipDeployment); + console.log('MetaDataKeys.skipDeployment', skipDeployment); + const deploymentStatus = await getMetadata(MetaDataKeys.deploymentStatus); + const status = preDeploy ? `${state}-preDeploy` : state; + + if (process.env.BLOCK_REQUESTS || !bkEnv.isPullRequest || skipDeployment === 'true' || deploymentStatus === status) + return; + + await setMetadata(MetaDataKeys.deploymentStatus, status); + + const previousCommentId = await getMetadata(MetaDataKeys.deploymentCommentId); + + if (state === 'pending' && !previousCommentId) { + // initial cleanup - delete previous comments + const { data: botComments } = await octokit.issues.listComments({ + ...defaultGHOptions, + issue_number: bkEnv.pullRequestNumber!, + }); + const deployComments = botComments.filter(commentByKey('deployment')); + await Promise.all( + deployComments.map(async ({ id }) => { + await octokit.issues.deleteComment({ + ...defaultGHOptions, + comment_id: id, + }); + }), + ); + const previousSha = await getPreviousDeployCommitSha(); + if (previousSha) { + await setMetadata(MetaDataKeys.deploymentPreviousSha, previousSha); + } + } + + if (previousCommentId) { + await octokit.issues.deleteComment({ + ...defaultGHOptions, + comment_id: Number(previousCommentId), + }); + } + + const deploymentUrl = options.deploymentUrl ?? (await getDeploymentUrl()); + const previousSha = options.previousSha ?? (await getMetadata(MetaDataKeys.deploymentPreviousSha)); + const commentBody = getComment('deployment', { ...options, state, preDeploy, sha, deploymentUrl, previousSha }); + + const { + data: { id }, + } = await octokit.issues.createComment({ + ...defaultGHOptions, + issue_number: bkEnv.pullRequestNumber!, + body: commentBody, + }); + + await setMetadata(MetaDataKeys.deploymentCommentId, id.toString()); +} + +/** + * Must clear previous deployments when deployment `transient_environment` is t`rue` + */ +export async function updatePreviousDeployments( + state: RestEndpointMethodTypes['repos']['createDeploymentStatus']['parameters']['state'] = 'inactive', +) { + const currentDeploymentId = getNumber(await getMetadata(MetaDataKeys.deploymentId)); + const { data: deployments } = await octokit.repos.listDeployments({ + ...defaultGHOptions, + task: getDeploymentTask(), + per_page: 100, // should never get this high + }); + + await Promise.all( + deployments.map(async ({ id }) => { + if (id === currentDeploymentId) return; + const { + data: [{ state: currentState, log_url, environment_url }], + } = await octokit.repos.listDeploymentStatuses({ + ...defaultGHOptions, + deployment_id: id, + per_page: 1, + }); + + if (!['in_progress', 'queued', 'pending', 'success'].includes(currentState)) { + return; + } + + console.log(`Updating deployment ${id} state: ${currentState} -> ${state}`); + + await octokit.repos.createDeploymentStatus({ + ...defaultGHOptions, + deployment_id: id, + description: 'This deployment precedes a newer deployment', + log_url, + environment_url, + state, + }); + }), + ); +} + +export const createDeploymentStatus = async ( + options: Optional> = {}, +) => { + if (process.env.BLOCK_REQUESTS) return; + + console.trace('createDeploymentStatus', options.state); + + console.log('MetaDataKeys.skipDeployment', await getMetadata(MetaDataKeys.skipDeployment)); + + if ((await getMetadata(MetaDataKeys.skipDeployment)) === 'true') return; + + const deployment_id = options.deployment_id ?? getNumber(await getMetadata(MetaDataKeys.deploymentId)); + + if (deployment_id === null) throw new Error(`Failed to set status, no deployment found`); + + try { + await octokit.rest.repos.createDeploymentStatus({ + ...defaultGHOptions, + state: 'success', + log_url: bkEnv.jobUrl, + ...options, + deployment_id, + }); + } catch { + // Should not throw as this would be an issue with CI + console.error(`Failed to create deployment status for deployment [${deployment_id}]`); + } +}; + +function getDeploymentTask() { + return bkEnv.isPullRequest ? `deploy:pr:${bkEnv.pullRequestNumber}` : `deploy:${bkEnv.branch}`; +} + +export const createDeployment = async ( + options: Optional> = {}, +): Promise => { + if (process.env.BLOCK_REQUESTS) return null; + if (await metadataExists(MetaDataKeys.deploymentId)) { + console.warn(`Deployment already exists for build`); + } + + const ref = bkEnv.commit; + + if (!ref) throw new Error(`Failed to set status, no ref available`); + + try { + const response = await octokit.repos.createDeployment({ + ...defaultGHOptions, + environment: bkEnv.isPullRequest ? 'pull-requests' : bkEnv.branch, + transient_environment: bkEnv.isPullRequest, // sets previous statuses to inactive + production_environment: false, + task: getDeploymentTask(), + ...options, + auto_merge: false, // use branch as is without merging with base + required_contexts: [], + ref, + }); + if (response.status === 202) return null; // Merged branch response + + await setMetadata(MetaDataKeys.deploymentId, String(response.data.id)); + return response.data.id; + } catch (error) { + console.error(`Failed to create deployment for ref [${ref}]`); + throw error; + } +}; diff --git a/.buildkite/utils/exec.ts b/.buildkite/utils/exec.ts index 120c7fd154..4a4b9072a3 100644 --- a/.buildkite/utils/exec.ts +++ b/.buildkite/utils/exec.ts @@ -17,7 +17,7 @@ interface ExecOptions extends ExecSyncOptionsWithBufferEncoding { args?: string; input?: string; failureMsg?: string; - onFailure?: () => Promise | void; + onFailure?: (err: { command: string; message: string }) => Promise | void; onSuccess?: () => Promise | void; cwd?: string; /** @@ -32,6 +32,7 @@ interface ExecOptions extends ExecSyncOptionsWithBufferEncoding { * Logic to run before next retry */ onRetry?: () => void | Promise; + allowFailure?: boolean; } export const wait = (n: number) => new Promise((resolve) => setTimeout(resolve, n)); @@ -54,6 +55,7 @@ export const exec = async ( retry = 0, retryWait = 0, onRetry, + allowFailure = false, }: ExecOptions = {}, ): Promise => { let retryCount = 0; @@ -81,9 +83,16 @@ export const exec = async ( if (retryWait) await wait(retryWait * 1000); return await execInner(); } + + if (allowFailure) { + throw error; // still need to catch + } + + const errorMsg = getErrorMsg(error); console.error(`❌ Failed to run command: [${command}]`); + await setJobMetadata('failed', 'true'); - await onFailure?.(); + await onFailure?.({ command, message: errorMsg }); await updateCheckStatus( { status: 'completed', @@ -104,3 +113,15 @@ export const yarnInstall = async (cwd?: string, ignoreScripts = true) => { const scriptFlag = ignoreScripts ? ' --ignore-scripts' : ''; await exec(`yarn install --frozen-lockfile${scriptFlag}`, { cwd, retry: 5, retryWait: 15 }); }; + +function getErrorMsg(error: unknown): string { + const output: Array = (error as any)?.output ?? []; + if (output?.length > 0) { + return output + .filter(Boolean) + .map((s) => s?.trim()) + .join('\n\n'); + } + + return error instanceof Error ? error.message : typeof error === 'string' ? error : ''; +} diff --git a/.buildkite/utils/firebase.ts b/.buildkite/utils/firebase.ts index 025b3a2097..7c32d59cab 100644 --- a/.buildkite/utils/firebase.ts +++ b/.buildkite/utils/firebase.ts @@ -13,39 +13,33 @@ import { fileSync } from 'tmp'; import { bkEnv, startGroup } from './buildkite'; import { DEFAULT_FIREBASE_URL, MetaDataKeys } from './constants'; +import { createDeploymentStatus, createOrUpdateDeploymentComment } from './deployment'; import { exec } from './exec'; -import { - octokit, - commentByKey, - createDeploymentStatus, - updatePreviousDeployments, - defaultGHOptions, - getComment, -} from './github'; // Set up Google Application Credentials for use by the Firebase CLI // https://cloud.google.com/docs/authentication/production#finding_credentials_automatically function createGACFile() { - const tmpFile = fileSync({ postfix: '.json' }); const gac = process.env.FIREBASE_AUTH; if (!gac) throw new Error('Error: unable to find FIREBASE_AUTH'); + const tmpFile = fileSync({ postfix: '.json' }); writeSync(tmpFile.fd, gac); - return tmpFile.name; } interface DeployOptions { expires?: string; - redeploy?: boolean; + preDeploy?: boolean; } +const getChannelId = () => + bkEnv.isPullRequest ? `pr-${bkEnv.pullRequestNumber!}` : bkEnv.isMainBranch ? null : bkEnv.branch; + export const firebaseDeploy = async (opt: DeployOptions = {}) => { - const expires = opt.expires ?? '7d'; + const expires = opt.expires ?? '30d'; startGroup('Deploying to firebase'); - const channelId = bkEnv.isPullRequest ? `pr-${bkEnv.pullRequestNumber!}` : bkEnv.isMainBranch ? null : bkEnv.branch; - + const channelId = getChannelId(); const gacFile = createGACFile(); const command = channelId ? `npx firebase-tools hosting:channel:deploy ${channelId} --expires ${expires} --no-authorized-domains --json` @@ -57,10 +51,19 @@ export const firebaseDeploy = async (opt: DeployOptions = {}) => { ...process.env, GOOGLE_APPLICATION_CREDENTIALS: gacFile, }, - async onFailure() { - await createDeploymentStatus({ - state: 'failure', - }); + async onFailure(err) { + if (bkEnv.isPullRequest) { + await createOrUpdateDeploymentComment({ + state: 'failure', + jobLink: bkEnv.jobUrl, + errorCmd: err.command, + errorMsg: err.message, + }); + } else { + await createDeploymentStatus({ + state: 'failure', + }); + } }, }); @@ -74,38 +77,53 @@ export const firebaseDeploy = async (opt: DeployOptions = {}) => { await setMetadata(MetaDataKeys.deploymentUrl, deploymentUrl); if (bkEnv.isPullRequest) { - // deactivate old deployments - await updatePreviousDeployments(); - - const { data: botComments } = await octokit.issues.listComments({ - ...defaultGHOptions, - issue_number: bkEnv.pullRequestNumber!, + await createOrUpdateDeploymentComment({ + state: 'success', + preDeploy: opt.preDeploy, + deploymentUrl, }); - const deployComments = botComments.filter((c) => commentByKey('deployments')(c.body)); - await Promise.all( - deployComments.map(async ({ id }) => { - await octokit.issues.deleteComment({ - ...defaultGHOptions, - comment_id: id, - }); - }), - ); - await octokit.issues.createComment({ - ...defaultGHOptions, - issue_number: bkEnv.pullRequestNumber!, - body: getComment('deployments', deploymentUrl, bkEnv.commit!), + } else { + await createDeploymentStatus({ + state: 'success', + environment_url: deploymentUrl, }); } - await createDeploymentStatus({ - state: 'success', - environment_url: deploymentUrl, - }); return deploymentUrl; } else { throw new Error(`Error: Firebase deployment resulted in ${status}`); } }; +interface ChannelDeploymentInfo { + status: string; + result?: { + url?: string; + }; +} + +/** + * Returns deployment id for given PR channel + */ +export async function getDeploymentUrl() { + try { + const channelId = getChannelId(); + const gacFile = createGACFile(); + const deploymentJson = await exec(`npx firebase-tools hosting:channel:open ${channelId} --json`, { + cwd: './e2e_server', + stdio: 'pipe', + allowFailure: true, + env: { + ...process.env, + GOOGLE_APPLICATION_CREDENTIALS: gacFile, + }, + }); + const info = JSON.parse(deploymentJson) as ChannelDeploymentInfo; + return info?.result?.url || undefined; + } catch { + return undefined; + } +} + interface DeployResult { status: string; result: diff --git a/.buildkite/utils/github.ts b/.buildkite/utils/github.ts index 8c21073d89..2b6ef6fad9 100644 --- a/.buildkite/utils/github.ts +++ b/.buildkite/utils/github.ts @@ -9,18 +9,16 @@ import { createAppAuth, StrategyOptions } from '@octokit/auth-app'; import { components } from '@octokit/openapi-types'; import { retry } from '@octokit/plugin-retry'; -import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; -import { getMetadata, metadataExists, setMetadata } from 'buildkite-agent-node'; +import { Octokit } from '@octokit/rest'; +import { getMetadata } from 'buildkite-agent-node'; import ghpages from 'gh-pages'; import minimatch, { IOptions as MinimatchOptions } from 'minimatch'; import { Optional } from 'utility-types'; import { getJobCheckName } from './build'; import { bkEnv } from './buildkite'; -import { getNumber } from './common'; -import { MetaDataKeys } from './constants'; +import { UpdateDeploymentCommentOptions } from './deployment'; import { CheckStatusOptions, CreateCheckOptions } from './octokit'; -import { OctokitParameters } from './types'; if (!process.env.GITHUB_AUTH) throw new Error('GITHUB_AUTH env variable must be defined'); @@ -274,72 +272,6 @@ export const updateCheckStatus = async ( } }; -export const getDeploymentTask = () => - bkEnv.isPullRequest ? `deploy:pr:${bkEnv.pullRequestNumber}` : `deploy:${bkEnv.branch}`; - -export const createDeployment = async ( - options: Optional> = {}, -): Promise => { - if (process.env.BLOCK_REQUESTS) return null; - if (await metadataExists(MetaDataKeys.deploymentId)) { - console.warn(`Deployment already exists for build`); - } - - const ref = bkEnv.commit; - - if (!ref) throw new Error(`Failed to set status, no ref available`); - - try { - const response = await octokit.repos.createDeployment({ - ...defaultGHOptions, - environment: bkEnv.isPullRequest ? 'pull-requests' : bkEnv.branch, - transient_environment: bkEnv.isPullRequest, // sets previous statuses to inactive - production_environment: false, - task: getDeploymentTask(), - ...options, - auto_merge: false, // use branch as is without merging with base - required_contexts: [], - ref, - }); - if (response.status === 202) return null; // Merged branch response - - await setMetadata(MetaDataKeys.deploymentId, String(response.data.id)); - return response.data.id; - } catch (error) { - console.error(`Failed to create deployment for ref [${ref}]`); - throw error; - } -}; - -export const createDeploymentStatus = async ( - options: Optional> = {}, -) => { - if (process.env.BLOCK_REQUESTS) return; - - console.trace('createDeploymentStatus', options.state); - - console.log('MetaDataKeys.skipDeployment', await getMetadata(MetaDataKeys.skipDeployment)); - - if ((await getMetadata(MetaDataKeys.skipDeployment)) === 'true') return; - - const deployment_id = options.deployment_id ?? getNumber(await getMetadata(MetaDataKeys.deploymentId)); - - if (deployment_id === null) throw new Error(`Failed to set status, no deployment found`); - - try { - await octokit.rest.repos.createDeploymentStatus({ - ...defaultGHOptions, - state: 'success', - log_url: bkEnv.jobUrl, - ...options, - deployment_id, - }); - } catch { - // Should not throw as this would be an issue with CI - console.error(`Failed to create deployment status for deployment [${deployment_id}]`); - } -}; - interface GLQPullRequestFiles { repository: { pullRequest: { @@ -359,45 +291,6 @@ interface GLQPullRequestFiles { }; } -export async function updatePreviousDeployments( - state: RestEndpointMethodTypes['repos']['createDeploymentStatus']['parameters']['state'] = 'inactive', -) { - const currentDeploymentId = getNumber(await getMetadata(MetaDataKeys.deploymentId)); - const { data: deployments } = await octokit.repos.listDeployments({ - ...defaultGHOptions, - task: getDeploymentTask(), - per_page: 100, // should never get this high - }); - - await Promise.all( - deployments.map(async ({ id }) => { - if (id === currentDeploymentId) return; - const { - data: [{ state: currentState, log_url, environment_url }], - } = await octokit.repos.listDeploymentStatuses({ - ...defaultGHOptions, - deployment_id: id, - per_page: 1, - }); - - if (!['in_progress', 'queued', 'pending', 'success'].includes(currentState)) { - return; - } - - console.log(`Updating deployment ${id} state: ${currentState} -> ${state}`); - - await octokit.repos.createDeploymentStatus({ - ...defaultGHOptions, - deployment_id: id, - description: 'This deployment precedes a newer deployment', - log_url, - environment_url, - state, - }); - }), - ); -} - export async function getFileDiffs(): Promise { if (!bkEnv.isPullRequest) return []; @@ -476,7 +369,8 @@ function generateMsg(key: string, body: string): string { const reMsgKey = /^/; export function commentByKey(key: T) { - return (comment?: string): boolean => { + return (commentRaw?: string | { body?: string }): boolean => { + const comment = typeof commentRaw === 'string' ? commentRaw : commentRaw?.body; if (!comment) return false; const [, commentKey] = reMsgKey.exec(comment) ?? []; return commentKey === key; @@ -487,12 +381,60 @@ export const comments = { communityPR() { return `Community pull request, @elastic/datavis please add the \`ci:approved ✅\` label to allow this and future builds.`; }, - deployments(deploymentUrl: string, sha: string) { - return `## Deployments - ${sha} + deployment({ + deploymentUrl, + sha, + previousSha, + state, + errorCmd, + errorMsg, + jobLink, + preDeploy = false, + }: UpdateDeploymentCommentOptions) { + console.log(`DEPLOYMENT STATUS - ${state} - preDeploy: ${preDeploy}`); + + if (state === 'failure') { + const errorCmdMsg = errorCmd + ? `\n\n**Command failed:** +\`\`\` +${errorCmd} +\`\`\`\n\n` + : '\n'; + const err = errorMsg + ? `${errorCmdMsg} +**Error:** + +\`\`\` +${errorMsg} +\`\`\`` + : errorCmdMsg; + const finalMessage = `## ❌ Failed Deployment - ${sha} +Failure${jobLink ? ` - [failed job](${jobLink})` : ''}${err} +`; + return `${finalMessage.trim()}\n\ncc: @nickofthyme`; + } + + if (state === 'pending') { + const updateComment = previousSha ? `\n> 🚧 Updating deployment from ${previousSha}` : ''; + const deploymentMsg = + previousSha && deploymentUrl + ? `### Old deployment - ${previousSha} +- [Storybook](${deploymentUrl}) +- [e2e server](${deploymentUrl}/e2e) +- ([Playwright report](${deploymentUrl}/e2e-report)` + : `- ⏳ Storybook +- ⏳ e2e server +- ⏳ Playwright report`; + return `## ⏳ Pending Deployment - ${sha}${updateComment} + +${deploymentMsg}`; + } + + return `## ✅ Successful ${preDeploy ? 'Preliminary ' : ''}Deployment - ${sha} -- Storybook ([link](${deploymentUrl})) -- e2e server ([link](${deploymentUrl}/e2e)) -- Playwright report ([link](${deploymentUrl}/e2e-report))`; +- [Storybook](${deploymentUrl}) +- [e2e server](${deploymentUrl}/e2e) +${preDeploy ? '- ⏳ Playwright report - Running e2e tests' : `- [Playwright report](${deploymentUrl}/e2e-report)`}`; }, }; diff --git a/.eslintignore b/.eslintignore index 6f4dc702cf..854bc5195a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,6 +8,7 @@ wiki **/node_modules coverage license_header.js +*.html # Editor configs .vscode diff --git a/github_bot/.firebaserc b/github_bot/.firebaserc new file mode 100644 index 0000000000..33aaa80e7c --- /dev/null +++ b/github_bot/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "ech-e2e-ci" + } +} diff --git a/github_bot/.nvmrc b/github_bot/.nvmrc index b6a7d89c68..6d80269a4f 100644 --- a/github_bot/.nvmrc +++ b/github_bot/.nvmrc @@ -1 +1 @@ -16 +18.16.0 diff --git a/github_bot/Dockerfile b/github_bot/Dockerfile index b09a3837b7..cfba4b6735 100644 --- a/github_bot/Dockerfile +++ b/github_bot/Dockerfile @@ -1,5 +1,6 @@ FROM node:16-alpine as builder WORKDIR /app +COPY firebase.json .firebaserc ./ COPY package.json yarn.lock ./ RUN yarn install --frozen-lockfile COPY tsconfig.main.json ./tsconfig.json @@ -9,6 +10,7 @@ RUN yarn build FROM node:16-alpine ENV NODE_ENV=production WORKDIR /app +COPY firebase.json .firebaserc ./ COPY package.json yarn.lock ./ RUN yarn install --frozen-lockfile COPY --from=builder /app/build ./build diff --git a/github_bot/firebase.json b/github_bot/firebase.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/github_bot/firebase.json @@ -0,0 +1 @@ +{} diff --git a/github_bot/src/build.ts b/github_bot/src/build.ts index 322705194c..e9b3a1c57b 100644 --- a/github_bot/src/build.ts +++ b/github_bot/src/build.ts @@ -28,6 +28,7 @@ export const getBuildConfig = (isMainBranch: boolean): BuildConfig => ({ { name: 'API', id: 'api' }, { name: 'Build - e2e server', id: 'build_e2e' }, { name: 'Build - Storybook', id: 'build_storybook' }, + { name: 'Pre Deploy - firebase', id: 'pre_deploy_fb' }, { name: 'Eslint', id: 'eslint' }, { name: 'Prettier', id: 'prettier' }, { name: 'Deploy - firebase', id: 'deploy_fb' }, diff --git a/github_bot/src/buildkite/finished_build.ts b/github_bot/src/buildkite/finished_build.ts index 0d73bfd069..602a729df6 100644 --- a/github_bot/src/buildkite/finished_build.ts +++ b/github_bot/src/buildkite/finished_build.ts @@ -26,8 +26,6 @@ interface MetaData { * This function runs to cleanup the commit statuses for all build statuses. */ export async function handleFinishedBuild(body: BuildkiteWebhookPayload, res: Response) { - console.log(body); - const build = body?.build; if (!build || !build?.commit) { res.sendStatus(400); // need sha to set statuses @@ -52,8 +50,6 @@ export async function handleFinishedBuild(body: BuildkiteWebhookPayload({ - branch: `${head.repo?.owner.login}:${head.ref}`, + context: ` - ${ctx.name} | ${ctx.payload.action}`, + branch: `${head.repo!.owner.login}:${head.ref}`, commit: head.sha, message: commit.commit.message, ignore_pipeline_branch_filters: true, diff --git a/github_bot/src/github/events/issue_comment/utils.ts b/github_bot/src/github/events/issue_comment/utils.ts index d72b15df86..da799de91a 100644 --- a/github_bot/src/github/events/issue_comment/utils.ts +++ b/github_bot/src/github/events/issue_comment/utils.ts @@ -55,11 +55,5 @@ export function hasCommentAction( const actionText = body.replace(ciNamePattern, ''); const foundActions = actionKeys.filter((k) => actions[k].test(actionText)); - console.log({ - body, - actionText, - actions: foundActions, - }); - return foundActions.length > 0; } diff --git a/github_bot/src/github/events/pull_request/cleanup.ts b/github_bot/src/github/events/pull_request/cleanup.ts index 22d3f32d28..f3519336b9 100644 --- a/github_bot/src/github/events/pull_request/cleanup.ts +++ b/github_bot/src/github/events/pull_request/cleanup.ts @@ -9,48 +9,46 @@ import { Probot } from 'probot'; import { buildkiteClient } from '../../../utils/buildkite'; +import { deleteDeployment } from '../../../utils/firebase'; import { commentByKey } from '../../../utils/github_utils/comments'; -import { updateLastDeployment } from '../../../utils/github_utils/deployments'; import { updateAllChecks } from '../../utils'; /** * Cleanups prs after closure. Includes: * * - cancels running buildkite builds - * - sets GitHub deployment to inactive - * - deletes deployment issue comment - * - * TODOs * - deletes firebase deployment (auto expires after 7 days) + * - deletes deployment issue comment */ export function cleanup(app: Probot) { + // @ts-ignore - probot issue https://github.com/probot/probot/issues/1680 app.on('pull_request.closed', async (ctx) => { console.log(`------- Triggered probot ${ctx.name} | ${ctx.payload.action}`); - const { head } = ctx.payload.pull_request; - - await buildkiteClient.cancelRunningBuilds(head.sha, async () => { - if (!ctx.payload.pull_request.merged) { - await updateAllChecks(ctx, { - status: 'completed', - conclusion: 'cancelled', - }); - } - }); - - await updateLastDeployment(ctx); - const { data: botComments } = await ctx.octokit.issues.listComments({ ...ctx.repo(), issue_number: ctx.payload.pull_request.number, }); - const deployComments = botComments.filter((c) => commentByKey('deployments')(c.body)); + const deployComments = botComments.filter((c) => commentByKey('deployment')(c.body)); for (const { id } of deployComments) { await ctx.octokit.issues.deleteComment({ ...ctx.repo(), comment_id: id, }); } + + deleteDeployment(ctx.payload.number); + + const { head } = ctx.payload.pull_request; + + await buildkiteClient.cancelRunningBuilds(head.sha, async () => { + if (!ctx.payload.pull_request.merged) { + await updateAllChecks(ctx, { + status: 'completed', + conclusion: 'cancelled', + }); + } + }); }); } diff --git a/github_bot/src/github/events/pull_request/trigger_build.ts b/github_bot/src/github/events/pull_request/trigger_build.ts index 6550aa3060..c71b2525ab 100644 --- a/github_bot/src/github/events/pull_request/trigger_build.ts +++ b/github_bot/src/github/events/pull_request/trigger_build.ts @@ -36,6 +36,7 @@ const prActionTriggers = new Set['action']>([ * build trigger for base and 3rd - party forked branches */ export function setupBuildTrigger(app: Probot) { + // @ts-ignore - TS complains here about ctx being too large of a union app.on('pull_request', async (ctx) => { if ( !prActionTriggers.has(ctx.payload.action) || @@ -108,7 +109,8 @@ export function setupBuildTrigger(app: Probot) { } const build = await buildkiteClient.triggerBuild({ - branch: head.label, // user:branch + context: ` - ${ctx.name} | ${ctx.payload.action}`, + branch: head.label, // : commit: head.sha, message: commit.commit.message, ignore_pipeline_branch_filters: true, diff --git a/github_bot/src/github/events/push/trigger_build.ts b/github_bot/src/github/events/push/trigger_build.ts index 2687c4cca7..7cc93c16b9 100644 --- a/github_bot/src/github/events/push/trigger_build.ts +++ b/github_bot/src/github/events/push/trigger_build.ts @@ -10,15 +10,16 @@ import { Probot } from 'probot'; import { getConfig } from '../../../config'; import { buildkiteClient } from '../../../utils/buildkite'; -import { checkCommitFn, isBaseRepo, testPatternString, updateAllChecks } from '../../utils'; +import { checkCommitFn, isBaseRepo, testPatternString, updateAllChecks, getBranchFromRef } from '../../utils'; /** - * build trigger for pushes to select base branches not pull requests + * Build trigger for pushes to select base branches, only on base repo not forks */ export function setupBuildTrigger(app: Probot) { // @ts-ignore - probot issue https://github.com/probot/probot/issues/1680 app.on('push', async (ctx) => { - const [branch] = ctx.payload.ref.split('/').reverse(); + console.log(`------- Triggered probot ${ctx.name}`); + const branch = getBranchFromRef(ctx.payload.ref); if ( !branch || @@ -44,7 +45,8 @@ export function setupBuildTrigger(app: Probot) { } const build = await buildkiteClient.triggerBuild({ - branch, + context: ` - ${ctx.name}`, + branch: `${ctx.payload.repository.owner.login}:${branch}`, commit: ctx.payload.after, message: ctx.payload.head_commit?.message, ignore_pipeline_branch_filters: true, diff --git a/github_bot/src/github/utils.ts b/github_bot/src/github/utils.ts index 748dfabb6b..29a6fc4ece 100644 --- a/github_bot/src/github/utils.ts +++ b/github_bot/src/github/utils.ts @@ -18,6 +18,11 @@ import { githubClient } from '../utils/github'; type GetPullResponseData = RestEndpointMethodTypes['pulls']['get']['response']['data']; +/** + * Strips away `refs/xxx` from `refs/xxx/` + */ +export const getBranchFromRef = (ref: string) => ref.replace(/^refs\/(.+?\/){0,1}/, ''); + export function isBaseRepo({ id, fork, @@ -93,8 +98,6 @@ export async function isValidUser(ctx: ProbotEventContext<'issue_comment' | 'pul ...ctx.repo(), username, }); - console.log({ permission }); - if (status === 200 && requiredPermission.has(permission)) { return true; } else { @@ -223,8 +226,6 @@ export async function updateAllChecks( throw new Error('Missing check runs, pagination required'); } - console.log(checkRuns); - for (const { id, external_id, details_url, status } of checkRuns) { if (status !== 'completed' || external_id === main.id) { await ctx.octokit.checks.update({ @@ -255,8 +256,6 @@ export async function updateAllChecks( } export async function syncChecks(ctx: ProbotEventContext<'pull_request'>) { - console.log('syncChecks'); - const [previousCommitSha] = await getLatestCommits(ctx); if (!previousCommitSha) throw new Error('Unable to load previous commit'); @@ -269,6 +268,7 @@ export async function syncChecks(ctx: ProbotEventContext<'pull_request'>) { ref: previousCommitSha, }); + console.log('syncChecks'); console.log(checks.map((c) => `${c.name}: status ${c.status}`)); await Promise.all( @@ -336,41 +336,6 @@ export async function getLatestCommits(ctx: ProbotEventContext<'pull_request'>, return response.repository.pullRequest.commits.nodes.map((n) => n.commit.oid); } -// TODO remove or use this function -export async function updatePreviousDeployments( - ctx: ProbotEventContext<'pull_request'>, - state: RestEndpointMethodTypes['repos']['createDeploymentStatus']['parameters']['state'] = 'inactive', -) { - const { data: deployments } = await ctx.octokit.repos.listDeployments({ - ...ctx.repo(), - ref: ctx.payload.pull_request.head.ref, - per_page: 100, - }); - - await Promise.all( - deployments.map(async ({ id }) => { - const { - data: [data], - } = await ctx.octokit.repos.listDeploymentStatuses({ - ...ctx.repo(), - deployment_id: id, - per_page: 1, - }); - - if (data && ['in_progress', 'queued', 'pending'].includes(data.state)) { - const { environment, ...status } = data; - await ctx.octokit.repos.createDeploymentStatus({ - ...ctx.repo(), - ...status, - // @ts-ignore - bad type for environment - environment, - state, - }); - } - }), - ); -} - export function pickDefined>(source: R): R { return Object.keys(source).reduce((acc, key) => { const val = source[key]; diff --git a/github_bot/src/utils/buildkite.ts b/github_bot/src/utils/buildkite.ts index 5f11c36ff6..76e1abd3c2 100644 --- a/github_bot/src/utils/buildkite.ts +++ b/github_bot/src/utils/buildkite.ts @@ -99,7 +99,8 @@ class Buildkite { // TODO build out separate pipelines to handle tasks on a run-as-needed basis const url = `organizations/elastic/pipelines/${this.pipelineSlug}/builds`; - console.log(`Triggering pipeline '${this.pipelineSlug}' against ${options.branch}...`); + console.log(`Trigger pipeline '${this.pipelineSlug}' against '${options.branch}'${options.context ?? ''}`); + console.log(JSON.stringify(options, null, 2)); try { const response = await this.http.post(url, options); diff --git a/github_bot/src/utils/firebase.ts b/github_bot/src/utils/firebase.ts new file mode 100644 index 0000000000..257c771c53 --- /dev/null +++ b/github_bot/src/utils/firebase.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { execSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +/** + * Deletes deployment of a given PR channel + */ +export function deleteDeployment(pr: number) { + try { + const channelId = `pr-${pr}`; + const gacFilePath = createGACFile(); + execSync(`npx firebase-tools hosting:channel:delete ${channelId} --force`, { + encoding: 'utf8', + stdio: 'pipe', + env: { + ...process.env, + GOOGLE_APPLICATION_CREDENTIALS: gacFilePath, + } as any, + }); + } catch (error) { + // Nothing to do, likely auto-deleted past expiry + console.error(error); + } +} + +// Set up Google Application Credentials for use by the Firebase CLI +// https://cloud.google.com/docs/authentication/production#finding_credentials_automatically +function createGACFile(): string { + const gac = process.env.FIREBASE_AUTH; + if (!gac) throw new Error('Error: unable to find FIREBASE_AUTH'); + + const tmpdir = os.tmpdir(); + const filePath = path.join(tmpdir, 'gac.json'); + + fs.writeFileSync(filePath, gac); + + return filePath; +} diff --git a/github_bot/src/utils/github_utils/comments.ts b/github_bot/src/utils/github_utils/comments.ts index 56b7a2e873..85fae2a4a3 100644 --- a/github_bot/src/utils/github_utils/comments.ts +++ b/github_bot/src/utils/github_utils/comments.ts @@ -23,12 +23,8 @@ export const comments = { communityPR() { return `Community pull request, @elastic/datavis please add the \`ci:approved ✅\` label to allow this and future builds.`; }, - deployments(deploymentUrl: string, sha: string) { - return `## Deployments - ${sha} - -- Storybook ([link](${deploymentUrl})) -- e2e server ([link](${deploymentUrl}/e2e)) -- Playwright report ([link](${deploymentUrl}/e2e-report))`; + deployment() { + return ''; // needed for type lookup }, }; diff --git a/github_bot/src/utils/github_utils/deployments.ts b/github_bot/src/utils/github_utils/deployments.ts deleted file mode 100644 index 35676db0a6..0000000000 --- a/github_bot/src/utils/github_utils/deployments.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { RestEndpointMethodTypes } from '@octokit/rest'; - -import { ProbotEventContext } from '../../github/types'; - -export async function updateLastDeployment( - ctx: ProbotEventContext<'pull_request'>, - state: RestEndpointMethodTypes['repos']['createDeploymentStatus']['parameters']['state'] = 'inactive', -) { - const { data: deployments = [] } = await ctx.octokit.repos.listDeployments({ - ...ctx.repo(), - task: `deploy:pr:${ctx.payload.pull_request.number}`, - per_page: 2, // in case there is on successful and one pending - }); - - for (const deployment of deployments) { - const { - data: [status], - } = await ctx.octokit.repos.listDeploymentStatuses({ - ...ctx.repo(), - deployment_id: deployment.id, - per_page: 1, - }); - - if (!status) continue; - const { state: currentState, log_url, environment_url } = status; - - if (!['in_progress', 'queued', 'pending', 'success'].includes(currentState)) { - continue; - } - - console.log(`Updating deployment ${deployment.id} state: ${currentState} -> ${state}`); - - await ctx.octokit.repos.createDeploymentStatus({ - ...ctx.repo(), - deployment_id: deployment.id, - description: 'Deployment destroyed after PR closed', - log_url, - environment_url, - state, - }); - } -} diff --git a/github_bot/src/utils/types.ts b/github_bot/src/utils/types.ts index 2d2759d5c3..783fa3b192 100644 --- a/github_bot/src/utils/types.ts +++ b/github_bot/src/utils/types.ts @@ -27,6 +27,10 @@ export interface BuildkiteTriggerBuildOptions< E extends Record = Record, MD extends Record = Record, > { + /** + * trigger context, used for logging + */ + context?: string; /** * Ref, SHA or tag to be built. *