From acbfeaa45b64e524cce61d0f73d3a83bd49c75f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 24 Jan 2024 15:09:50 +0100 Subject: [PATCH] feat(ci): support running on "deployment_status" event To support Vercel deployments: https://vercel.com/guides/how-can-i-run-end-to-end-tests-after-my-vercel-preview-deployment --- packages/core/src/ci-environment/index.ts | 8 +- .../ci-environment/services/github-actions.ts | 117 +++++++++++++++--- packages/core/src/ci-environment/types.ts | 4 +- packages/core/src/config.test.ts | 16 +-- packages/core/src/config.ts | 6 +- packages/core/src/upload.ts | 16 +-- packages/playwright/src/reporter.ts | 10 +- 7 files changed, 135 insertions(+), 42 deletions(-) diff --git a/packages/core/src/ci-environment/index.ts b/packages/core/src/ci-environment/index.ts index 749b9d6..6314192 100644 --- a/packages/core/src/ci-environment/index.ts +++ b/packages/core/src/ci-environment/index.ts @@ -24,9 +24,9 @@ const services = [ git, ]; -export const getCiEnvironment = ({ +export async function getCiEnvironment({ env = process.env, -}: Options = {}): CiEnvironment | null => { +}: Options = {}): Promise { const ctx = { env }; debug("Detecting CI environment", { env }); const service = services.find((service) => service.detect(ctx)); @@ -34,11 +34,11 @@ export const getCiEnvironment = ({ // Service matched if (service) { debug("Internal service matched", service.name); - const variables = service.config(ctx); + const variables = await service.config(ctx); const ciEnvironment = { name: service.name, ...variables }; debug("CI environment", ciEnvironment); return ciEnvironment; } return null; -}; +} diff --git a/packages/core/src/ci-environment/services/github-actions.ts b/packages/core/src/ci-environment/services/github-actions.ts index 422a837..45c6621 100644 --- a/packages/core/src/ci-environment/services/github-actions.ts +++ b/packages/core/src/ci-environment/services/github-actions.ts @@ -1,5 +1,80 @@ import { existsSync, readFileSync } from "node:fs"; import type { Service, Context } from "../types"; +import axios from "axios"; +import { debug } from "../../debug"; + +type EventPayload = { + pull_request?: { + head: { + sha: string; + ref: string; + }; + number: number; + }; + deployment?: { + sha: string; + environment: string; + }; +}; + +type GitHubPullRequest = { + number: number; + head: { + ref: string; + sha: string; + }; +}; + +/** + * When triggered by a deployment we try to get the pull request number from the + * deployment sha. + */ +async function getPullRequestFromHeadSha({ env }: Context, sha: string) { + debug("Fetching pull request number from head sha", sha); + if (!env.GITHUB_REPOSITORY) { + throw new Error("GITHUB_REPOSITORY is missing"); + } + if (!env.GITHUB_TOKEN) { + if (!env.DISABLE_GITHUB_TOKEN_WARNING) { + console.log( + ` +Running argos from a "deployment_status" event requires a GITHUB_TOKEN. +Please add \`GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}\` as environment variable. + +Read more at https://argos-ci.com/docs/run-on-preview-deployment + +To disable this warning, add \`DISABLE_GITHUB_TOKEN_WARNING: true\` as environment variable. +`.trim(), + ); + } + return null; + } + try { + const result = await axios.get( + `https://api.github.com/repos/${env.GITHUB_REPOSITORY}/pulls`, + { + params: { + head: sha, + }, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + if (result.data.length === 0) { + debug("Aborting because no pull request found"); + return null; + } + const firstPr = result.data[0]; + debug("PR found", firstPr); + return firstPr; + } catch (error) { + debug("Error while fetching pull request from head sha", error); + return null; + } +} const getBranch = ({ env }: Context) => { if (env.GITHUB_HEAD_REF) { @@ -20,16 +95,6 @@ const getRepository = ({ env }: Context) => { return env.GITHUB_REPOSITORY.split("/")[1]; }; -interface EventPayload { - pull_request?: { - head: { - sha: string; - ref: string; - }; - number: number; - }; -} - const readEventPayload = ({ env }: Context): EventPayload | null => { if (!env.GITHUB_EVENT_PATH) return null; if (!existsSync(env.GITHUB_EVENT_PATH)) return null; @@ -39,18 +104,40 @@ const readEventPayload = ({ env }: Context): EventPayload | null => { const service: Service = { name: "GitHub Actions", detect: ({ env }) => Boolean(env.GITHUB_ACTIONS), - config: ({ env }) => { + config: async ({ env }) => { const payload = readEventPayload({ env }); - return { - commit: process.env.GITHUB_SHA || null, - branch: payload?.pull_request?.head.ref || getBranch({ env }) || null, + const sha = process.env.GITHUB_SHA || null; + + if (!sha) { + throw new Error(`GITHUB_SHA is missing`); + } + + const commonConfig = { + commit: sha, owner: env.GITHUB_REPOSITORY_OWNER || null, repository: getRepository({ env }), jobId: env.GITHUB_JOB || null, runId: env.GITHUB_RUN_ID || null, + nonce: `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}` || null, + }; + + // If the job is triggered by from a "deployment" or a "deployment_status" + if (payload?.deployment) { + debug("Deployment event detected"); + const pullRequest = await getPullRequestFromHeadSha({ env }, sha); + return { + ...commonConfig, + branch: pullRequest?.head.ref || payload.deployment.environment || null, + prNumber: pullRequest?.number || null, + prHeadCommit: pullRequest?.head.sha || null, + }; + } + + return { + ...commonConfig, + branch: payload?.pull_request?.head.ref || getBranch({ env }) || null, prNumber: payload?.pull_request?.number || null, prHeadCommit: payload?.pull_request?.head.sha ?? null, - nonce: `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}` || null, }; }, }; diff --git a/packages/core/src/ci-environment/types.ts b/packages/core/src/ci-environment/types.ts index 395a649..3ea6a06 100644 --- a/packages/core/src/ci-environment/types.ts +++ b/packages/core/src/ci-environment/types.ts @@ -22,5 +22,7 @@ export interface CiEnvironment { export interface Service { name: string; detect(ctx: Context): boolean; - config(ctx: Context): Omit; + config( + ctx: Context, + ): Omit | Promise>; } diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index a6e850f..5a34b65 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -2,25 +2,27 @@ import { describe, it, expect } from "vitest"; import { readConfig } from "./config"; describe("#createConfig", () => { - it("gets config", () => { - const config = readConfig({ + it("gets config", async () => { + const config = await readConfig({ commit: "f16f980bd17cccfa93a1ae7766727e67950773d0", }); expect(config.commit).toBe("f16f980bd17cccfa93a1ae7766727e67950773d0"); }); - it("throws with invalid commit", () => { - expect(() => readConfig({ commit: "xx" })).toThrow( + it("throws with invalid commit", async () => { + await expect(() => readConfig({ commit: "xx" })).rejects.toThrow( "commit: Invalid commit", ); }); - it("throws with invalid token", () => { - expect(() => + it("throws with invalid token", async () => { + await expect(() => readConfig({ commit: "f16f980bd17cccfa93a1ae7766727e67950773d0", token: "invalid", }), - ).toThrow("token: Invalid Argos repository token (must be 40 characters)"); + ).rejects.toThrow( + "token: Invalid Argos repository token (must be 40 characters)", + ); }); }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 6792980..95cfd93 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -148,10 +148,10 @@ const createConfig = () => { }); }; -export const readConfig = (options: Partial = {}) => { +export async function readConfig(options: Partial = {}) { const config = createConfig(); - const ciEnv = getCiEnvironment(); + const ciEnv = await getCiEnvironment(); config.load({ apiBaseUrl: options.apiBaseUrl ?? config.get("apiBaseUrl"), @@ -183,4 +183,4 @@ export const readConfig = (options: Partial = {}) => { config.validate(); return config.get(); -}; +} diff --git a/packages/core/src/upload.ts b/packages/core/src/upload.ts index d48d852..59a0027 100644 --- a/packages/core/src/upload.ts +++ b/packages/core/src/upload.ts @@ -47,15 +47,17 @@ export interface UploadParameters { referenceCommit?: string; } -const getConfigFromOptions = ({ parallel, ...options }: UploadParameters) => { - const config = readConfig({ +async function getConfigFromOptions({ + parallel, + ...options +}: UploadParameters) { + return readConfig({ ...options, parallel: Boolean(parallel), parallelNonce: parallel ? parallel.nonce : null, parallelTotal: parallel ? parallel.total : null, }); - return config; -}; +} async function uploadFilesToS3( files: { url: string; path: string; contentType: string }[], @@ -86,11 +88,11 @@ async function uploadFilesToS3( /** * Upload screenshots to argos-ci.com. */ -export const upload = async (params: UploadParameters) => { +export async function upload(params: UploadParameters) { debug("Starting upload with params", params); // Read config - const config = getConfigFromOptions(params); + const config = await getConfigFromOptions(params); const files = params.files ?? ["**/*.{png,jpg,jpeg}"]; debug("Using config and files", config, files); @@ -209,4 +211,4 @@ export const upload = async (params: UploadParameters) => { }); return { build: result.build, screenshots }; -}; +} diff --git a/packages/playwright/src/reporter.ts b/packages/playwright/src/reporter.ts index 301481d..e9660e8 100644 --- a/packages/playwright/src/reporter.ts +++ b/packages/playwright/src/reporter.ts @@ -36,12 +36,12 @@ export type ArgosReporterOptions = Omit & { uploadToArgos?: boolean; }; -const getParallelFromConfig = ( +async function getParallelFromConfig( config: FullConfig, -): null | UploadParameters["parallel"] => { +): Promise { if (!config.shard) return null; if (config.shard.total === 1) return null; - const argosConfig = readConfig(); + const argosConfig = await readConfig(); if (!argosConfig.parallelNonce) { throw new Error( "Playwright shard mode detected. Please specify ARGOS_PARALLEL_NONCE env variable. Read /parallel-testing", @@ -51,7 +51,7 @@ const getParallelFromConfig = ( total: config.shard.total, nonce: argosConfig.parallelNonce, }; -}; +} class ArgosReporter implements Reporter { uploadDir!: string; @@ -130,7 +130,7 @@ class ArgosReporter implements Reporter { async onEnd(_result: FullResult) { if (!this.uploadToArgos) return; - const parallel = getParallelFromConfig(this.playwrightConfig); + const parallel = await getParallelFromConfig(this.playwrightConfig); try { const res = await upload({