diff --git a/.github/shared/package.json b/.github/shared/package.json index 84e19daa92a6..61871cd119ca 100644 --- a/.github/shared/package.json +++ b/.github/shared/package.json @@ -14,6 +14,7 @@ "./readme": "./src/readme.js", "./sdk-types": "./src/sdk-types.js", "./sleep": "./src/sleep.js", + "./sort": "./src/sort.js", "./spec-model-error": "./src/spec-model-error.js", "./spec-model": "./src/spec-model.js", "./swagger": "./src/swagger.js", diff --git a/.github/shared/src/sort.js b/.github/shared/src/sort.js new file mode 100644 index 000000000000..befce40a0147 --- /dev/null +++ b/.github/shared/src/sort.js @@ -0,0 +1,45 @@ +// @ts-check + +/** + * Returns a comparator that compares values by a date string in ascending order. + * Throws if the value returned by getDate() is null, undefined, or cannot be + * parsed as a date. + * + * @template T + * @param {(item: T) => string} getDate + * @returns {(a: T, b: T) => number} + */ +export function byDate(getDate) { + return (a, b) => { + // Sort ascending to match JS default + return parseDate(getDate(a)) - parseDate(getDate(b)); + }; +} + +/** + * Parses a string to a date, throwing if null, undefined, or cannot be parsed. + * + * @param {string} s + * @returns {number} + */ +function parseDate(s) { + // Date.parse() returns NaN for null, undefined, or strings that cannot be parsed. + const parsed = Date.parse(s); + + if (Number.isNaN(parsed)) { + throw new Error(`Unable to parse '${s}' to a valid date`); + } + + return parsed; +} + +/** + * Inverts a comparator function. + * + * @template T + * @param {(a: T, b: T) => number} comparator + * @returns {(a: T, b: T) => number} + */ +export function invert(comparator) { + return (a, b) => -comparator(a, b); +} diff --git a/.github/shared/test/sort.test.js b/.github/shared/test/sort.test.js new file mode 100644 index 000000000000..17db2445643a --- /dev/null +++ b/.github/shared/test/sort.test.js @@ -0,0 +1,35 @@ +// @ts-check + +import { describe, expect, it } from "vitest"; +import { byDate, invert } from "../src/sort.js"; + +describe("byDate", () => { + const input = [{ foo: "2025-01-01" }, { foo: "2023-01-01" }, { foo: "2024-01-01" }]; + + it("ascending by default", () => { + input.sort(byDate((s) => s.foo)); + + // Value `undefined` always sorts to the end + expect(input).toEqual([{ foo: "2023-01-01" }, { foo: "2024-01-01" }, { foo: "2025-01-01" }]); + }); + + it("descending with invert()", () => { + input.sort(invert(byDate((s) => s.foo))); + + // Value `undefined` always sorts to the end + expect(input).toEqual([{ foo: "2025-01-01" }, { foo: "2024-01-01" }, { foo: "2023-01-01" }]); + }); + + it.each([null, undefined, "invalid"])("invalid input: %s", (i) => { + /** @type {{foo: string | null | undefined}[]} */ + const input = [{ foo: "2025-01-01" }, { foo: "2024-01-01" }]; + const comparator = byDate((i) => i.foo); + + // Ensure base case doesn't throw + input.sort(comparator); + expect(input).toEqual([{ foo: "2024-01-01" }, { foo: "2025-01-01" }]); + + input[0].foo = i; + expect(() => input.sort(comparator)).toThrowError(`Unable to parse '${i}' to a valid date`); + }); +}); diff --git a/.github/workflows/_reusable-verify-run-status.yaml b/.github/workflows/_reusable-verify-run-status.yaml index ed7982100530..ab08c8788506 100644 --- a/.github/workflows/_reusable-verify-run-status.yaml +++ b/.github/workflows/_reusable-verify-run-status.yaml @@ -8,9 +8,11 @@ on: description: Name of the check run to verify required: true type: string + commit_status_name: + description: Name of the commit status to verify + type: string workflow_name: description: Name of the workflow to verify - required: true type: string permissions: @@ -41,4 +43,5 @@ jobs: return await verifyRunStatus({ github, context, core }); env: CHECK_RUN_NAME: ${{ inputs.check_run_name }} + COMMIT_STATUS_NAME: ${{ inputs.commit_status_name }} WORKFLOW_NAME: ${{ inputs.workflow_name }} diff --git a/.github/workflows/src/github.js b/.github/workflows/src/github.js index a5fa001defe3..b02dea5b1f55 100644 --- a/.github/workflows/src/github.js +++ b/.github/workflows/src/github.js @@ -1,4 +1,14 @@ // @ts-check + +import { byDate, invert } from "../../shared/src/sort.js"; + +/** + * @typedef {import('@octokit/plugin-rest-endpoint-methods').RestEndpointMethodTypes} RestEndpointMethodTypes + * @typedef {RestEndpointMethodTypes["checks"]["listForRef"]["response"]["data"]["check_runs"]} CheckRuns + * @typedef {RestEndpointMethodTypes["actions"]["listWorkflowRunsForRepo"]["response"]["data"]["workflow_runs"]} WorkflowRuns + * @typedef {RestEndpointMethodTypes["repos"]["listCommitStatusesForRef"]["response"]["data"]} CommitStatuses + */ + export const PER_PAGE_MAX = 100; /** @@ -133,3 +143,80 @@ export async function writeToActionsSummary(content, core) { throw new Error(`Failed to write to the GitHub Actions summary: ${error}`); } } + +/** + * Returns the check with the given checkRunName for the given ref. + * @param {import('github-script').AsyncFunctionArguments['github']} github + * @param {import('github-script').AsyncFunctionArguments['context']} context + * @param {string} checkRunName + * @param {string} ref + * @returns {Promise} + */ +export async function getCheckRuns(github, context, checkRunName, ref) { + const result = await github.paginate(github.rest.checks.listForRef, { + ...context.repo, + ref: ref, + check_name: checkRunName, + status: "completed", + per_page: PER_PAGE_MAX, + }); + + /* v8 ignore next */ + return result.sort( + invert( + byDate((run) => { + if (run.completed_at === null) { + // completed_at should never be null because status is "completed" + throw new Error(`Unexpected value of run.completed_at: '${run.completed_at}'`); + } else { + return run.completed_at; + } + }), + ), + ); +} + +/** + * Returns the check with the given checkRunName for the given ref. + * @param {import('github-script').AsyncFunctionArguments['github']} github + * @param {import('github-script').AsyncFunctionArguments['context']} context + * @param {string} commitStatusName + * @param {string} ref + * @returns {Promise} + */ +export async function getCommitStatuses(github, context, commitStatusName, ref) { + const result = await github.paginate(github.rest.repos.listCommitStatusesForRef, { + ...context.repo, + ref: ref, + per_page: PER_PAGE_MAX, + }); + + return result + .filter( + (status) => + // Property "context" is case-insensitive + status.context.toLowerCase() === commitStatusName.toLowerCase(), + ) + .sort(invert(byDate((status) => status.updated_at))); +} + +/** + * Returns the workflow run with the given workflowName for the given ref. + * @param {import('github-script').AsyncFunctionArguments['github']} github + * @param {import('github-script').AsyncFunctionArguments['context']} context + * @param {string} workflowName + * @param {string} ref + * @returns {Promise} + */ +export async function getWorkflowRuns(github, context, workflowName, ref) { + const result = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, { + ...context.repo, + head_sha: ref, + status: "completed", + per_page: PER_PAGE_MAX, + }); + + return result + .filter((run) => run.name === workflowName) + .sort(invert(byDate((run) => run.updated_at))); +} diff --git a/.github/workflows/src/verify-run-status.js b/.github/workflows/src/verify-run-status.js index b99bbca3d46e..023a540a4362 100644 --- a/.github/workflows/src/verify-run-status.js +++ b/.github/workflows/src/verify-run-status.js @@ -1,14 +1,13 @@ import { extractInputs } from "./context.js"; -import { PER_PAGE_MAX } from "./github.js"; - -const SUPPORTED_EVENTS = ["workflow_run", "check_run", "check_suite"]; +import { getCheckRuns, getCommitStatuses, getWorkflowRuns } from "./github.js"; /** * @typedef {import('@octokit/plugin-rest-endpoint-methods').RestEndpointMethodTypes} RestEndpointMethodTypes - * @typedef {RestEndpointMethodTypes["checks"]["listForRef"]["response"]["data"]["check_runs"]} CheckRuns - * @typedef {RestEndpointMethodTypes["actions"]["listWorkflowRunsForRepo"]["response"]["data"]["workflow_runs"]} WorkflowRuns + * @typedef {RestEndpointMethodTypes["repos"]["listCommitStatusesForRef"]["response"]["data"]} CommitStatuses */ +const SUPPORTED_EVENTS = ["workflow_run", "check_run", "check_suite"]; + /* v8 ignore start */ /** * Given the name of a completed check run name and a completed workflow, verify @@ -22,9 +21,10 @@ export async function verifyRunStatus({ github, context, core }) { throw new Error("CHECK_RUN_NAME is not set"); } + const commitStatusName = process.env.COMMIT_STATUS_NAME; const workflowName = process.env.WORKFLOW_NAME; - if (!workflowName) { - throw new Error("WORKFLOW_NAME is not set"); + if (!commitStatusName && !workflowName) { + throw new Error("Neither COMMIT_STATUS nor WORKFLOW_NAME is not set"); } if (!SUPPORTED_EVENTS.some((e) => e === context.eventName)) { @@ -45,6 +45,7 @@ export async function verifyRunStatus({ github, context, core }) { context, core, checkRunName, + commitStatusName, workflowName, }); } @@ -56,9 +57,17 @@ export async function verifyRunStatus({ github, context, core }) { * @param {import('github-script').AsyncFunctionArguments["context"]} params.context * @param {import('github-script').AsyncFunctionArguments["core"]} params.core * @param {string} params.checkRunName - * @param {string} params.workflowName + * @param {string} [params.commitStatusName] + * @param {string} [params.workflowName] */ -export async function verifyRunStatusImpl({ github, context, core, checkRunName, workflowName }) { +export async function verifyRunStatusImpl({ + github, + context, + core, + checkRunName, + commitStatusName, + workflowName, +}) { if (context.eventName == "check_run") { const contextRunName = context.payload.check_run.name; if (contextRunName !== checkRunName) { @@ -98,86 +107,94 @@ export async function verifyRunStatusImpl({ github, context, core, checkRunName, ); core.debug(`Check run: ${JSON.stringify(checkRun)}`); - let workflowRun; - if (context.eventName == "workflow_run") { - workflowRun = context.payload.workflow_run; - } else { - const workflowRuns = await getWorkflowRuns(github, context, workflowName, head_sha); - if (workflowRuns.length === 0) { + if (commitStatusName) { + core.info(`commitStatusName: ${commitStatusName}`); + + // Get the commit status + let commitStatusContext, commitStatusState, commitStatusTargetUrl; + + if (context.eventName === "status") { + // For status events, the payload contains the status data directly + commitStatusContext = context.payload.context; + commitStatusState = context.payload.state; + commitStatusTargetUrl = context.payload.target_url; + } else { + // Fetch the commit status from the API + try { + const commitStatuses = await getCommitStatuses(github, context, commitStatusName, head_sha); + commitStatusContext = commitStatuses[0].context; + commitStatusState = commitStatuses[0].state; + commitStatusTargetUrl = commitStatuses[0].target_url; + } catch (error) { + core.setFailed( + `Failed to fetch commit status: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + } + + core.info( + `Commit status context: ${commitStatusContext}, state: ${commitStatusState}, URL: ${commitStatusTargetUrl}`, + ); + core.debug( + `Commit status: ${JSON.stringify({ context: commitStatusContext, state: commitStatusState, target_url: commitStatusTargetUrl })}`, + ); + if (commitStatusState === "pending") { core.notice( - `No completed workflow run with name: ${workflowName}. Not enough information to judge success or failure. Ending with success status.`, + `Commit status is in pending state. Skipping comparison with check run conclusion.`, ); return; } - // Use the most recent workflow run - workflowRun = workflowRuns[0]; - } + // Normalize check run conclusion: treat 'neutral' as 'success' + const normalizedCheckRunConclusion = + checkRun.conclusion === "neutral" ? "success" : checkRun.conclusion; - core.info( - `Workflow run name: ${workflowRun.name}, conclusion: ${workflowRun.conclusion}, URL: ${workflowRun.html_url}`, - ); - core.debug(`Workflow run: ${JSON.stringify(workflowRun)}`); + if (normalizedCheckRunConclusion !== commitStatusState) { + core.setFailed( + `Check run conclusion (${checkRun.conclusion}) does not match commit status state (${commitStatusState})`, + ); + return; + } - if (checkRun.conclusion !== workflowRun.conclusion) { - core.setFailed( - `Check run conclusion (${checkRun.conclusion}) does not match workflow run conclusion (${workflowRun.conclusion})`, + core.notice( + `Conclusions match for check run ${checkRunName} and commit status ${commitStatusName}`, ); - return; } - core.notice(`Conclusions match for check run ${checkRunName} and workflow run ${workflowName}`); -} + if (workflowName) { + let workflowRun; + if (context.eventName == "workflow_run") { + workflowRun = context.payload.workflow_run; + } else { + const workflowRuns = await getWorkflowRuns(github, context, workflowName, head_sha); + if (workflowRuns.length === 0) { + core.notice( + `No completed workflow run with name: ${workflowName}. Not enough information to judge success or failure. Ending with success status.`, + ); + return; + } -/** - * Returns the check with the given checkRunName for the given ref. - * @param {import('github-script').AsyncFunctionArguments['github']} github - * @param {import('github-script').AsyncFunctionArguments['context']} context - * @param {string} checkRunName - * @param {string} ref - * @returns {Promise} - */ -export async function getCheckRuns(github, context, checkRunName, ref) { - const result = await github.paginate(github.rest.checks.listForRef, { - ...context.repo, - ref: ref, - check_name: checkRunName, - status: "completed", - per_page: PER_PAGE_MAX, - }); + // Use the most recent workflow run + workflowRun = workflowRuns[0]; + } - // a and b will never be null because status is "completed" - /* v8 ignore next */ - return result.sort((a, b) => compareDatesDescending(a.completed_at || "", b.completed_at || "")); -} + core.info( + `Workflow run name: ${workflowRun.name}, conclusion: ${workflowRun.conclusion}, URL: ${workflowRun.html_url}`, + ); + core.debug(`Workflow run: ${JSON.stringify(workflowRun)}`); -/** - * Returns the workflow run with the given workflowName for the given ref. - * @param {import('github-script').AsyncFunctionArguments['github']} github - * @param {import('github-script').AsyncFunctionArguments['context']} context - * @param {string} workflowName - * @param {string} ref - * @returns {Promise} - */ -export async function getWorkflowRuns(github, context, workflowName, ref) { - const result = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, { - ...context.repo, - head_sha: ref, - status: "completed", - per_page: PER_PAGE_MAX, - }); + // Normalize check run conclusion: treat 'neutral' as 'success' + const normalizedCheckRunConclusion = + checkRun.conclusion === "neutral" ? "success" : checkRun.conclusion; - return result - .filter((run) => run.name === workflowName) - .sort((a, b) => compareDatesDescending(a.updated_at, b.updated_at)); -} + if (normalizedCheckRunConclusion !== workflowRun.conclusion) { + core.setFailed( + `Check run conclusion (${checkRun.conclusion}) does not match workflow run conclusion (${workflowRun.conclusion})`, + ); + return; + } -/** - * Compares two date strings in descending order. - * @param {string} a date string of the form "YYYY-MM-DDTHH:mm:ssZ" - * @param {string} b date string of the form "YYYY-MM-DDTHH:mm:ssZ" - * @returns - */ -export function compareDatesDescending(a, b) { - return new Date(b).getTime() - new Date(a).getTime(); + core.notice(`Conclusions match for check run ${checkRunName} and workflow run ${workflowName}`); + } } diff --git a/.github/workflows/test/github.test.js b/.github/workflows/test/github.test.js index 8107af06c7d6..9dfcef9dbd0a 100644 --- a/.github/workflows/test/github.test.js +++ b/.github/workflows/test/github.test.js @@ -1,9 +1,190 @@ -import { describe, expect, it } from "vitest"; -import { writeToActionsSummary } from "../src/github.js"; -import { createMockCore } from "./mocks.js"; +import { describe, expect, it, vi } from "vitest"; +import { getCheckRuns, getWorkflowRuns, writeToActionsSummary } from "../src/github.js"; +import { createMockContext, createMockCore, createMockGithub } from "./mocks.js"; const mockCore = createMockCore(); +describe("getCheckRuns", () => { + it("returns matching check_run", async () => { + const githubMock = createMockGithub(); + githubMock.rest.checks.listForRef = vi.fn().mockResolvedValue({ + data: { + check_runs: [ + { + name: "checkRunName", + status: "completed", + conclusion: "success", + }, + ], + }, + }); + + const actual = await getCheckRuns( + githubMock, + createMockContext(), + createMockCore(), + "checkRunName", + "head_sha", + ); + + expect(actual).toEqual([ + expect.objectContaining({ + name: "checkRunName", + status: "completed", + conclusion: "success", + }), + ]); + }); + + it("returns null when no check matches", async () => { + const githubMock = createMockGithub(); + githubMock.rest.checks.listForRef = vi.fn().mockResolvedValue({ + data: { + check_runs: [], + }, + }); + + const actual = await getCheckRuns(githubMock, createMockContext(), "checkRunName", "head_sha"); + + expect(actual).toEqual([]); + }); + + it("throws when multiple checks match", async () => { + const githubMock = createMockGithub(); + const earlierDate = "2025-04-01T00:00:00Z"; + const laterDate = "2025-04-02T00:00:00Z"; + githubMock.rest.checks.listForRef = vi.fn().mockResolvedValue({ + data: { + check_runs: [ + { + name: "checkRunName", + status: "completed", + conclusion: "success", + completed_at: earlierDate, + }, + { + name: "checkRunName", + status: "completed", + conclusion: "success", + completed_at: laterDate, + }, + ], + }, + }); + + const actual = await getCheckRuns(githubMock, createMockContext(), "checkRunName", "head_sha"); + + expect(actual).toEqual([ + expect.objectContaining({ + name: "checkRunName", + status: "completed", + conclusion: "success", + completed_at: laterDate, + }), + expect.objectContaining({ + name: "checkRunName", + status: "completed", + conclusion: "success", + completed_at: earlierDate, + }), + ]); + }); +}); + +describe("getWorkflowRuns", () => { + it("returns matching workflow_run", async () => { + const githubMock = createMockGithub(); + githubMock.rest.actions.listWorkflowRunsForRepo = vi.fn().mockResolvedValue({ + data: { + workflow_runs: [ + { + name: "workflowName", + status: "completed", + conclusion: "success", + }, + ], + }, + }); + + const actual = await getWorkflowRuns( + githubMock, + createMockContext(), + "workflowName", + "head_sha", + ); + + expect(actual).toEqual([ + expect.objectContaining({ + name: "workflowName", + status: "completed", + conclusion: "success", + }), + ]); + }); + + it("returns null when no workflow matches", async () => { + const githubMock = createMockGithub(); + githubMock.rest.actions.listWorkflowRunsForRepo = vi.fn().mockResolvedValue({ + data: { + workflow_runs: [ + { + name: "otherWorkflowName", + }, + ], + }, + }); + + const actual = await getWorkflowRuns( + githubMock, + createMockContext(), + "workflowName", + "head_sha", + ); + + expect(actual).toEqual([]); + }); + + it("returns latest when multiple workflows match", async () => { + const githubMock = createMockGithub(); + const earlyDate = "2025-04-01T00:00:00Z"; + const laterDate = "2025-04-02T00:00:00Z"; + githubMock.rest.actions.listWorkflowRunsForRepo = vi.fn().mockResolvedValue({ + data: { + workflow_runs: [ + { + name: "workflowName", + status: "completed", + conclusion: "success", + updated_at: earlyDate, + }, + { + name: "workflowName", + status: "completed", + conclusion: "success", + updated_at: laterDate, + }, + ], + }, + }); + + const actual = await getWorkflowRuns( + githubMock, + createMockContext(), + "workflowName", + "head_sha", + ); + + expect(actual).toEqual([ + expect.objectContaining({ + updated_at: laterDate, + }), + expect.objectContaining({ + updated_at: earlyDate, + }), + ]); + }); +}); + describe("writeToActionsSummary function", () => { it("should add content to the summary and write it", async () => { // Call function diff --git a/.github/workflows/test/verify-run-status.test.js b/.github/workflows/test/verify-run-status.test.js index 1cfe35080755..20841900d34a 100644 --- a/.github/workflows/test/verify-run-status.test.js +++ b/.github/workflows/test/verify-run-status.test.js @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; -import { createMockGithub, createMockContext, createMockCore } from "./mocks.js"; -import { getCheckRuns, getWorkflowRuns, verifyRunStatusImpl } from "../src/verify-run-status.js"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { verifyRunStatusImpl } from "../src/verify-run-status.js"; +import { createMockCore, createMockGithub } from "./mocks.js"; vi.mock("../src/context.js", () => { return { @@ -10,207 +10,30 @@ vi.mock("../src/context.js", () => { }; }); -describe("getCheckRuns", () => { - it("returns matching check_run", async () => { - const githubMock = createMockGithub(); - githubMock.rest.checks.listForRef = vi.fn().mockResolvedValue({ - data: { - check_runs: [ - { - name: "checkRunName", - status: "completed", - conclusion: "success", - }, - ], - }, - }); - - const actual = await getCheckRuns( - githubMock, - createMockContext(), - createMockCore(), - "checkRunName", - "head_sha", - ); - - expect(actual).toEqual([ - expect.objectContaining({ - name: "checkRunName", - status: "completed", - conclusion: "success", - }), - ]); - }); - - it("returns null when no check matches", async () => { - const githubMock = createMockGithub(); - githubMock.rest.checks.listForRef = vi.fn().mockResolvedValue({ - data: { - check_runs: [], - }, - }); - - const actual = await getCheckRuns(githubMock, createMockContext(), "checkRunName", "head_sha"); - - expect(actual).toEqual([]); - }); - - it("throws when multiple checks match", async () => { - const githubMock = createMockGithub(); - const earlierDate = "2025-04-01T00:00:00Z"; - const laterDate = "2025-04-02T00:00:00Z"; - githubMock.rest.checks.listForRef = vi.fn().mockResolvedValue({ - data: { - check_runs: [ - { - name: "checkRunName", - status: "completed", - conclusion: "success", - completed_at: earlierDate, - }, - { - name: "checkRunName", - status: "completed", - conclusion: "success", - completed_at: laterDate, - }, - ], - }, - }); - - const actual = await await getCheckRuns( - githubMock, - createMockContext(), - "checkRunName", - "head_sha", - ); - - expect(actual).toEqual([ - expect.objectContaining({ - name: "checkRunName", - status: "completed", - conclusion: "success", - completed_at: laterDate, - }), - expect.objectContaining({ - name: "checkRunName", - status: "completed", - conclusion: "success", - completed_at: earlierDate, - }), - ]); - }); +vi.mock("../src/github.js", () => { + return { + getCheckRuns: vi.fn().mockResolvedValue([]), + getWorkflowRuns: vi.fn().mockResolvedValue([]), + getCommitStatuses: vi.fn().mockResolvedValue([]), + }; }); -describe("getWorkflowRuns", () => { - it("returns matching workflow_run", async () => { - const githubMock = createMockGithub(); - githubMock.rest.actions.listWorkflowRunsForRepo = vi.fn().mockResolvedValue({ - data: { - workflow_runs: [ - { - name: "workflowName", - status: "completed", - conclusion: "success", - }, - ], - }, - }); - - const actual = await getWorkflowRuns( - githubMock, - createMockContext(), - "workflowName", - "head_sha", - ); +describe("verifyRunStatusImpl", () => { + // Reset mock call history before each test + beforeEach(() => { + vi.clearAllMocks(); + }); - expect(actual).toEqual([ - expect.objectContaining({ + it("verifies status when check_run event fires", async () => { + const github = createMockGithub(); + const { getWorkflowRuns } = await import("../src/github.js"); + getWorkflowRuns.mockResolvedValue([ + { name: "workflowName", status: "completed", conclusion: "success", - }), - ]); - }); - - it("returns null when no workflow matches", async () => { - const githubMock = createMockGithub(); - githubMock.rest.actions.listWorkflowRunsForRepo = vi.fn().mockResolvedValue({ - data: { - workflow_runs: [ - { - name: "otherWorkflowName", - }, - ], }, - }); - - const actual = await getWorkflowRuns( - githubMock, - createMockContext(), - "workflowName", - "head_sha", - ); - - expect(actual).toEqual([]); - }); - - it("returns latest when multiple workflows match", async () => { - const githubMock = createMockGithub(); - const earlyDate = "2025-04-01T00:00:00Z"; - const laterDate = "2025-04-02T00:00:00Z"; - githubMock.rest.actions.listWorkflowRunsForRepo = vi.fn().mockResolvedValue({ - data: { - workflow_runs: [ - { - name: "workflowName", - status: "completed", - conclusion: "success", - updated_at: earlyDate, - }, - { - name: "workflowName", - status: "completed", - conclusion: "success", - updated_at: laterDate, - }, - ], - }, - }); - - const actual = await getWorkflowRuns( - githubMock, - createMockContext(), - "workflowName", - "head_sha", - ); - - expect(actual).toEqual([ - expect.objectContaining({ - updated_at: laterDate, - }), - expect.objectContaining({ - updated_at: earlyDate, - }), ]); - }); -}); - -describe("verifyRunStatusImpl", () => { - it("verifies status when check_run event fires", async () => { - const github = createMockGithub(); - github.rest.actions.listWorkflowRunsForRepo = vi.fn().mockResolvedValue({ - data: { - workflow_runs: [ - { - name: "workflowName", - status: "completed", - conclusion: "success", - }, - ], - }, - }); - const context = { eventName: "check_run", payload: { @@ -310,12 +133,8 @@ describe("verifyRunStatusImpl", () => { }); it("returns early during check_run event when no matching workflow_run is found", async () => { - const github = createMockGithub(); - github.rest.actions.listWorkflowRunsForRepo = vi.fn().mockResolvedValue({ - data: { - workflow_runs: [], - }, - }); + const { getWorkflowRuns } = await import("../src/github.js"); + getWorkflowRuns.mockResolvedValue([]); const context = { eventName: "check_run", @@ -328,7 +147,7 @@ describe("verifyRunStatusImpl", () => { }; const core = createMockCore(); await verifyRunStatusImpl({ - github, + github: createMockGithub(), context, core, checkRunName: "checkRunName", @@ -365,18 +184,14 @@ describe("verifyRunStatusImpl", () => { }); it("throws if check_run conclusion does not match workflow_run conclusion", async () => { - const github = createMockGithub(); - github.rest.actions.listWorkflowRunsForRepo = vi.fn().mockResolvedValue({ - data: { - workflow_runs: [ - { - name: "workflowName", - status: "completed", - conclusion: "failure", - }, - ], + const { getWorkflowRuns } = await import("../src/github.js"); + getWorkflowRuns.mockResolvedValue([ + { + name: "workflowName", + status: "completed", + conclusion: "failure", }, - }); + ]); const context = { eventName: "check_run", @@ -389,7 +204,7 @@ describe("verifyRunStatusImpl", () => { }; const core = createMockCore(); await verifyRunStatusImpl({ - github, + github: createMockGithub(), context, core, checkRunName: "checkRunName", @@ -430,4 +245,259 @@ describe("verifyRunStatusImpl", () => { "Could not locate check run checkRunName in check suite checkRunName. Ensure job is filtering by github.event.check_suite.app.name.", ); }); + + // Tests for commit status comparison functionality + it("verifies status when status event fires with matching commit status", async () => { + const { getCheckRuns } = await import("../src/github.js"); + getCheckRuns.mockResolvedValue([ + { + name: "checkRunName", + conclusion: "success", + html_url: "https://example.com/check", + }, + ]); + + const context = { + eventName: "status", + payload: { + context: "commitStatusName", + state: "success", + target_url: "https://example.com/status", + }, + }; + + const core = createMockCore(); + + await verifyRunStatusImpl({ + github: createMockGithub(), + context, + core, + checkRunName: "checkRunName", + commitStatusName: "commitStatusName", + }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(core.notice).toHaveBeenCalledWith( + "Conclusions match for check run checkRunName and commit status commitStatusName", + ); + }); + + it("verifies status when neutral check run matches success commit status", async () => { + const { getCheckRuns } = await import("../src/github.js"); + getCheckRuns.mockResolvedValue([ + { + name: "checkRunName", + conclusion: "neutral", + html_url: "https://example.com/check", + }, + ]); + + const context = { + eventName: "status", + payload: { + context: "commitStatusName", + state: "success", + target_url: "https://example.com/status", + }, + }; + + const core = createMockCore(); + + await verifyRunStatusImpl({ + github: createMockGithub(), + context, + core, + checkRunName: "checkRunName", + commitStatusName: "commitStatusName", + }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(core.notice).toHaveBeenCalledWith( + "Conclusions match for check run checkRunName and commit status commitStatusName", + ); + }); + + it("fails when check run conclusion does not match commit status state", async () => { + const { getCheckRuns } = await import("../src/github.js"); + getCheckRuns.mockResolvedValue([ + { + name: "checkRunName", + conclusion: "success", + html_url: "https://example.com/check", + }, + ]); + + const context = { + eventName: "status", + payload: { + context: "commitStatusName", + state: "failure", + target_url: "https://example.com/status", + }, + }; + + const core = createMockCore(); + + await verifyRunStatusImpl({ + github: createMockGithub(), + context, + core, + checkRunName: "checkRunName", + commitStatusName: "commitStatusName", + }); + + expect(core.setFailed).toHaveBeenCalledWith( + "Check run conclusion (success) does not match commit status state (failure)", + ); + }); + + it("skips comparison when commit status is pending", async () => { + const { getCheckRuns } = await import("../src/github.js"); + getCheckRuns.mockResolvedValue([ + { + name: "checkRunName", + conclusion: "success", + html_url: "https://example.com/check", + }, + ]); + + const context = { + eventName: "status", + payload: { + context: "commitStatusName", + state: "pending", + target_url: "https://example.com/status", + }, + }; + + const core = createMockCore(); + + await verifyRunStatusImpl({ + github: createMockGithub(), + context, + core, + checkRunName: "checkRunName", + commitStatusName: "commitStatusName", + }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(core.notice).toHaveBeenCalledWith( + "Commit status is in pending state. Skipping comparison with check run conclusion.", + ); + }); + + it("fetches commit status from API when not status event", async () => { + const { getCheckRuns, getCommitStatuses } = await import("../src/github.js"); + getCheckRuns.mockResolvedValue([ + { + name: "checkRunName", + conclusion: "success", + html_url: "https://example.com/check", + }, + ]); + getCommitStatuses.mockResolvedValue([ + { + context: "commitStatusName", + state: "success", + target_url: "https://example.com/status", + }, + ]); + + const context = { + eventName: "workflow_run", + payload: { + workflow_run: { + name: "workflowName", + conclusion: "success", + }, + }, + }; + + const core = createMockCore(); + + await verifyRunStatusImpl({ + github: createMockGithub(), + context, + core, + checkRunName: "checkRunName", + commitStatusName: "commitStatusName", + workflowName: "workflowName", + }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(core.notice).toHaveBeenCalledWith( + "Conclusions match for check run checkRunName and commit status commitStatusName", + ); + }); + + it("handles API error when fetching commit status", async () => { + const { getCheckRuns, getCommitStatuses } = await import("../src/github.js"); + getCheckRuns.mockResolvedValue([ + { + name: "checkRunName", + conclusion: "success", + html_url: "https://example.com/check", + }, + ]); + getCommitStatuses.mockRejectedValue(new Error("API Error")); + + const context = { + eventName: "workflow_run", + payload: { + workflow_run: { + name: "workflowName", + conclusion: "success", + }, + }, + }; + + const core = createMockCore(); + + await verifyRunStatusImpl({ + github: createMockGithub(), + context, + core, + checkRunName: "checkRunName", + commitStatusName: "commitStatusName", + workflowName: "workflowName", + }); + + expect(core.setFailed).toHaveBeenCalledWith("Failed to fetch commit status: API Error"); + }); + + it("verifies neutral check run matches success workflow run", async () => { + const { getCheckRuns } = await import("../src/github.js"); + getCheckRuns.mockResolvedValue([ + { + name: "checkRunName", + conclusion: "neutral", + html_url: "https://example.com/check", + }, + ]); + + const context = { + eventName: "workflow_run", + payload: { + workflow_run: { + name: "workflowName", + conclusion: "success", + }, + }, + }; + + const core = createMockCore(); + + await verifyRunStatusImpl({ + github: createMockGithub(), + context, + core, + checkRunName: "checkRunName", + workflowName: "workflowName", + }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(core.notice).toHaveBeenCalledWith( + "Conclusions match for check run checkRunName and workflow run workflowName", + ); + }); }); diff --git a/.github/workflows/watch-avocado.yaml b/.github/workflows/watch-avocado.yaml index 63db5aad195c..c083b034d3ae 100644 --- a/.github/workflows/watch-avocado.yaml +++ b/.github/workflows/watch-avocado.yaml @@ -10,7 +10,7 @@ on: workflow_run: types: completed workflows: - - "\\[TEST-IGNORE\\] Swagger Avocado - Analyze Code" + - "\\[TEST-IGNORE\\] Swagger Avocado - Set Status" permissions: checks: read @@ -22,4 +22,4 @@ jobs: uses: ./.github/workflows/_reusable-verify-run-status.yaml with: check_run_name: "Swagger Avocado" - workflow_name: "[TEST-IGNORE] Swagger Avocado - Analyze Code" + commit_status_name: "[TEST-IGNORE] Swagger Avocado" diff --git a/.github/workflows/watch-breakingchange.yaml b/.github/workflows/watch-breakingchange.yaml index 07985c86f753..9c14cbe57cb2 100644 --- a/.github/workflows/watch-breakingchange.yaml +++ b/.github/workflows/watch-breakingchange.yaml @@ -10,7 +10,7 @@ on: workflow_run: types: completed workflows: - - "\\[TEST-IGNORE\\] Swagger BreakingChange - Analyze Code" + - "\\[TEST-IGNORE\\] Swagger BreakingChange - Set Status" permissions: checks: read @@ -22,4 +22,4 @@ jobs: uses: ./.github/workflows/_reusable-verify-run-status.yaml with: check_run_name: "Swagger BreakingChange" - workflow_name: "[TEST-IGNORE] Swagger BreakingChange - Analyze Code" + commit_status_name: "[TEST-IGNORE] Swagger BreakingChange"