From 38713b4b8ca05e107bfd3ee3cadee6eabe8e61fc Mon Sep 17 00:00:00 2001 From: Vadorequest Date: Wed, 30 Dec 2020 21:39:24 +0100 Subject: [PATCH] Handle GitHub private repositories (#236) --- .../workflows/deploy-vercel-production.yml | 5 +- .github/workflows/deploy-vercel-staging.yml | 5 +- next.config.js | 4 +- src/pages/api/status.ts | 1 + src/pages/api/webhooks/deploymentCompleted.ts | 118 ++++++++-------- src/utils/api/convertRequestBodyToJSObject.ts | 2 +- src/utils/gitHubActions/dispatchWorkflow.ts | 131 +++++++++++------- .../gitHubActions/dispatchWorkflowByPath.ts | 48 ++++++- src/utils/monitoring/sentry.ts | 6 +- 9 files changed, 202 insertions(+), 118 deletions(-) diff --git a/.github/workflows/deploy-vercel-production.yml b/.github/workflows/deploy-vercel-production.yml index a153ffa02..0489e8470 100644 --- a/.github/workflows/deploy-vercel-production.yml +++ b/.github/workflows/deploy-vercel-production.yml @@ -180,7 +180,7 @@ jobs: timeout: 90 # Wait for 90 seconds before failing - name: Display deployment status - run: "echo My deployment is ${{ fromJson(steps.await-vercel.outputs.deploymentDetails).readyState }}" + run: "echo The deployment is ${{ fromJson(steps.await-vercel.outputs.deploymentDetails).readyState }}" # Send a HTTP call to the webhook url that's provided in the customer configuration file (vercel.*.json) send-webhook-callback-once-deployment-ready: @@ -194,7 +194,8 @@ jobs: - name: Expose git environment variables and call webhook (if provided) # Workflow overview: # - Resolves webhook url from customer config file - # - If a webhook url was defined, send a + # - If a webhook url was defined in the customer config file, send an HTTP request, as POST request, with a JSON request body dynamically generated + # - Prints the headers of the POST HTTP request (curl) run: | MANUAL_TRIGGER_CUSTOMER="${{ github.event.inputs.customer}}" CUSTOMER_REF_TO_DEPLOY="${MANUAL_TRIGGER_CUSTOMER:-$(cat vercel.json | jq --raw-output '.build.env.NEXT_PUBLIC_CUSTOMER_REF')}" diff --git a/.github/workflows/deploy-vercel-staging.yml b/.github/workflows/deploy-vercel-staging.yml index ca50cfbb2..afd4e4545 100644 --- a/.github/workflows/deploy-vercel-staging.yml +++ b/.github/workflows/deploy-vercel-staging.yml @@ -257,7 +257,7 @@ jobs: timeout: 90 # Wait for 90 seconds before failing - name: Display deployment status - run: "echo My deployment is ${{ fromJson(steps.await-vercel.outputs.deploymentDetails).readyState }}" + run: "echo The deployment is ${{ fromJson(steps.await-vercel.outputs.deploymentDetails).readyState }}" # Send a HTTP call to the webhook url that's provided in the customer configuration file (vercel.*.json) send-webhook-callback-once-deployment-ready: @@ -271,7 +271,8 @@ jobs: - name: Expose git environment variables and call webhook (if provided) # Workflow overview: # - Resolves webhook url from customer config file - # - If a webhook url was defined, send a + # - If a webhook url was defined in the customer config file, send an HTTP request, as POST request, with a JSON request body dynamically generated + # - Prints the headers of the POST HTTP request (curl) run: | MANUAL_TRIGGER_CUSTOMER="${{ github.event.inputs.customer}}" CUSTOMER_REF_TO_DEPLOY="${MANUAL_TRIGGER_CUSTOMER:-$(cat vercel.json | jq --raw-output '.build.env.NEXT_PUBLIC_CUSTOMER_REF')}" diff --git a/next.config.js b/next.config.js index 6ec8c6244..33fa0b2d2 100644 --- a/next.config.js +++ b/next.config.js @@ -23,8 +23,8 @@ console.debug(`Building Next with NODE_ENV="${process.env.NODE_ENV}" NEXT_PUBLIC const GIT_COMMIT_TAGS = process.env.GIT_COMMIT_TAGS ? process.env.GIT_COMMIT_TAGS.trim() : ''; console.debug(`Deployment will be tagged automatically, using GIT_COMMIT_TAGS: "${GIT_COMMIT_TAGS}"`); -// Iterate over all tags and extract the first the match "v*" and extract only the version number ("v${major}.${minor}.${patch}) -const APP_RELEASE_TAG = GIT_COMMIT_TAGS ? GIT_COMMIT_TAGS.split(' ').find((tag) => tag.startsWith('v')).split('-')[0] : `unknown-${GIT_COMMIT_SHA_SHORT}`; +// Iterate over all tags and extract the first the match "v*" +const APP_RELEASE_TAG = GIT_COMMIT_TAGS ? GIT_COMMIT_TAGS.split(' ').find((tag) => tag.startsWith('v')) : `unknown-${GIT_COMMIT_SHA_SHORT}`; console.debug(`Release version resolved from tags: "${APP_RELEASE_TAG}" (matching first tag starting with "v")`); /** diff --git a/src/pages/api/status.ts b/src/pages/api/status.ts index 5ca6c3162..f21e4a858 100644 --- a/src/pages/api/status.ts +++ b/src/pages/api/status.ts @@ -31,6 +31,7 @@ export const status = async (req: NextApiRequest, res: NextApiResponse): Promise appName: process.env.NEXT_PUBLIC_APP_NAME, appRelease: process.env.NEXT_PUBLIC_APP_VERSION_RELEASE, appBuildTime: process.env.NEXT_PUBLIC_APP_BUILD_TIME, + appBuildTimeISO: (new Date(process.env.NEXT_PUBLIC_APP_BUILD_TIME)).toISOString(), appBuildTimestamp: process.env.NEXT_PUBLIC_APP_BUILD_TIMESTAMP, appBuildId: process.env.NEXT_PUBLIC_APP_BUILD_ID, nodejs: process.version, diff --git a/src/pages/api/webhooks/deploymentCompleted.ts b/src/pages/api/webhooks/deploymentCompleted.ts index aaa558b74..5034661dc 100644 --- a/src/pages/api/webhooks/deploymentCompleted.ts +++ b/src/pages/api/webhooks/deploymentCompleted.ts @@ -16,64 +16,66 @@ const logger = createLogger({ label: fileLabel, }); +type EndpointRequestBody = { + /** + * Value that was typed in the GHA web page, as "Customer to deploy". + * + * If this value is defined, it means the deployment was manually triggered through the Github Actions page (by a human being). + * + * @example customer1 + * @see https://github.com/UnlyEd/next-right-now/actions + */ + MANUAL_TRIGGER_CUSTOMER: string; + + /** + * Ref of the customer that was actually deployed. + * + * Computed value at runtime (during CI), depending on MANUAL_TRIGGER_CUSTOMER. + * Fallback to the default customer (defined in the vercel.json file). + * + * @example customer1 + */ + CUSTOMER_REF?: string; + + /** + * Stage (production|staging) used for the deployment. + * + * @example production + * @example staging + */ + STAGE?: string; + + /** + * SHA of the git commit used as deployment ref. + * + * Resolved by GitHub Action automatically, depending on which GIT_COMMIT_REF was used. + * + * @example 23ad5f7cc9a4b6c35e9c8d796fea13dcb3a08238 + */ + GIT_COMMIT_SHA?: string; + + /** + * Ref (branch|tag) that was used as deployment ref. + * + * When the deployment was performed manually, it corresponds to the ref selected in the "Use workflow from" list (UI). + * When the deployment was performed automatically (git push), it corresponds to the branch that was being used. + * + * @example refs/heads/master + */ + GIT_COMMIT_REF?: string; + + /** + * All tags associated with the git commit. + * + * Will contain the version number that was automatically assigned with the commit. + * + * @example v4.0.34-custom-webhooks-in-ci + */ + GIT_COMMIT_TAGS?: string; +}; + type EndpointRequest = NextApiRequest & { - body: { - /** - * Value that was typed in the GHA web page, as "Customer to deploy". - * - * If this value is defined, it means the deployment was manually triggered through the Github Actions page (by a human being). - * - * @example customer1 - * @see https://github.com/UnlyEd/next-right-now/actions - */ - MANUAL_TRIGGER_CUSTOMER: string; - - /** - * Ref of the customer that was actually deployed. - * - * Computed value at runtime (during CI), depending on MANUAL_TRIGGER_CUSTOMER. - * Fallback to the default customer (defined in the vercel.json file). - * - * @example customer1 - */ - CUSTOMER_REF?: string; - - /** - * Stage (production|staging) used for the deployment. - * - * @example production - * @example staging - */ - STAGE?: string; - - /** - * SHA of the git commit used as deployment ref. - * - * Resolved by GitHub Action automatically, depending on which GIT_COMMIT_REF was used. - * - * @example 23ad5f7cc9a4b6c35e9c8d796fea13dcb3a08238 - */ - GIT_COMMIT_SHA?: string; - - /** - * Ref (branch|tag) that was used as deployment ref. - * - * When the deployment was performed manually, it corresponds to the ref selected in the "Use workflow from" list (UI). - * When the deployment was performed automatically (git push), it corresponds to the branch that was being used. - * - * @example refs/heads/master - */ - GIT_COMMIT_REF?: string; - - /** - * All tags associated with the git commit. - * - * Will contain the version number that was automatically assigned with the commit. - * - * @example v4.0.34-custom-webhooks-in-ci - */ - GIT_COMMIT_TAGS?: string; - } + body: EndpointRequestBody; }; /** @@ -97,7 +99,7 @@ export const deploymentCompleted = async (req: EndpointRequest, res: NextApiResp // eslint-disable-next-line no-console console.log(req?.body); - const parsedBody: GenericObject = convertRequestBodyToJSObject(req); + const parsedBody = convertRequestBodyToJSObject(req) as EndpointRequestBody; // eslint-disable-next-line no-console console.debug('body (parsed)', parsedBody); diff --git a/src/utils/api/convertRequestBodyToJSObject.ts b/src/utils/api/convertRequestBodyToJSObject.ts index 8f231e321..091076eab 100644 --- a/src/utils/api/convertRequestBodyToJSObject.ts +++ b/src/utils/api/convertRequestBodyToJSObject.ts @@ -12,7 +12,7 @@ import { GenericObject } from '../../types/GenericObject'; export const convertRequestBodyToJSObject = (req: NextApiRequest): GenericObject => { let parsedBody: GenericObject = {}; - if (typeof req?.body === 'string') { + if (typeof req?.body === 'string' && req?.body?.length > 0) { parsedBody = JSON.parse(req?.body); } else { parsedBody = req.body; diff --git a/src/utils/gitHubActions/dispatchWorkflow.ts b/src/utils/gitHubActions/dispatchWorkflow.ts index 0403b24cd..8080ed49e 100644 --- a/src/utils/gitHubActions/dispatchWorkflow.ts +++ b/src/utils/gitHubActions/dispatchWorkflow.ts @@ -17,65 +17,102 @@ const logger = createLogger({ * @param workflowFilePath */ export const dispatchWorkflow = async (workflowsList: WorkflowsAPIResponse, platformReleaseRef: string, workflowFilePath: string): Promise => { - const [workflowDetails] = workflowsList?.workflows?.filter((workflow) => workflow?.path === workflowFilePath); + try { + const [workflowDetails] = workflowsList?.workflows?.filter((workflow) => workflow?.path === workflowFilePath); - if (workflowDetails) { - const body = { - inputs: { - customer: process.env.NEXT_PUBLIC_CUSTOMER_REF, - }, - ref: platformReleaseRef, - }; - const options = { - method: 'POST', - headers: { - Authorization: `token ${process.env.GITHUB_DISPATCH_TOKEN}`, - Accept: 'application/vnd.github.v3+json', - }, - body: JSON.stringify(body), - }; - const url = `${workflowDetails?.url}/dispatches`; + if (workflowDetails) { + /** + * Creates a workflow dispatch event. + * + * @see https://docs.github.com/en/free-pro-team@latest/rest/reference/actions#create-a-workflow-dispatch-event + */ + const url = `${workflowDetails?.url}/dispatches`; + const body = { + inputs: { + customer: process.env.NEXT_PUBLIC_CUSTOMER_REF, + }, + ref: platformReleaseRef, + }; + const options = { + method: 'POST', + headers: { + Accept: 'application/vnd.github.v3+json', + }, + body: JSON.stringify(body), + }; - Sentry.configureScope((scope): void => { - scope.setExtra('workflowFilePath', workflowFilePath); - scope.setExtra('workflowDispatchRequestUrl', url); - scope.setContext('workflowDispatchRequestBody', body); - scope.setContext('workflowDetails', workflowDetails); - }); + if (process.env.GITHUB_DISPATCH_TOKEN) { + // Authorization token, required if the repository is private, unnecessary if the repo is public + options.headers['Authorization'] = `token ${process.env.GITHUB_DISPATCH_TOKEN}`; + } + + Sentry.configureScope((scope): void => { + scope.setExtra('workflowFilePath', workflowFilePath); + scope.setExtra('workflowDispatchRequestUrl', url); + scope.setExtra('platformReleaseRef', platformReleaseRef); + scope.setContext('workflowDispatchRequestBody', body); + scope.setContext('workflowDetails', workflowDetails); + }); - Sentry.withScope((scope): void => { - scope.setTag('alertType', ALERT_TYPES.VERCEL_DEPLOYMENT_TRIGGERED); + Sentry.withScope((scope): void => { + scope.setTag('alertType', ALERT_TYPES.VERCEL_DEPLOYMENT_TRIGGER_ATTEMPT); - Sentry.captureEvent({ - message: 'Triggering Vercel deployment.', - level: Sentry.Severity.Log, + Sentry.captureEvent({ + message: `Attempting to trigger a Vercel deployment using "${workflowFilePath}" with version "${platformReleaseRef}".`, + level: Sentry.Severity.Log, + }); }); - }); - logger.debug(`Fetching "${url}", using workflow path: "${workflowFilePath}", with request body: ${JSON.stringify(body, null, 2)}`); - const response = await fetch(url, options); + logger.debug(`Fetching "${url}", using workflow path: "${workflowFilePath}", with request body: ${JSON.stringify(body, null, 2)}`); + const response = await fetch(url, options); - if (!response?.status.toString().startsWith('2')) { // If the response status isn't 2XX, then something wrong happened - try { - const result = await response.json(); - const errorMessage = JSON.stringify(result, null, 2); + if (!response?.status?.toString()?.startsWith('2')) { + let errorMessage; + + try { + // Response might contain JSON or plain text, attempt to stringify JSON, will fail if no valid JSON found + const result = await response.json(); + errorMessage = JSON.stringify(result, null, 2); + + Sentry.captureException(new Error(errorMessage)); + logger.error(errorMessage); + } catch (e) { + // Stringifying JSON failed, attempt to retrieve the plain text error message + Sentry.captureException(e); + logger.error(e); + + errorMessage = await response.text(); + Sentry.captureException(errorMessage); + logger.error(errorMessage); + } finally { + Sentry.withScope((scope): void => { + scope.setTag('alertType', ALERT_TYPES.VERCEL_DEPLOYMENT_TRIGGER_ATTEMPT_FAILED); - Sentry.captureException(new Error(errorMessage)); - logger.error(errorMessage); - } catch (e) { - Sentry.captureException(e); - logger.error(e); + Sentry.captureEvent({ + message: `Failed to trigger a Vercel deployment using "${workflowFilePath}" with version "${platformReleaseRef}". Error: "${errorMessage}"`, + level: Sentry.Severity.Error, + }); + }); + } + } else { + Sentry.withScope((scope): void => { + scope.setTag('alertType', ALERT_TYPES.VERCEL_DEPLOYMENT_TRIGGER_ATTEMPT_SUCCEEDED); - const result = await response.text(); - Sentry.captureException(result); - logger.error(result); + Sentry.captureEvent({ + message: `Successfully triggered a Vercel deployment using "${workflowFilePath}" with version "${platformReleaseRef}".`, + level: Sentry.Severity.Log, + }); + }); } + } else { + const errorMessage = `No GitHub Actions workflow could be found for file path: "${workflowFilePath}"`; + Sentry.captureException(new Error(errorMessage)); + logger.error(errorMessage); } - } else { - const errorMessage = `No GitHub Actions workflow could be found for file path: "${workflowFilePath}"`; - Sentry.captureException(new Error(errorMessage)); - logger.error(errorMessage); + } catch (e) { + Sentry.captureException(e); + logger.error(e); } }; diff --git a/src/utils/gitHubActions/dispatchWorkflowByPath.ts b/src/utils/gitHubActions/dispatchWorkflowByPath.ts index 244a7265d..8d443a048 100644 --- a/src/utils/gitHubActions/dispatchWorkflowByPath.ts +++ b/src/utils/gitHubActions/dispatchWorkflowByPath.ts @@ -13,7 +13,18 @@ const logger = createLogger({ label: fileLabel, }); -const GITHUB_API_LIST_PROJECT_WORKFLOWS = `${GITHUB_API_BASE_URL}repos/${GITHUB_OWNER_NAME}/${GITHUB_REPO_NAME}/actions/workflows`; +/** + * Endpoint to list the workflows of a repository. + * Public if the repository is public. + * + * @see https://docs.github.com/en/free-pro-team@latest/rest/reference/actions#list-repository-workflows + */ +const GITHUB_API_LIST_PROJECT_WORKFLOWS = `${GITHUB_API_BASE_URL}/repos/${GITHUB_OWNER_NAME}/${GITHUB_REPO_NAME}/actions/workflows`; + +type GitHubAPIError = { + message?: string; + documentation_url: string; +} /** * Fetches all GitHub Actions workflows then dispatches the workflow referenced by "workflowFilePath". @@ -24,10 +35,39 @@ const GITHUB_API_LIST_PROJECT_WORKFLOWS = `${GITHUB_API_BASE_URL}repos/${GITHUB_ export const dispatchWorkflowByPath = async (platformReleaseRef: string, workflowFilePath: string): Promise => { try { logger.debug(`Fetching "${GITHUB_API_LIST_PROJECT_WORKFLOWS}"`); - const response = await fetch(GITHUB_API_LIST_PROJECT_WORKFLOWS); - const results: WorkflowsAPIResponse = await response.json(); - await dispatchWorkflow(results, platformReleaseRef, workflowFilePath); + const options = { + method: 'GET', + headers: { + Accept: 'application/vnd.github.v3+json', + }, + }; + + if (process.env.GITHUB_DISPATCH_TOKEN) { + // Authorization token, required if the repository is private, unnecessary if the repo is public + options.headers['Authorization'] = `token ${process.env.GITHUB_DISPATCH_TOKEN}`; + } + + const response = await fetch(GITHUB_API_LIST_PROJECT_WORKFLOWS, options); + const results: WorkflowsAPIResponse | GitHubAPIError = await response.json(); + + if (response.status !== 200) { + // Something wrong happened + const error: GitHubAPIError = results as GitHubAPIError; + const message = error?.message + (error?.documentation_url ? ` - See ${error?.documentation_url}` : ''); + + logger.error(message); + Sentry.withScope((scope): void => { + scope.setContext('response (raw)', response); + scope.setContext('results (parsed)', results); + Sentry.captureException(message); + }); + + return Promise.resolve(); + } else { + await dispatchWorkflow(results as WorkflowsAPIResponse, platformReleaseRef, workflowFilePath); + } + } catch (e) { Sentry.captureException(new Error(e)); logger.error(e); diff --git a/src/utils/monitoring/sentry.ts b/src/utils/monitoring/sentry.ts index aec6b00c0..974590d25 100644 --- a/src/utils/monitoring/sentry.ts +++ b/src/utils/monitoring/sentry.ts @@ -57,7 +57,9 @@ if (process.env.SENTRY_DSN) { */ export const ALERT_TYPES = { VERCEL_DEPLOYMENT_INVOKED: 'vercel-deployment-invoked', - VERCEL_DEPLOYMENT_TRIGGERED: 'vercel-deployment-triggered', + VERCEL_DEPLOYMENT_TRIGGER_ATTEMPT: 'vercel-deployment-trigger-attempt', + VERCEL_DEPLOYMENT_TRIGGER_ATTEMPT_FAILED: 'vercel-deployment-trigger-attempt-failed', + VERCEL_DEPLOYMENT_TRIGGER_ATTEMPT_SUCCEEDED: 'vercel-deployment-trigger-attempt-succeeded', VERCEL_DEPLOYMENT_COMPLETED: 'vercel-deployment-completed', }; @@ -110,7 +112,7 @@ export const configureReq = (req: NextApiRequest, tags?: { [key: string]: string parsedBody = convertRequestBodyToJSObject(req); } catch (e) { // eslint-disable-next-line no-console - console.error(e); + // console.error(e); } // Do nothing, as "body" is not necessarily supposed to contain valid stringified JSON Sentry.configureScope((scope) => {