From 37ba2ce7f2e947c6732c0a5210f6be8a4767ce01 Mon Sep 17 00:00:00 2001 From: Alex Miller Date: Wed, 29 Jul 2026 14:25:20 +1200 Subject: [PATCH 1/6] feat: use new dispatch API body, remove manual ID trawling --- .github/workflows/action.yml | 2 - .github/workflows/dispatch.yml | 4 - README.md | 110 +- action.yml | 10 - .../return-dispatch.spec.ts.snap | 35 - src/action.spec.ts | 41 - src/action.ts | 38 - src/api.spec.ts | 954 ++---------------- src/api.ts | 312 ++---- src/constants.ts | 5 - src/etags.ts | 68 -- src/main.spec.ts | 178 +--- src/main.ts | 47 +- src/return-dispatch.spec.ts | 860 ---------------- src/return-dispatch.ts | 196 ---- src/types.ts | 16 - src/utils.spec.ts | 251 ----- src/utils.ts | 102 -- 18 files changed, 202 insertions(+), 3027 deletions(-) delete mode 100644 src/__snapshots__/return-dispatch.spec.ts.snap delete mode 100644 src/constants.ts delete mode 100644 src/etags.ts delete mode 100644 src/return-dispatch.spec.ts delete mode 100644 src/return-dispatch.ts delete mode 100644 src/types.ts delete mode 100644 src/utils.spec.ts delete mode 100644 src/utils.ts diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml index 4a7d11fa..19678949 100644 --- a/.github/workflows/action.yml +++ b/.github/workflows/action.yml @@ -25,8 +25,6 @@ jobs: owner: codex- workflow: dispatch.yml workflow_inputs: '{"cake":"delicious"}' - workflow_timeout_seconds: 30 - workflow_job_steps_retry_seconds: 10 - name: Evaluate that the Run ID output has been set run: | if [ "${{ steps.return_dispatch.outputs.run_id }}" == "" ]; then diff --git a/.github/workflows/dispatch.yml b/.github/workflows/dispatch.yml index f9ccd432..8df40c53 100644 --- a/.github/workflows/dispatch.yml +++ b/.github/workflows/dispatch.yml @@ -2,8 +2,6 @@ name: action-test on: workflow_dispatch: inputs: - distinct_id: - required: true cake: # Test for input passthrough. required: true @@ -11,7 +9,5 @@ jobs: test: runs-on: ubuntu-latest steps: - - name: echo distinct ID ${{ github.event.inputs.distinct_id }} - run: echo ${{ github.event.inputs.distinct_id }} - name: echo input passthrough run: echo ${{ github.event.inputs.cake }} diff --git a/README.md b/README.md index 0a0b86cb..5fd5fb0b 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,15 @@ # GitHub Action: return-dispatch -[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/codex-/return-dispatch/test.yml?style=flat-square)](https://github.com/Codex-/return-dispatch/actions/workflows/test.yml) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) [![codecov](https://img.shields.io/codecov/c/github/Codex-/return-dispatch?style=flat-square)](https://codecov.io/gh/Codex-/return-dispatch) [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-return–dispatch-blue.svg?colorA=24292e&colorB=0366d6&style=flat-square&longCache=true&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAM6wAADOsB5dZE0gAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAERSURBVCiRhZG/SsMxFEZPfsVJ61jbxaF0cRQRcRJ9hlYn30IHN/+9iquDCOIsblIrOjqKgy5aKoJQj4O3EEtbPwhJbr6Te28CmdSKeqzeqr0YbfVIrTBKakvtOl5dtTkK+v4HfA9PEyBFCY9AGVgCBLaBp1jPAyfAJ/AAdIEG0dNAiyP7+K1qIfMdonZic6+WJoBJvQlvuwDqcXadUuqPA1NKAlexbRTAIMvMOCjTbMwl1LtI/6KWJ5Q6rT6Ht1MA58AX8Apcqqt5r2qhrgAXQC3CZ6i1+KMd9TRu3MvA3aH/fFPnBodb6oe6HM8+lYHrGdRXW8M9bMZtPXUji69lmf5Cmamq7quNLFZXD9Rq7v0Bpc1o/tp0fisAAAAASUVORK5CYII=)](https://github.com/marketplace/actions/return-dispatch) +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/codex-/return-dispatch/test.yml?style=flat-square)](https://github.com/Codex-/return-dispatch/actions/workflows/test.yml) [![codecov](https://img.shields.io/codecov/c/github/Codex-/return-dispatch?style=flat-square)](https://codecov.io/gh/Codex-/return-dispatch) [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-return–dispatch-blue.svg?colorA=24292e&colorB=0366d6&style=flat-square&longCache=true&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAM6wAADOsB5dZE0gAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAERSURBVCiRhZG/SsMxFEZPfsVJ61jbxaF0cRQRcRJ9hlYn30IHN/+9iquDCOIsblIrOjqKgy5aKoJQj4O3EEtbPwhJbr6Te28CmdSKeqzeqr0YbfVIrTBKakvtOl5dtTkK+v4HfA9PEyBFCY9AGVgCBLaBp1jPAyfAJ/AAdIEG0dNAiyP7+K1qIfMdonZic6+WJoBJvQlvuwDqcXadUuqPA1NKAlexbRTAIMvMOCjTbMwl1LtI/6KWJ5Q6rT6Ht1MA58AX8Apcqqt5r2qhrgAXQC3CZ6i1+KMd9TRu3MvA3aH/fFPnBodb6oe6HM8+lYHrGdRXW8M9bMZtPXUji69lmf5Cmamq7quNLFZXD9Rq7v0Bpc1o/tp0fisAAAAASUVORK5CYII=)](https://github.com/marketplace/actions/return-dispatch) Dispatch an action to a foreign repository and output the newly created run ID. -This Action exists as a workaround for the issue where dispatching an action to foreign repository does not return any kind of identifier. - The returned run ID can be used to await the completion of the remote run using [`await-remote-run`](https://github.com/Codex-/await-remote-run). ## Usage -Ensure you have configured your remote action correctly, see below for an example. - -### Dispatching Repository Action +The dispatched workflow needs no special setup. It only has to accept the `workflow_dispatch` +event and declare any inputs you pass via `workflow_inputs`. ```yaml steps: @@ -26,12 +23,6 @@ steps: owner: repository-owner workflow: automation-test.yml workflow_inputs: '{ "some_input": "value" }' # Optional - workflow_timeout_seconds: 120 # Default: 300 - workflow_job_steps_retry_seconds: - # Lineal backoff retry attempts are made where the attempt count is - # the magnitude and the scaling value is `workflow_job_steps_retry_seconds` - 10 # Default: 5 - distinct_id: someDistinctId # Optional - name: Use the output run ID and URL run: | @@ -47,101 +38,32 @@ steps: run_id: ${{ steps.return_dispatch.outputs.run_id }} ``` -### Receiving Repository Action - -In the earliest possible stage for the Action, add the input into the name. - -As every step needs a `uses` or `run`, simply `echo` the ID or similar to satisfy this requirement. - -```yaml -name: action-test -on: - workflow_dispatch: - inputs: - distinct_id: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: echo distinct ID ${{ inputs.distinct_id }} - run: echo "${DISTINCT_ID}" - env: - DISTINCT_ID: ${{ inputs.distinct_id }} -``` - ## Token -To be able to use dispatch we need to use a token which has `repo` permissions. `GITHUB_TOKEN` currently does not allow adding permissions for `repo` level permissions currently so a Personal Access Token (PAT) must be used. +`GITHUB_TOKEN` can only access the repository containing the workflow, so dispatching +to another repository requires a Personal Access Token (PAT). Dispatching within the +same repository works with `GITHUB_TOKEN`, provided it is granted `actions: write`. ### Permissions Required -The permissions required for this action to function correctly are: +One of the following, depending on the token type: -- `repo` scope - - You may get away with simply having `repo:public_repo` - - `repo` is definitely needed if the repository is private. -- `actions:read` -- `actions:write` +- Fine-grained PAT, GitHub App, or `GITHUB_TOKEN`: `Actions` repository permission, **write** +- Classic PAT or OAuth token: `repo` scope ### APIs Used -For the sake of transparency please note that this action uses the following API calls: - - [Create a workflow dispatch event](https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event) - POST `/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches` - - Permissions: - - `repo` - - `actions:write` -- [List repository workflows](https://docs.github.com/en/rest/actions/workflows#list-repository-workflows) - - GET `/repos/{owner}/{repo}/actions/workflows` - - Permissions: - - `repo` - - `actions:read` -- [List workflow runs](https://docs.github.com/en/rest/actions/workflow-runs#list-workflow-runs-for-a-repository) - - GET `/repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs` - - Permissions: - - `repo` -- [List jobs for a workflow run](https://docs.github.com/en/rest/actions/workflow-jobs#list-jobs-for-a-workflow-run) - - GET `/repos/{owner}/{repo}/actions/runs/{run_id}/jobs` - - Permissions: - - `repo` - - `actions:read` + - Sent with `return_run_details: true`, so the response carries the new run's ID and URL For more information please see [api.ts](./src/api.ts). ## Where does this help? -If you have an action in a repository that dispatches an action on a foreign repository currently with Github API there is no way to know what the foreign run you've just dispatched is. Identifying this can be cumbersome and tricky. - -The consequence of not being provided with something to identify the run is that you cannot easily wait for this run or poll the run for it's completion status (success, failure, etc). - -## Flow - -```ascii -┌─────────────────┐ -│ │ -│ Dispatch Action │ -│ │ -│ with unique ID │ -│ │ -└───────┬─────────┘ - │ - │ - ▼ ┌───────────────┐ -┌────────────────┐ │ │ -│ │ │ Request steps │ -│ Request top 10 ├────────────────►│ │ -│ │ │ for each run │ -│ workflow runs │ │ │ -│ │◄────────────────┤ and search │ -└───────┬────────┘ Retry │ │ - │ └───────┬───────┘ - │ │ -Timeout │ │ - │ │ - ▼ ▼ - ┌──────┐ ┌───────────────┐ - │ Fail │ │ Output run ID │ - └──────┘ └───────────────┘ -``` +If you have an action in a repository that dispatches an action on a foreign repository, +you need to know which foreign run you've just dispatched before you can wait for it or +poll it for a completion status (success, failure, etc). + +This Action gets you that run ID and URL as step outputs, so you can hand them straight +to something like [`await-remote-run`](https://github.com/Codex-/await-remote-run). diff --git a/action.yml b/action.yml index 9a913bd1..842e8503 100644 --- a/action.yml +++ b/action.yml @@ -27,16 +27,6 @@ inputs: required: true workflow_inputs: description: A flat JSON object, only supports strings, numbers, and booleans (as per workflow inputs API). - workflow_timeout_seconds: - description: Time until giving up waiting for the start of the workflow run. - default: 300 - workflow_job_steps_retry_seconds: - description: | - The interval (in seconds) to wait between retries. A linear backoff strategy is used, where the wait time - increases by this value with each attempt (e.g., 1st retry = this value, 2nd retry = 2x this value, etc.). - default: 5 - distinct_id: - description: Specify a static string to use instead of a random distinct ID. runs: using: node24 diff --git a/src/__snapshots__/return-dispatch.spec.ts.snap b/src/__snapshots__/return-dispatch.spec.ts.snap deleted file mode 100644 index acb6fee3..00000000 --- a/src/__snapshots__/return-dispatch.spec.ts.snap +++ /dev/null @@ -1,35 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`return-dispatch > getRunIdAndUrl > called fetchWorkflowRunIds with the provided workflowId and branch 1`] = `"Attempting to get step names for Run IDs: [0]"`; - -exports[`return-dispatch > getRunIdAndUrl > should call retryOrTimeout with the larger workflowTimeoutMs timeout value 1`] = `"Attempting to get step names for Run IDs: [0]"`; - -exports[`return-dispatch > getRunIdAndUrl > should retry until an ID is found 1`] = `"No Run IDs found for workflow, attempt 1..."`; - -exports[`return-dispatch > getRunIdAndUrl > should retry until an ID is found 2`] = `"Waiting for 5000ms before the next attempt..."`; - -exports[`return-dispatch > getRunIdAndUrl > should retry until an ID is found 3`] = `"No Run IDs found for workflow, attempt 2..."`; - -exports[`return-dispatch > getRunIdAndUrl > should retry until an ID is found 4`] = `"Waiting for 10000ms before the next attempt..."`; - -exports[`return-dispatch > getRunIdAndUrl > should retry until an ID is found 5`] = `"Attempting to get step names for Run IDs: [0]"`; - -exports[`return-dispatch > getRunIdAndUrl > should return the ID when found 1`] = `"Attempting to get step names for Run IDs: [0]"`; - -exports[`return-dispatch > getRunIdAndUrl > should return the ID when found 2`] = `undefined`; - -exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 1`] = `"Exhausted searching IDs in known runs, attempt 1..."`; - -exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 2`] = `"Waiting for 3000ms before the next attempt..."`; - -exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 3`] = `"Attempting to get step names for Run IDs: [0]"`; - -exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 4`] = `"Exhausted searching IDs in known runs, attempt 2..."`; - -exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 5`] = `"Waiting for 6000ms before the next attempt..."`; - -exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 6`] = `"Attempting to get step names for Run IDs: [0]"`; - -exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 7`] = `"Exhausted searching IDs in known runs, attempt 3..."`; - -exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 8`] = `"Attempting to get step names for Run IDs: [0]"`; diff --git a/src/action.spec.ts b/src/action.spec.ts index c006461a..9acf9480 100644 --- a/src/action.spec.ts +++ b/src/action.spec.ts @@ -1,13 +1,8 @@ -import { randomUUID } from "node:crypto"; - import * as core from "@actions/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type ActionConfig, getConfig } from "./action.ts"; -vi.mock("node:crypto", () => ({ - randomUUID: vi.fn(), -})); vi.mock("@actions/core"); describe("Action", () => { @@ -27,9 +22,6 @@ describe("Action", () => { owner: "owner", workflow: "workflow_name", workflow_inputs: JSON.stringify(workflowInputs), - workflow_timeout_seconds: "60", - workflow_job_steps_retry_seconds: "3", - distinct_id: "distinct_id", }; vi.spyOn(core, "getInput").mockImplementation((input: string): string => { @@ -47,12 +39,6 @@ describe("Action", () => { return mockEnvConfig.workflow; case "workflow_inputs": return mockEnvConfig.workflow_inputs; - case "workflow_timeout_seconds": - return mockEnvConfig.workflow_timeout_seconds; - case "workflow_job_steps_retry_seconds": - return mockEnvConfig.workflow_job_steps_retry_seconds; - case "distinct_id": - return mockEnvConfig.distinct_id; default: throw new Error("invalid input requested"); } @@ -74,9 +60,6 @@ describe("Action", () => { expect(config.owner).toStrictEqual("owner"); expect(config.workflow).toStrictEqual("workflow_name"); expect(config.workflowInputs).toStrictEqual(workflowInputs); - expect(config.workflowTimeoutSeconds).toStrictEqual(60); - expect(config.workflowJobStepsRetrySeconds).toStrictEqual(3); - expect(config.distinctId).toStrictEqual("distinct_id"); }); it("should have a number for a workflow when given a workflow ID", () => { @@ -86,20 +69,6 @@ describe("Action", () => { expect(config.workflow).toStrictEqual(123456); }); - it("should provide a default workflow timeout if none is supplied", () => { - mockEnvConfig.workflow_timeout_seconds = ""; - const config: ActionConfig = getConfig(); - - expect(config.workflowTimeoutSeconds).toStrictEqual(300); - }); - - it("should provide a default workflow job step retry if none is supplied", () => { - mockEnvConfig.workflow_job_steps_retry_seconds = ""; - const config: ActionConfig = getConfig(); - - expect(config.workflowJobStepsRetrySeconds).toStrictEqual(5); - }); - it("should handle no inputs being provided", () => { mockEnvConfig.workflow_inputs = ""; const config: ActionConfig = getConfig(); @@ -136,15 +105,5 @@ describe("Action", () => { callAndAssert('{"vegetable":null}', '"vegetable" value is null'); callAndAssert('{"fruit":[]}', '"fruit" value is Array'); }); - - it("should handle no distinct_id being provided", () => { - const v4Mock = vi.mocked(randomUUID); - v4Mock.mockImplementationOnce(() => "test-mocked-uuid-is-used"); - mockEnvConfig.distinct_id = ""; - const config: ActionConfig = getConfig(); - - expect(config.distinctId).toStrictEqual("test-mocked-uuid-is-used"); - expect(v4Mock).toHaveBeenCalledOnce(); - }); }); }); diff --git a/src/action.ts b/src/action.ts index 7ac5254a..973c9fde 100644 --- a/src/action.ts +++ b/src/action.ts @@ -1,10 +1,5 @@ -import { randomUUID } from "node:crypto"; - import * as core from "@actions/core"; -const WORKFLOW_TIMEOUT_SECONDS = 5 * 60; -const WORKFLOW_JOB_STEPS_RETRY_SECONDS = 5; - /** * action.yaml definition. */ @@ -38,21 +33,6 @@ export interface ActionConfig { * A flat JSON object, only supports strings (as per workflow inputs API). */ workflowInputs?: ActionWorkflowInputs; - - /** - * Time until giving up on identifying the Run ID. - */ - workflowTimeoutSeconds: number; - - /** - * Time in retries for identifying the Run ID. - */ - workflowJobStepsRetrySeconds: number; - - /** - * Specify a static ID to use instead of a distinct ID. - */ - distinctId: string; } type ActionWorkflowInputs = Record; @@ -72,14 +52,6 @@ export function getConfig(): ActionConfig { core.getInput("workflow", { required: true }), ), workflowInputs: getWorkflowInputs(core.getInput("workflow_inputs")), - workflowTimeoutSeconds: - getNumberFromValue(core.getInput("workflow_timeout_seconds")) ?? - WORKFLOW_TIMEOUT_SECONDS, - workflowJobStepsRetrySeconds: - getNumberFromValue(core.getInput("workflow_job_steps_retry_seconds")) ?? - WORKFLOW_JOB_STEPS_RETRY_SECONDS, - distinctId: - getOptionalWorkflowValue(core.getInput("distinct_id")) ?? randomUUID(), }; } @@ -141,13 +113,3 @@ function tryGetWorkflowAsNumber(workflowInput: string): string | number { return workflowInput; } } - -/** - * We want empty strings to simply be undefined. - * - * While simple, make it very clear that the usage of `||` - * is intentional here. - */ -function getOptionalWorkflowValue(workflowInput: string): string | undefined { - return workflowInput || undefined; -} diff --git a/src/api.spec.ts b/src/api.spec.ts index 97fbe23b..3b0f99b8 100644 --- a/src/api.spec.ts +++ b/src/api.spec.ts @@ -10,19 +10,8 @@ import { vi, } from "vitest"; -import type { ActionConfig } from "./action.ts"; -import { - dispatchWorkflow, - fetchWorkflowId, - fetchWorkflowRunIds, - fetchWorkflowRunJobSteps, - fetchWorkflowRunUrl, - init, - retryOrTimeout, -} from "./api.ts"; -import { clearEtags } from "./etags.ts"; +import { dispatchWorkflow, init } from "./api.ts"; import { mockLoggingFunctions } from "./test-utils/logging.mock.ts"; -import { getBranchName } from "./utils.ts"; vi.mock("@actions/core"); vi.mock("@actions/github"); @@ -33,44 +22,21 @@ interface MockResponse { headers: Record; } -function* mockPageIterator( - apiMethod: (params: P) => T, - params: P, -): Generator { - yield apiMethod(params); -} - const mockOctokit = { rest: { actions: { createWorkflowDispatch: (_req?: any): Promise => { throw new Error("Should be mocked"); }, - getWorkflowRun: (_req?: any): Promise => { - throw new Error("Should be mocked"); - }, - listRepoWorkflows: (_req?: any): Promise => { - throw new Error("Should be mocked"); - }, - listWorkflowRuns: (_req?: any): Promise => { - throw new Error("Should be mocked"); - }, - downloadWorkflowRunLogs: (_req?: any): Promise => { - throw new Error("Should be mocked"); - }, - listJobsForWorkflowRun: (_req?: any): Promise => { - throw new Error("Should be mocked"); - }, }, }, - paginate: { - iterator: mockPageIterator, - }, }; -afterEach(() => { - clearEtags(); -}); +const runDetails = { + workflow_run_id: 123456, + run_url: "https://api.github.com/repos/owner/repo/actions/runs/123456", + html_url: "https://github.com/owner/repo/actions/runs/123456", +}; describe("API", () => { const { @@ -99,10 +65,6 @@ describe("API", () => { return "workflow"; case "workflow_inputs": return JSON.stringify({ testInput: "test" }); - case "workflow_timeout_seconds": - return "30"; - case "workflow_job_steps_retry_seconds": - return "5"; default: return ""; } @@ -117,20 +79,31 @@ describe("API", () => { }); describe("dispatchWorkflow", () => { - it("should resolve after a successful dispatch", async () => { + it("should return the run ID and html_url from the dispatch response", async () => { + let dispatchedRequest: Record | undefined; vi.spyOn( mockOctokit.rest.actions, "createWorkflowDispatch", - ).mockReturnValue( - Promise.resolve({ - data: undefined, - status: 204, + ).mockImplementation((req?: any) => { + dispatchedRequest = req as Record; + + return Promise.resolve({ + data: runDetails, + status: 200, headers: {}, - }), - ); + }); + }); // Behaviour - await expect(dispatchWorkflow("")).resolves.not.toThrow(); + await expect(dispatchWorkflow()).resolves.toStrictEqual({ + id: 123456, + url: "https://github.com/owner/repo/actions/runs/123456", + }); + + // The run details are opt-in, so the request must ask for them + expect(dispatchedRequest?.return_run_details).toStrictEqual(true); + // Only the caller's inputs are forwarded, nothing is injected + expect(dispatchedRequest?.inputs).toStrictEqual({ testInput: "test" }); // Logging assertOnlyCalled(coreInfoLogMock); @@ -141,614 +114,63 @@ describe("API", () => { Branch: ref Workflow: workflow Workflow Inputs: {"testInput":"test"} - Distinct ID: " + Run ID: 123456 + Run URL: https://github.com/owner/repo/actions/runs/123456" `); }); - it("should throw if a non-200 or non-204 status is returned", async () => { - const errorStatus = 401; + it("should throw for an empty 204, as returned by servers without return_run_details support", async () => { vi.spyOn( mockOctokit.rest.actions, "createWorkflowDispatch", ).mockReturnValue( Promise.resolve({ data: undefined, - status: errorStatus, - headers: {}, - }), - ); - - // Behaviour - await expect(dispatchWorkflow("")).rejects.toThrow( - `Failed to dispatch action, expected 200 or 204 but received ${errorStatus}`, - ); - - // Logging - assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); - expect(coreErrorLogMock).toHaveBeenCalledOnce(); - expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"dispatchWorkflow: An unexpected error has occurred: Failed to dispatch action, expected 200 or 204 but received 401"`, - ); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - }); - - it.each([200, 204])( - "should dispatch with a distinctId in the inputs and handle a %d response", - async (statusCode) => { - const distinctId = "50b4f5fa-f9ce-4661-80e6-6d660a4a3a0d"; - let dispatchedId: string | undefined; - vi.spyOn( - mockOctokit.rest.actions, - "createWorkflowDispatch", - ).mockImplementation((req?: any) => { - dispatchedId = req.inputs.distinct_id; - - return Promise.resolve({ - data: undefined, - status: statusCode, - headers: {}, - }); - }); - - // Behaviour - await expect(dispatchWorkflow(distinctId)).resolves.not.toThrow(); - expect(dispatchedId).toStrictEqual(distinctId); - - // Logging - assertOnlyCalled(coreInfoLogMock); - expect(coreInfoLogMock).toHaveBeenCalledOnce(); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatch( - "Successfully dispatched workflow:\n" + - " Repository: owner/repo\n" + - " Branch: ref\n" + - " Workflow: workflow\n" + - ' Workflow Inputs: {"testInput":"test"}\n' + - " Distinct ID: 50b4f5fa-f9ce-4661-80e6-6d660a4a3a0d", - ); - }, - ); - }); - - describe("fetchWorkflowId", () => { - it("should return the workflow ID for a given workflow filename", async () => { - const mockData = [ - { - id: 0, - path: ".github/workflows/cake.yml", - }, - { - id: 1, - path: ".github/workflows/pie.yml", - }, - { - id: 2, - path: ".github/workflows/slice.yml", - }, - ]; - vi.spyOn(mockOctokit.rest.actions, "listRepoWorkflows").mockReturnValue( - Promise.resolve({ - data: mockData, - status: 200, - headers: {}, - }), - ); - - // Behaviour - expect(await fetchWorkflowId("slice.yml")).toStrictEqual(mockData[2]!.id); - - // Logging - assertOnlyCalled(coreInfoLogMock); - expect(coreInfoLogMock).toHaveBeenCalledOnce(); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Fetched Workflow ID: - Repository: owner/repo - Workflow ID: '2' - Input Filename: 'slice.yml' - Sanitised Filename: 'slice\\.yml' - URL: undefined" - `, - ); - }); - - it("should throw if a non-200 status is returned", async () => { - const errorStatus = 401; - vi.spyOn(mockOctokit.rest.actions, "listRepoWorkflows").mockReturnValue( - Promise.resolve({ - data: undefined, - status: errorStatus, - headers: {}, - }), - ); - - // Behaviour - await expect(fetchWorkflowId("implode")).rejects.toThrow( - `Failed to fetch workflows, expected 200 but received ${errorStatus}`, - ); - - // Logging - assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); - expect(coreErrorLogMock).toHaveBeenCalledOnce(); - expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"fetchWorkflowId: An unexpected error has occurred: Failed to fetch workflows, expected 200 but received 401"`, - ); - }); - - it("should throw if a given workflow name cannot be found in the response", async () => { - const workflowName = "slice"; - vi.spyOn(mockOctokit.rest.actions, "listRepoWorkflows").mockReturnValue( - Promise.resolve({ - data: [], - status: 200, - headers: {}, - }), - ); - - // Behaviour - await expect(fetchWorkflowId(workflowName)).rejects.toThrow( - `Unable to find ID for Workflow: ${workflowName}`, - ); - - // Logging - assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); - expect(coreErrorLogMock).toHaveBeenCalledOnce(); - expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"fetchWorkflowId: An unexpected error has occurred: Unable to find ID for Workflow: slice"`, - ); - }); - - it("should return the workflow ID when the name is a substring of another workflow name", async () => { - const mockData = [ - { - id: 0, - path: ".github/workflows/small-cake.yml", - }, - { - id: 1, - path: ".github/workflows/big-cake.yml", - }, - { - id: 2, - path: ".github/workflows/cake.yml", - }, - ]; - vi.spyOn(mockOctokit.rest.actions, "listRepoWorkflows").mockReturnValue( - Promise.resolve({ - data: mockData, - status: 200, - headers: {}, - }), - ); - - // Behaviour - expect(await fetchWorkflowId("cake.yml")).toStrictEqual(mockData[2]!.id); - - // Logging - assertOnlyCalled(coreInfoLogMock); - expect(coreInfoLogMock).toHaveBeenCalledOnce(); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Fetched Workflow ID: - Repository: owner/repo - Workflow ID: '2' - Input Filename: 'cake.yml' - Sanitised Filename: 'cake\\.yml' - URL: undefined" - `, - ); - }); - }); - - describe("fetchWorkflowRunIds", () => { - const startTimeISO = "2025-06-17T22:24:23.238Z"; - const workflowIdCfg: ActionConfig = { - token: "secret", - ref: "/refs/heads/feature_branch", - repo: "repository", - owner: "owner", - workflow: "workflow_name", - workflowInputs: { testInput: "test" }, - workflowTimeoutSeconds: 60, - workflowJobStepsRetrySeconds: 3, - distinctId: "test-uuid", - }; - - beforeEach(() => { - init(workflowIdCfg); - }); - - it("should get the run IDs for a given workflow ID", async () => { - const branch = getBranchName(workflowIdCfg.ref); - coreDebugLogMock.mockReset(); - - const mockData = { - total_count: 3, - workflow_runs: [{ id: 0 }, { id: 1 }, { id: 2 }], - }; - vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockReturnValue( - Promise.resolve({ - data: mockData, - status: 200, - headers: {}, - }), - ); - - // Behaviour - await expect( - fetchWorkflowRunIds(0, branch, startTimeISO), - ).resolves.toStrictEqual(mockData.workflow_runs.map((run) => run.id)); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Fetched Workflow Runs: - Repository: owner/repository - Branch Filter: true (feature_branch) - Workflow ID: 0 - Created: >=2025-06-17T22:24:23.238Z - Runs Fetched: [0, 1, 2]" - `, - ); - }); - - it("should throw if a non-200 status is returned", async () => { - const branch = getBranchName(workflowIdCfg.ref); - coreDebugLogMock.mockReset(); - - const errorStatus = 401; - vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockReturnValue( - Promise.resolve({ - data: undefined, - status: errorStatus, + status: 204, headers: {}, }), ); // Behaviour - await expect( - fetchWorkflowRunIds(0, branch, startTimeISO), - ).rejects.toThrow( - `Failed to fetch Workflow runs, expected 200 but received ${errorStatus}`, + await expect(dispatchWorkflow()).rejects.toThrow( + "Dispatch did not return the run details, this action requires github.com or GHES >=3.21", ); // Logging assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); - expect(coreErrorLogMock).toHaveBeenCalled(); expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"fetchWorkflowRunIds: An unexpected error has occurred: Failed to fetch Workflow runs, expected 200 but received 401"`, - ); - }); - - it("should return an empty array if there are no runs", async () => { - const branch = getBranchName(workflowIdCfg.ref); - coreDebugLogMock.mockReset(); - - const mockData = { - total_count: 0, - workflow_runs: [], - }; - vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockReturnValue( - Promise.resolve({ - data: mockData, - status: 200, - headers: {}, - }), - ); - - // Behaviour - await expect( - fetchWorkflowRunIds(0, branch, startTimeISO), - ).resolves.toStrictEqual([]); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Fetched Workflow Runs: - Repository: owner/repository - Branch Filter: true (feature_branch) - Workflow ID: 0 - Created: >=2025-06-17T22:24:23.238Z - Runs Fetched: []" - `, - ); - }); - - it("should filter by branch name", async () => { - const branch = getBranchName("/refs/heads/master"); - coreDebugLogMock.mockReset(); - - let parsedRef!: string; - vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockImplementation( - (req: any) => { - parsedRef = req.branch; - const mockResponse: MockResponse = { - data: { - total_count: 0, - workflow_runs: [], - }, - status: 200, - headers: {}, - }; - return Promise.resolve(mockResponse); - }, - ); - - // Behaviour - await expect( - fetchWorkflowRunIds(0, branch, startTimeISO), - ).resolves.not.toThrow(); - expect(parsedRef).toStrictEqual("master"); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Fetched Workflow Runs: - Repository: owner/repository - Branch Filter: true (master) - Workflow ID: 0 - Created: >=2025-06-17T22:24:23.238Z - Runs Fetched: []" - `, + `"dispatchWorkflow: An unexpected error has occurred: Dispatch did not return the run details, this action requires github.com or GHES >=3.21"`, ); }); - it("should not use a branch filter if using a tag ref", async () => { - const branch = getBranchName("/refs/tags/1.5.0"); - coreDebugLogMock.mockReset(); - - let parsedRef!: string; - vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockImplementation( - (req: any) => { - parsedRef = req.branch; - const mockResponse: MockResponse = { - data: { - total_count: 0, - workflow_runs: [], - }, - status: 200, - headers: {}, - }; - return Promise.resolve(mockResponse); - }, - ); - - // Behaviour - await expect( - fetchWorkflowRunIds(0, branch, startTimeISO), - ).resolves.not.toThrow(); - expect(parsedRef).toBeUndefined(); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Fetched Workflow Runs: - Repository: owner/repository - Branch Filter: false (/refs/tags/1.5.0) - Workflow ID: 0 - Created: >=2025-06-17T22:24:23.238Z - Runs Fetched: []" - `, - ); - }); - - it("should not use a branch filter if non-standard ref", async () => { - const branch = getBranchName("/refs/cake"); - coreDebugLogMock.mockReset(); - - let parsedRef!: string; - vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockImplementation( - (req: any) => { - parsedRef = req.branch; - const mockResponse: MockResponse = { - data: { - total_count: 0, - workflow_runs: [], - }, - status: 200, - headers: {}, - }; - return Promise.resolve(mockResponse); - }, - ); - - // Behaviour - await expect( - fetchWorkflowRunIds(0, branch, startTimeISO), - ).resolves.not.toThrow(); - expect(parsedRef).toBeUndefined(); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Fetched Workflow Runs: - Repository: owner/repository - Branch Filter: false (/refs/cake) - Workflow ID: 0 - Created: >=2025-06-17T22:24:23.238Z - Runs Fetched: []" - `, - ); - }); - - it("should send the previous etag in the If-None-Match header", async () => { - const branch = getBranchName(workflowIdCfg.ref); - coreDebugLogMock.mockReset(); - - const mockData = { - total_count: 0, - workflow_runs: [], - }; - const etag = - "37c2311495bbea359329d0bb72561bdb2b2fffea1b7a54f696b5a287e7ccad1e"; - let submittedEtag = null; - vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockImplementation( - ({ headers }) => { - if (headers?.["If-None-Match"]) { - submittedEtag = headers["If-None-Match"]; - return Promise.resolve({ - data: null, - status: 304, - headers: { - etag: `W/"${submittedEtag}"`, - }, - }); - } - return Promise.resolve({ - data: mockData, - status: 200, - headers: { - etag: `W/"${etag}"`, - }, - }); - }, - ); - - // Behaviour - // First API call will return 200 with an etag response header - await fetchWorkflowRunIds(0, branch, startTimeISO); - expect(submittedEtag).toStrictEqual(null); - // Second API call with same parameters should pass the If-None-Match header - await fetchWorkflowRunIds(0, branch, startTimeISO); - expect(submittedEtag).toStrictEqual(etag); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledTimes(2); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Fetched Workflow Runs: - Repository: owner/repository - Branch Filter: true (feature_branch) - Workflow ID: 0 - Created: >=2025-06-17T22:24:23.238Z - Runs Fetched: []" - `, - ); - }); - - it("should not send the previous etag in the If-None-Match header when different request params are used", async () => { - const branch = getBranchName(workflowIdCfg.ref); - coreDebugLogMock.mockReset(); - - const mockData = { - total_count: 0, - workflow_runs: [], - }; - const etag = - "37c2311495bbea359329d0bb72561bdb2b2fffea1b7a54f696b5a287e7ccad1e"; - let submittedEtag = null; - vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockImplementation( - ({ headers }) => { - if (headers?.["If-None-Match"]) { - submittedEtag = headers["If-None-Match"]; - return Promise.resolve({ - data: null, - status: 304, - headers: { - etag: `W/"${submittedEtag}"`, - }, - }); - } - return Promise.resolve({ - data: mockData, - status: 200, - headers: { - etag: `W/"${etag}"`, - }, - }); - }, - ); - - // Behaviour - // First API call will return 200 with an etag response header - await fetchWorkflowRunIds(0, branch, startTimeISO); - expect(submittedEtag).toStrictEqual(null); - // Second API call, without If-None-Match header because of different parameters - await fetchWorkflowRunIds(1, branch, startTimeISO); - expect(submittedEtag).toStrictEqual(null); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledTimes(2); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Fetched Workflow Runs: - Repository: owner/repository - Branch Filter: true (feature_branch) - Workflow ID: 0 - Created: >=2025-06-17T22:24:23.238Z - Runs Fetched: []" - `, - ); - }); - }); - - describe("fetchWorkflowRunJobSteps", () => { - it("should get the step names for a given Workflow Run ID", async () => { - const mockData = { - total_count: 1, - jobs: [ - { - id: 0, - steps: [ - { - name: "Test Step 1", - number: 1, - }, - { - name: "Test Step 2", - number: 2, - }, - ], - }, - ], - }; + it.each([ + ["an empty body", {}], + ["a body missing the run ID", { html_url: "https://github.com" }], + ["a body missing the URL", { workflow_run_id: 123456 }], + ["a non-numeric run ID", { workflow_run_id: "123456", html_url: "url" }], + ])("should throw for %s", async (_label, data) => { vi.spyOn( mockOctokit.rest.actions, - "listJobsForWorkflowRun", + "createWorkflowDispatch", ).mockReturnValue( Promise.resolve({ - data: mockData, + data, status: 200, headers: {}, }), ); // Behaviour - await expect(fetchWorkflowRunJobSteps(0)).resolves.toStrictEqual([ - "Test Step 1", - "Test Step 2", - ]); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Fetched Workflow Run Job Steps: - Repository: owner/repo - Workflow Run ID: 0 - Jobs Fetched: [0] - Steps Fetched: ["Test Step 1", "Test Step 2"]" - `, + await expect(dispatchWorkflow()).rejects.toThrow( + "Dispatch did not return the run details", ); }); - it("should throw if a non-200 status is returned", async () => { + it("should throw if a non-200 or non-204 status is returned", async () => { const errorStatus = 401; vi.spyOn( mockOctokit.rest.actions, - "listJobsForWorkflowRun", + "createWorkflowDispatch", ).mockReturnValue( Promise.resolve({ data: undefined, @@ -758,290 +180,60 @@ describe("API", () => { ); // Behaviour - await expect(fetchWorkflowRunJobSteps(0)).rejects.toThrow( - `Failed to fetch Workflow Run Jobs, expected 200 but received ${errorStatus}`, + await expect(dispatchWorkflow()).rejects.toThrow( + `Failed to dispatch action, expected 200 or 204 but received ${errorStatus}`, ); // Logging assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); expect(coreErrorLogMock).toHaveBeenCalledOnce(); expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"fetchWorkflowRunJobSteps: An unexpected error has occurred: Failed to fetch Workflow Run Jobs, expected 200 but received 401"`, - ); - }); - - it("should return an empty array if there are no steps", async () => { - const mockData = { - total_count: 1, - jobs: [ - { - id: 0, - steps: undefined, - }, - ], - }; - vi.spyOn( - mockOctokit.rest.actions, - "listJobsForWorkflowRun", - ).mockReturnValue( - Promise.resolve({ - data: mockData, - status: 200, - headers: {}, - }), + `"dispatchWorkflow: An unexpected error has occurred: Failed to dispatch action, expected 200 or 204 but received 401"`, ); - - // Behaviour - await expect(fetchWorkflowRunJobSteps(0)).resolves.toStrictEqual([]); - - // Logging - assertOnlyCalled(coreDebugLogMock); expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Fetched Workflow Run Job Steps: - Repository: owner/repo - Workflow Run ID: 0 - Jobs Fetched: [0] - Steps Fetched: []" - `, - ); }); - it("should send the previous etag in the If-None-Match header", async () => { - const mockData = { - total_count: 1, - jobs: [ - { - id: 0, - steps: undefined, - }, - ], - }; - const etag = - "37c2311495bbea359329d0bb72561bdb2b2fffea1b7a54f696b5a287e7ccad1e"; - let submittedEtag = null; - vi.spyOn( - mockOctokit.rest.actions, - "listJobsForWorkflowRun", - ).mockImplementation(({ headers }) => { - if (headers?.["If-None-Match"]) { - submittedEtag = headers["If-None-Match"]; - return Promise.resolve({ - data: null, - status: 304, - headers: { - etag: `W/"${submittedEtag}"`, - }, - }); + it("should omit the inputs from the log when none are configured", async () => { + vi.spyOn(core, "getInput").mockImplementation((key: string) => { + switch (key) { + case "token": + return "token"; + case "ref": + return "ref"; + case "repo": + return "repo"; + case "owner": + return "owner"; + case "workflow": + return "workflow"; + default: + return ""; } - return Promise.resolve({ - data: mockData, - status: 200, - headers: { - etag: `W/"${etag}"`, - }, - }); }); + init(); - // Behaviour - // First API call will return 200 with an etag response header - await fetchWorkflowRunJobSteps(0); - expect(submittedEtag).toStrictEqual(null); - // Second API call with same parameters should pass the If-None-Match header - await fetchWorkflowRunJobSteps(0); - expect(submittedEtag).toStrictEqual(etag); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledTimes(2); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Fetched Workflow Run Job Steps: - Repository: owner/repo - Workflow Run ID: 0 - Jobs Fetched: [0] - Steps Fetched: []" - `, - ); - }); - - it("should not send the previous etag in the If-None-Match header when different request params are used", async () => { - const mockData = { - total_count: 1, - jobs: [ - { - id: 0, - steps: undefined, - }, - ], - }; - const etag = - "37c2311495bbea359329d0bb72561bdb2b2fffea1b7a54f696b5a287e7ccad1e"; - let submittedEtag = null; vi.spyOn( mockOctokit.rest.actions, - "listJobsForWorkflowRun", - ).mockImplementation(({ headers }) => { - if (headers?.["If-None-Match"]) { - submittedEtag = headers["If-None-Match"]; - return Promise.resolve({ - data: null, - status: 304, - headers: { - etag: `W/"${submittedEtag}"`, - }, - }); - } - return Promise.resolve({ - data: mockData, - status: 200, - headers: { - etag: `W/"${etag}"`, - }, - }); - }); - - // Behaviour - // First API call will return 200 with an etag response header - await fetchWorkflowRunJobSteps(0); - expect(submittedEtag).toStrictEqual(null); - // Second API call, without If-None-Match header because of different parameters - await fetchWorkflowRunJobSteps(1); - expect(submittedEtag).toStrictEqual(null); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledTimes(2); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Fetched Workflow Run Job Steps: - Repository: owner/repo - Workflow Run ID: 0 - Jobs Fetched: [0] - Steps Fetched: []" - `, - ); - }); - }); - - describe("fetchWorkflowRunUrl", () => { - it("should return the workflow run state for a given run ID", async () => { - const mockData = { - html_url: "master sword", - }; - vi.spyOn(mockOctokit.rest.actions, "getWorkflowRun").mockReturnValue( + "createWorkflowDispatch", + ).mockReturnValue( Promise.resolve({ - data: mockData, + data: runDetails, status: 200, headers: {}, }), ); - const url = await fetchWorkflowRunUrl(123456); - expect(url).toStrictEqual(mockData.html_url); - }); - - it("should throw if a non-200 status is returned", async () => { - const errorStatus = 401; - vi.spyOn(mockOctokit.rest.actions, "getWorkflowRun").mockReturnValue( - Promise.resolve({ - data: undefined, - status: errorStatus, - headers: {}, - }), - ); - // Behaviour - await expect(fetchWorkflowRunUrl(0)).rejects.toThrow( - `Failed to fetch Workflow Run state, expected 200 but received ${errorStatus}`, - ); + await expect(dispatchWorkflow()).resolves.toStrictEqual({ + id: 123456, + url: "https://github.com/owner/repo/actions/runs/123456", + }); // Logging - assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); - expect(coreErrorLogMock).toHaveBeenCalledOnce(); - expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"fetchWorkflowRunUrl: An unexpected error has occurred: Failed to fetch Workflow Run state, expected 200 but received 401"`, + assertOnlyCalled(coreInfoLogMock); + expect(coreInfoLogMock.mock.calls[0]?.[0]).not.toContain( + "Workflow Inputs", ); }); }); - - describe("retryOrTimeout", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("should return a result", async () => { - const attemptResult = [0]; - const attempt = () => Promise.resolve(attemptResult); - - const result = await retryOrTimeout(attempt, 1000); - if (!result.success) { - expect.fail("expected retryOrTimeout not to timeout"); - } - - expect(result.success).toStrictEqual(true); - expect(result.value).toStrictEqual(attemptResult); - }); - - it("should return a timeout result if the given timeout is exceeded", async () => { - // Never return data. - const attempt = () => Promise.resolve([]); - - const retryOrTimeoutPromise = retryOrTimeout(attempt, 1000); - await vi.advanceTimersByTimeAsync(2000); - - const result = await retryOrTimeoutPromise; - if (result.success) { - expect.fail("expected retryOrTimeout to timeout"); - } - - expect(result.success).toStrictEqual(false); - }); - - it("should retry to get a populated array", async () => { - const attemptResult = [0]; - const attempt = vi - .fn() - .mockResolvedValue(attemptResult) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]); - - const retryOrDiePromise = retryOrTimeout(attempt, 5000); - await vi.advanceTimersByTimeAsync(3000); - - const result = await retryOrDiePromise; - if (!result.success) { - expect.fail("expected retryOrTimeout not to timeout"); - } - - expect(result.success).toStrictEqual(true); - expect(result.value).toStrictEqual(attemptResult); - expect(attempt).toHaveBeenCalledTimes(3); - }); - - it("should iterate only once if timed out", async () => { - const attempt = vi.fn(() => Promise.resolve([])); - - const retryOrTimeoutPromise = retryOrTimeout(attempt, 1000); - - expect(attempt).toHaveBeenCalledOnce(); - - await vi.advanceTimersByTimeAsync(2000); - - const result = await retryOrTimeoutPromise; - - if (result.success) { - expect.fail("expected retryOrTimeout to timeout"); - } - expect(attempt).toHaveBeenCalledOnce(); - - expect(result.success).toStrictEqual(false); - expect(result.reason).toStrictEqual("timeout"); - }); - }); }); diff --git a/src/api.ts b/src/api.ts index 51bc88c9..dc145c4b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -2,9 +2,6 @@ import * as core from "@actions/core"; import * as github from "@actions/github"; import { type ActionConfig, getConfig } from "./action.ts"; -import { withEtag } from "./etags.js"; -import type { Result } from "./types.ts"; -import { sleep, type BranchNameResult } from "./utils.ts"; type Octokit = ReturnType; @@ -16,7 +13,51 @@ export function init(cfg?: ActionConfig): void { octokit = github.getOctokit(config.token); } -export async function dispatchWorkflow(distinctId: string): Promise { +/** + * The 200 response body of the workflow dispatch endpoint, returned when + * `return_run_details` is requested. + * + * Declared locally because `@octokit/openapi-types` still describes this + * endpoint as 204-only, so the response shape cannot be taken from the types + * and is validated at runtime instead. + * + * See: https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event--status-codes + */ +interface WorkflowDispatchResponse { + workflow_run_id: number; + run_url: string; + html_url: string; +} + +/** + * Identifies the run created by a dispatch. + */ +export interface DispatchedWorkflowRun { + id: number; + url: string; +} + +function readDispatchedRun(data: unknown): DispatchedWorkflowRun | undefined { + if (typeof data !== "object" || data === null) { + return undefined; + } + + const { workflow_run_id: id, html_url: url } = + data as Partial; + if (typeof id !== "number" || typeof url !== "string") { + return undefined; + } + + return { id, url }; +} + +/** + * Dispatch the workflow and return the run that it created. + * + * Throws if the server does not report the run details, which requires + * github.com or GitHub Enterprise Server 3.21 or newer. + */ +export async function dispatchWorkflow(): Promise { try { // https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event const response = await octokit.rest.actions.createWorkflowDispatch({ @@ -24,10 +65,11 @@ export async function dispatchWorkflow(distinctId: string): Promise { repo: config.repo, workflow_id: config.workflow, ref: config.ref, - inputs: { - ...(config.workflowInputs ?? undefined), - distinct_id: distinctId, - }, + inputs: config.workflowInputs, + // The docs omit `return_run_details`. It is specified only in the OpenAPI + // description, which is what conditions the 200 and 204 responses on it. + // see: https://github.com/github/rest-api-description/tree/main/descriptions/api.github.com + return_run_details: true, }); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -37,6 +79,15 @@ export async function dispatchWorkflow(distinctId: string): Promise { ); } + // Servers that do not support `return_run_details` ignore it and respond + // with an empty 204. + const dispatchedRun = readDispatchedRun(response.data); + if (dispatchedRun === undefined) { + throw new Error( + "Dispatch did not return the run details, this action requires github.com or GHES >=3.21", + ); + } + core.info( "Successfully dispatched workflow:\n" + ` Repository: ${config.owner}/${config.repo}\n` + @@ -45,255 +96,18 @@ export async function dispatchWorkflow(distinctId: string): Promise { (config.workflowInputs ? ` Workflow Inputs: ${JSON.stringify(config.workflowInputs)}\n` : ``) + - ` Distinct ID: ${distinctId}`, + ` Run ID: ${dispatchedRun.id}\n` + + ` Run URL: ${dispatchedRun.url}`, ); - } catch (error) { - if (error instanceof Error) { - core.error( - `dispatchWorkflow: An unexpected error has occurred: ${error.message}`, - ); - core.debug(error.stack ?? ""); - } - throw error; - } -} -export async function fetchWorkflowId( - workflowFilename: string, -): Promise { - try { - const sanitisedFilename = workflowFilename - .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - .trim(); - const filenameRegex = new RegExp(`/${sanitisedFilename}`); - - // https://docs.github.com/en/rest/actions/workflows#list-repository-workflows - const workflowIterator = octokit.paginate.iterator( - octokit.rest.actions.listRepoWorkflows, - { - owner: config.owner, - repo: config.repo, - }, - ); - let workflowId: number | undefined; - let workflowIdUrl: string | undefined; - for await (const response of workflowIterator) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (response.status !== 200) { - throw new Error( - `Failed to fetch workflows, expected 200 but received ${response.status}`, - ); - } - - const workflowData = response.data.find((workflow) => - filenameRegex.test(workflow.path), - ); - workflowId = workflowData?.id; - - if (workflowId !== undefined) { - workflowIdUrl = workflowData?.html_url; - break; - } - } - - if (workflowId === undefined) { - throw new Error(`Unable to find ID for Workflow: ${workflowFilename}`); - } - - core.info( - `Fetched Workflow ID:\n` + - ` Repository: ${config.owner}/${config.repo}\n` + - ` Workflow ID: '${workflowId}'\n` + - ` Input Filename: '${workflowFilename}'\n` + - ` Sanitised Filename: '${sanitisedFilename}'\n` + - ` URL: ${workflowIdUrl}`, - ); - - return workflowId; + return dispatchedRun; } catch (error) { if (error instanceof Error) { core.error( - `fetchWorkflowId: An unexpected error has occurred: ${error.message}`, - ); - core.debug(error.stack ?? ""); - } - throw error; - } -} - -export async function fetchWorkflowRunUrl(runId: number): Promise { - try { - // https://docs.github.com/en/rest/reference/actions#get-a-workflow-run - const response = await octokit.rest.actions.getWorkflowRun({ - owner: config.owner, - repo: config.repo, - run_id: runId, - }); - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (response.status !== 200) { - throw new Error( - `Failed to fetch Workflow Run state, expected 200 but received ${response.status}`, - ); - } - - core.debug( - `Fetched Run:\n` + - ` Repository: ${config.owner}/${config.repo}\n` + - ` Run ID: ${runId}\n` + - ` URL: ${response.data.html_url}`, - ); - - return response.data.html_url; - } catch (error) { - if (error instanceof Error) { - core.error( - `fetchWorkflowRunUrl: An unexpected error has occurred: ${error.message}`, - ); - core.debug(error.stack ?? ""); - } - throw error; - } -} - -export async function fetchWorkflowRunIds( - workflowId: number, - branch: BranchNameResult, - startTimeISO: string, -): Promise { - try { - const useBranchFilter = - !branch.isTag && - branch.branchName !== undefined && - branch.branchName !== ""; - - const createdFrom = `>=${startTimeISO}`; - - const response = await withEtag( - "listWorkflowRuns", - { - owner: config.owner, - repo: config.repo, - workflow_id: workflowId, - created: createdFrom, - event: "workflow_dispatch", - ...(useBranchFilter - ? { - branch: branch.branchName, - per_page: 10, - } - : { - per_page: 20, - }), - }, - async (params) => { - // https://docs.github.com/en/rest/actions/workflow-runs#list-workflow-runs-for-a-repository - return await octokit.rest.actions.listWorkflowRuns(params); - }, - ); - - if (response.status !== 200) { - throw new Error( - `Failed to fetch Workflow runs, expected 200 but received ${response.status}`, - ); - } - - const runIds = response.data.workflow_runs.map( - (workflowRun) => workflowRun.id, - ); - - const branchMsg = useBranchFilter - ? `true (${branch.branchName})` - : `false (${branch.ref})`; - core.debug( - "Fetched Workflow Runs:\n" + - ` Repository: ${config.owner}/${config.repo}\n` + - ` Branch Filter: ${branchMsg}\n` + - ` Workflow ID: ${workflowId}\n` + - ` Created: ${createdFrom}\n` + - ` Runs Fetched: [${runIds.join(", ")}]`, - ); - - return runIds; - } catch (error) { - if (error instanceof Error) { - core.error( - `fetchWorkflowRunIds: An unexpected error has occurred: ${error.message}`, - ); - core.debug(error.stack ?? ""); - } - throw error; - } -} - -export async function fetchWorkflowRunJobSteps( - runId: number, -): Promise { - try { - const response = await withEtag( - "listJobsForWorkflowRun", - { - owner: config.owner, - repo: config.repo, - run_id: runId, - filter: "latest" as const, - }, - async (params) => { - // https://docs.github.com/en/rest/actions/workflow-jobs#list-jobs-for-a-workflow-run - return await octokit.rest.actions.listJobsForWorkflowRun(params); - }, - ); - - if (response.status !== 200) { - throw new Error( - `Failed to fetch Workflow Run Jobs, expected 200 but received ${response.status}`, - ); - } - - const jobs = response.data.jobs.map((job) => ({ - id: job.id, - steps: job.steps?.map((step) => step.name) ?? [], - })); - const steps = Array.from(new Set(jobs.flatMap((job) => job.steps))); - - core.debug( - "Fetched Workflow Run Job Steps:\n" + - ` Repository: ${config.owner}/${config.repo}\n` + - ` Workflow Run ID: ${runId}\n` + - ` Jobs Fetched: [${jobs.map((job) => job.id).join(", ")}]\n` + - ` Steps Fetched: [${steps.map((step) => `"${step}"`).join(", ")}]`, - ); - - return steps; - } catch (error) { - if (error instanceof Error) { - core.error( - `fetchWorkflowRunJobSteps: An unexpected error has occurred: ${error.message}`, + `dispatchWorkflow: An unexpected error has occurred: ${error.message}`, ); core.debug(error.stack ?? ""); } throw error; } } - -/** - * Attempt to get a non-empty array from the API. - */ -export async function retryOrTimeout( - retryFunc: () => Promise, - timeoutMs: number, -): Promise> { - const startTime = Date.now(); - let elapsedTime = 0; - while (elapsedTime < timeoutMs) { - const response = await retryFunc(); - if (response.length > 0) { - return { success: true, value: response }; - } - - await sleep(1000); - elapsedTime = Date.now() - startTime; - } - - return { success: false, reason: "timeout" }; -} diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index db31c7f1..00000000 --- a/src/constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* eslint-disable @typescript-eslint/no-inferrable-types */ - -export const WORKFLOW_FETCH_TIMEOUT_MS: number = 60 * 1000; -export const WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MAX: number = 3; -export const WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MS: number = 500; diff --git a/src/etags.ts b/src/etags.ts deleted file mode 100644 index 372f4681..00000000 --- a/src/etags.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { - RequestHeaders, - RequestParameters, - OctokitResponse, -} from "@octokit/types"; - -interface EtagStoreEntry { - etag: string; - savedResponse: OctokitResponse; -} - -const etagStore = new Map(); - -export async function withEtag( - endpoint: string, - params: P, - requester: (params: P) => Promise>, -): Promise> { - const { etag, savedResponse } = getEtag(endpoint, params) ?? {}; - - const paramsWithEtag = { ...params }; - if (etag) - paramsWithEtag.headers = { - "If-None-Match": etag, - ...(params.headers ?? {}), - } satisfies RequestHeaders; - - const response = await requester(paramsWithEtag); - - if ( - response.status === 304 && - etag && - etag === extractEtag(response) && - savedResponse !== undefined - ) { - return savedResponse; - } - - rememberEtag(endpoint, params, response); - return response; -} - -function extractEtag(response: OctokitResponse): string | undefined { - if ("string" !== typeof response.headers.etag) return; - return response.headers.etag.split('"')[1] ?? ""; -} - -function getEtag(endpoint: string, params: object): EtagStoreEntry | undefined { - return etagStore.get(JSON.stringify({ endpoint, params })); -} - -function rememberEtag( - endpoint: string, - params: object, - response: OctokitResponse, -): void { - const etag = extractEtag(response); - if (!etag) return; - - etagStore.set(JSON.stringify({ endpoint, params }), { - etag, - savedResponse: response, - }); -} - -export function clearEtags(): void { - etagStore.clear(); -} diff --git a/src/main.spec.ts b/src/main.spec.ts index 9ad5a8c6..44dc9f26 100644 --- a/src/main.spec.ts +++ b/src/main.spec.ts @@ -13,15 +13,11 @@ import { import * as action from "./action.ts"; import * as api from "./api.ts"; import { main } from "./main.ts"; -import * as returnDispatch from "./return-dispatch.ts"; import { mockLoggingFunctions } from "./test-utils/logging.mock.ts"; -import * as utils from "./utils.ts"; vi.mock("@actions/core"); vi.mock("./action.ts"); vi.mock("./api.ts"); -vi.mock("./return-dispatch.ts"); -vi.mock("./utils.ts"); describe("main", () => { const { @@ -31,20 +27,13 @@ describe("main", () => { assertOnlyCalled, } = mockLoggingFunctions(); const testCfg: action.ActionConfig = { - distinctId: "test-id", ref: "test-ref", workflow: "test-workflow", - workflowTimeoutSeconds: 0, - workflowJobStepsRetrySeconds: 0, } satisfies Partial as action.ActionConfig; - const testBranch: utils.BranchNameResult = { - branchName: "test-branch", - isTag: false, - ref: testCfg.ref, - }; // Core let coreSetFailedMock: MockInstance; + let coreSetOutputMock: MockInstance; // Action let actionGetConfigMock: MockInstance; @@ -53,29 +42,6 @@ describe("main", () => { let apiDispatchWorkflowMock: MockInstance; let apiInitMock: MockInstance; - // Utils - let utilsGetBranchNameMock: MockInstance; - let utilsLogInfoForBranchNameResult: MockInstance< - typeof utils.logInfoForBranchNameResult - >; - let utilsCreateDistinctIdRegexMock: MockInstance< - typeof utils.createDistinctIdRegex - >; - - // Return Dispatch - let returnDispatchGetRunIdAndUrlMock: MockInstance< - typeof returnDispatch.getRunIdAndUrl - >; - let returnDispatchGetWorkflowIdMock: MockInstance< - typeof returnDispatch.getWorkflowId - >; - let returnDispatchHandleFailMock: MockInstance< - typeof returnDispatch.handleActionFail - >; - let returnDispatchHandleSuccessMock: MockInstance< - typeof returnDispatch.handleActionSuccess - >; - afterAll(() => { vi.restoreAllMocks(); }); @@ -84,6 +50,7 @@ describe("main", () => { vi.useFakeTimers(); coreSetFailedMock = vi.spyOn(core, "setFailed"); + coreSetOutputMock = vi.spyOn(core, "setOutput"); actionGetConfigMock = vi .spyOn(action, "getConfig") @@ -91,26 +58,6 @@ describe("main", () => { apiDispatchWorkflowMock = vi.spyOn(api, "dispatchWorkflow"); apiInitMock = vi.spyOn(api, "init"); - - utilsGetBranchNameMock = vi.spyOn(utils, "getBranchName"); - utilsLogInfoForBranchNameResult = vi.spyOn( - utils, - "logInfoForBranchNameResult", - ); - utilsCreateDistinctIdRegexMock = vi.spyOn(utils, "createDistinctIdRegex"); - - returnDispatchGetRunIdAndUrlMock = vi.spyOn( - returnDispatch, - "getRunIdAndUrl", - ); - returnDispatchGetWorkflowIdMock = vi - .spyOn(returnDispatch, "getWorkflowId") - .mockResolvedValue(0); - returnDispatchHandleFailMock = vi.spyOn(returnDispatch, "handleActionFail"); - returnDispatchHandleSuccessMock = vi.spyOn( - returnDispatch, - "handleActionSuccess", - ); }); afterEach(() => { @@ -118,92 +65,59 @@ describe("main", () => { vi.resetAllMocks(); }); - it("should successfully complete", async () => { - const distinctIdRegex = new RegExp(testCfg.distinctId); - const returnDispatchSuccessResult = { - success: true, - value: { - id: 0, - url: "test-url", - }, - } as const; - - utilsGetBranchNameMock.mockReturnValue(testBranch); - utilsCreateDistinctIdRegexMock.mockReturnValue(distinctIdRegex); - returnDispatchGetWorkflowIdMock.mockResolvedValue(0); - returnDispatchGetRunIdAndUrlMock.mockResolvedValue( - returnDispatchSuccessResult, - ); + it("should output the run ID and URL returned by the dispatch", async () => { + apiDispatchWorkflowMock.mockResolvedValue({ id: 123, url: "test-url" }); await main(); // Behaviour - // Setup expect(actionGetConfigMock).toHaveBeenCalledOnce(); expect(apiInitMock).toHaveBeenCalledOnce(); expect(apiInitMock).toHaveBeenCalledWith(testCfg); - // Workflow ID - expect(returnDispatchGetWorkflowIdMock).toHaveBeenCalledOnce(); - expect(returnDispatchGetWorkflowIdMock).toHaveBeenCalledWith( - testCfg.workflow, - ); - - // Dispatch expect(apiDispatchWorkflowMock).toHaveBeenCalledOnce(); - expect(apiDispatchWorkflowMock).toHaveBeenCalledWith(testCfg.distinctId); - - // Branch name - expect(utilsGetBranchNameMock).toHaveBeenCalledOnce(); - expect(utilsGetBranchNameMock).toHaveBeenCalledWith(testCfg.ref); - expect(utilsLogInfoForBranchNameResult).toHaveBeenCalledOnce(); - expect(utilsLogInfoForBranchNameResult).toHaveBeenCalledWith( - testBranch, - testCfg.ref, - ); - expect(utilsCreateDistinctIdRegexMock).toHaveBeenCalledOnce(); - expect(utilsCreateDistinctIdRegexMock).toHaveBeenCalledWith( - testCfg.distinctId, - ); - - // Get run ID - expect(returnDispatchGetRunIdAndUrlMock).toHaveBeenCalledOnce(); - expect(returnDispatchGetRunIdAndUrlMock).toHaveBeenCalledWith({ - startTime: Date.now(), - branch: testBranch, - distinctIdRegex: distinctIdRegex, - workflowId: 0, - workflowTimeoutMs: testCfg.workflowTimeoutSeconds * 1000, - workflowJobStepsRetryMs: testCfg.workflowJobStepsRetrySeconds * 1000, - }); // Result expect(coreSetFailedMock).not.toHaveBeenCalled(); - expect(returnDispatchHandleFailMock).not.toHaveBeenCalled(); - expect(returnDispatchHandleSuccessMock).toHaveBeenCalledOnce(); - expect(returnDispatchHandleSuccessMock).toHaveBeenCalledWith( - returnDispatchSuccessResult.value.id, - returnDispatchSuccessResult.value.url, + expect(coreSetOutputMock).toHaveBeenCalledTimes(2); + expect(coreSetOutputMock).toHaveBeenCalledWith( + action.ActionOutputs.runId, + 123, + ); + expect(coreSetOutputMock).toHaveBeenCalledWith( + action.ActionOutputs.runUrl, + "test-url", ); // Logging - assertOnlyCalled(coreInfoLogMock, coreDebugLogMock); - expect(coreInfoLogMock).toHaveBeenCalledTimes(2); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Attempt to extract branch name from ref..."`, - ); - expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatchInlineSnapshot( - `"Attempting to identify run ID from steps..."`, - ); - expect(coreDebugLogMock).toHaveBeenCalledTimes(2); + assertOnlyCalled(coreDebugLogMock); + expect(coreDebugLogMock).toHaveBeenCalledOnce(); expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Attempting to identify run ID for test-workflow (0)"`, - ); - expect(coreDebugLogMock.mock.calls[1]?.[0]).toMatchInlineSnapshot( `"Completed (0ms)"`, ); }); + it("should fail without setting outputs if the dispatch throws", async () => { + const testError = new Error("dispatch failed"); + apiDispatchWorkflowMock.mockRejectedValue(testError); + + await main(); + + // Behaviour + expect(apiDispatchWorkflowMock).toHaveBeenCalledOnce(); + expect(coreSetOutputMock).not.toHaveBeenCalled(); + + expect(coreSetFailedMock).toHaveBeenCalledOnce(); + expect(coreSetFailedMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( + `"Failed: An unhandled error has occurred: dispatch failed"`, + ); + + // Logging + assertOnlyCalled(coreDebugLogMock, coreErrorLogMock); + expect(coreErrorLogMock).toHaveBeenCalledOnce(); + expect(coreDebugLogMock.mock.calls[0]?.[0]).toStrictEqual(testError.stack); + }); + it("should fail for an unhandled error", async () => { const testError = new Error("test error"); actionGetConfigMock.mockImplementation(() => { @@ -216,13 +130,8 @@ describe("main", () => { expect(actionGetConfigMock).toHaveBeenCalledOnce(); expect(apiInitMock).not.toHaveBeenCalled(); - expect(returnDispatchGetWorkflowIdMock).not.toHaveBeenCalled(); expect(apiDispatchWorkflowMock).not.toHaveBeenCalled(); - expect(utilsGetBranchNameMock).not.toHaveBeenCalled(); - expect(utilsLogInfoForBranchNameResult).not.toHaveBeenCalled(); - expect(returnDispatchGetRunIdAndUrlMock).not.toHaveBeenCalled(); - expect(returnDispatchHandleFailMock).not.toHaveBeenCalled(); - expect(returnDispatchHandleSuccessMock).not.toHaveBeenCalled(); + expect(coreSetOutputMock).not.toHaveBeenCalled(); expect(coreSetFailedMock).toHaveBeenCalledOnce(); expect(coreSetFailedMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( @@ -252,13 +161,8 @@ describe("main", () => { expect(actionGetConfigMock).toHaveBeenCalledOnce(); expect(apiInitMock).not.toHaveBeenCalled(); - expect(returnDispatchGetWorkflowIdMock).not.toHaveBeenCalled(); expect(apiDispatchWorkflowMock).not.toHaveBeenCalled(); - expect(utilsGetBranchNameMock).not.toHaveBeenCalled(); - expect(utilsLogInfoForBranchNameResult).not.toHaveBeenCalled(); - expect(returnDispatchGetRunIdAndUrlMock).not.toHaveBeenCalled(); - expect(returnDispatchHandleFailMock).not.toHaveBeenCalled(); - expect(returnDispatchHandleSuccessMock).not.toHaveBeenCalled(); + expect(coreSetOutputMock).not.toHaveBeenCalled(); expect(coreSetFailedMock).toHaveBeenCalledOnce(); expect(coreSetFailedMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( @@ -274,4 +178,12 @@ describe("main", () => { expect(coreDebugLogMock).toHaveBeenCalledOnce(); expect(coreDebugLogMock.mock.calls[0]?.[0]).toStrictEqual(testError); }); + + it("should not log info as there is nothing to report beyond the dispatch", async () => { + apiDispatchWorkflowMock.mockResolvedValue({ id: 123, url: "test-url" }); + + await main(); + + expect(coreInfoLogMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/main.ts b/src/main.ts index 4b3412e6..32142c10 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,18 +1,7 @@ import * as core from "@actions/core"; -import { getConfig } from "./action.ts"; +import { ActionOutputs, getConfig } from "./action.ts"; import * as api from "./api.ts"; -import { - getWorkflowId, - handleActionFail, - handleActionSuccess, - getRunIdAndUrl, -} from "./return-dispatch.ts"; -import { - createDistinctIdRegex, - getBranchName, - logInfoForBranchNameResult, -} from "./utils.ts"; export async function main(): Promise { try { @@ -21,38 +10,12 @@ export async function main(): Promise { const config = getConfig(); api.init(config); - const workflowId = await getWorkflowId(config.workflow); + const dispatchedRun = await api.dispatchWorkflow(); - // Dispatch the action - await api.dispatchWorkflow(config.distinctId); + core.setOutput(ActionOutputs.runId, dispatchedRun.id); + core.setOutput(ActionOutputs.runUrl, dispatchedRun.url); - // Attempt to get the branch from config ref - core.info("Attempt to extract branch name from ref..."); - const branch = getBranchName(config.ref); - logInfoForBranchNameResult(branch, config.ref); - - const distinctIdRegex = createDistinctIdRegex(config.distinctId); - - core.info("Attempting to identify run ID from steps..."); - core.debug( - `Attempting to identify run ID for ${config.workflow} (${workflowId})`, - ); - - const result = await getRunIdAndUrl({ - startTime, - branch, - distinctIdRegex, - workflowId, - workflowTimeoutMs: config.workflowTimeoutSeconds * 1000, - workflowJobStepsRetryMs: config.workflowJobStepsRetrySeconds * 1000, - }); - if (result.success) { - handleActionSuccess(result.value.id, result.value.url); - core.debug(`Completed (${Date.now() - startTime}ms)`); - } else { - handleActionFail(); - core.debug(`Timed out (${Date.now() - startTime}ms)`); - } + core.debug(`Completed (${Date.now() - startTime}ms)`); } catch (error) { if (error instanceof Error) { const failureMsg = `Failed: An unhandled error has occurred: ${error.message}`; diff --git a/src/return-dispatch.spec.ts b/src/return-dispatch.spec.ts deleted file mode 100644 index f1ea1a4d..00000000 --- a/src/return-dispatch.spec.ts +++ /dev/null @@ -1,860 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import * as core from "@actions/core"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, - type MockInstance, -} from "vitest"; - -import { ActionOutputs } from "./action.ts"; -import * as api from "./api.ts"; -import * as constants from "./constants.ts"; -import { - attemptToFindRunId, - getRunIdAndUrl, - getWorkflowId, - handleActionFail, - handleActionSuccess, - shouldRetryOrThrow, - type GetRunIdAndUrlOpts, -} from "./return-dispatch.ts"; -import { mockLoggingFunctions } from "./test-utils/logging.mock.ts"; -import * as utils from "./utils.ts"; - -vi.mock("@actions/core"); -vi.mock("./api.ts"); - -describe("return-dispatch", () => { - const { - coreDebugLogMock, - coreErrorLogMock, - coreInfoLogMock, - assertOnlyCalled, - assertNoneCalled, - } = mockLoggingFunctions(); - - function resetLogMocks(): void { - for (const logMock of [ - coreDebugLogMock, - coreInfoLogMock, - coreErrorLogMock, - ]) { - logMock.mockReset(); - } - } - - afterAll(() => { - vi.restoreAllMocks(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - describe("fetchWorkflowId", () => { - let fetchWorkflowIdMock: MockInstance; - - beforeAll(() => { - fetchWorkflowIdMock = vi.spyOn(api, "fetchWorkflowId"); - }); - - it("should return the workflow ID without calling the API if given a number", async () => { - const workflowId = await getWorkflowId(123); - - // Behaviour - expect(workflowId).toStrictEqual(123); - expect(fetchWorkflowIdMock).not.toHaveBeenCalled(); - - // Logging - assertNoneCalled(); - }); - - it("should return the workflow ID from API if given a string", async () => { - fetchWorkflowIdMock.mockImplementationOnce(() => Promise.resolve(123)); - const workflowId = await getWorkflowId("hello.yml"); - - // Behaviour - expect(workflowId).toStrictEqual(123); - expect(fetchWorkflowIdMock).toHaveBeenCalled(); - - // Logging - assertOnlyCalled(coreInfoLogMock); - expect(coreInfoLogMock).toHaveBeenCalledTimes(2); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Fetching Workflow ID for hello.yml..."`, - ); - expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatchInlineSnapshot( - `"Fetched Workflow ID: 123"`, - ); - }); - - it("should throw if any API error occurs", async () => { - fetchWorkflowIdMock.mockImplementationOnce(() => - Promise.reject(new Error()), - ); - const workflowIdPromise = getWorkflowId("hello.yml"); - - // Behaviour - await expect(workflowIdPromise).rejects.toThrow(Error); - - // Logging - assertOnlyCalled(coreInfoLogMock); - expect(coreInfoLogMock).toHaveBeenCalledOnce(); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Fetching Workflow ID for hello.yml..."`, - ); - }); - }); - - describe("shouldRetryOrThrow", () => { - beforeEach(() => { - vi.spyOn( - constants, - "WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MAX", - "get", - ).mockReturnValue(3); - }); - - it('should retry on "Server error" and max attempts not exceeded', () => { - const testErr = new Error("Server Error"); - - // Behaviour - expect(shouldRetryOrThrow(testErr, 0)).toStrictEqual(true); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Encountered a Server Error while attempting to fetch steps, retrying in 500ms"`, - ); - }); - - it('should retry on "Server error" and max attempts not exceeded', () => { - const testErr = new Error("Server Error"); - - // Behaviour - expect(shouldRetryOrThrow(testErr, 5)).toStrictEqual(false); - - // Logging - assertNoneCalled(); - }); - - it('should log on "Not Found"', () => { - const testErr = new Error("Not Found"); - - // Behaviour - expect(shouldRetryOrThrow(testErr, 0)).toStrictEqual(false); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Could not identify ID in run, continuing..."`, - ); - }); - - it("re-throw on unhandled error", () => { - const testErr = new Error("Unhandled Error"); - - // Behaviour - expect(() => shouldRetryOrThrow(testErr, 0)).toThrow(testErr); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Unhandled error has occurred: Unhandled Error"`, - ); - }); - }); - - describe("attemptToFindRunId", () => { - const testId = randomUUID(); - - let getWorkflowRunJobStepMock: MockInstance< - typeof api.fetchWorkflowRunJobSteps - >; - let fetchWorkflowRunUrlMock: MockInstance; - - beforeEach(() => { - getWorkflowRunJobStepMock = vi.spyOn(api, "fetchWorkflowRunJobSteps"); - fetchWorkflowRunUrlMock = vi.spyOn(api, "fetchWorkflowRunUrl"); - }); - - it("should return a not found result if there is nothing to iterate on", async () => { - const result = await attemptToFindRunId(new RegExp(testId), []); - if (result.success) { - expect.fail("result found when none expected"); - } - - // Behaviour - expect(result.success).toStrictEqual(false); - expect(getWorkflowRunJobStepMock).not.toHaveBeenCalled(); - expect(fetchWorkflowRunUrlMock).not.toHaveBeenCalled(); - - // Logging - assertNoneCalled(); - }); - - it("should return a not found result if there is only undefined to iterate on", async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const result = await attemptToFindRunId(new RegExp(testId), [ - undefined as any, - ]); - if (result.success) { - expect.fail("result found when none expected"); - } - - // Behaviour - expect(result.success).toStrictEqual(false); - expect(getWorkflowRunJobStepMock).not.toHaveBeenCalled(); - expect(fetchWorkflowRunUrlMock).not.toHaveBeenCalled(); - - // Logging - assertNoneCalled(); - }); - - it("finds the ID on the first iteration", async () => { - getWorkflowRunJobStepMock.mockResolvedValueOnce([testId]); - fetchWorkflowRunUrlMock.mockResolvedValue("test-url"); - - const result = await attemptToFindRunId(new RegExp(testId), [0]); - if (!result.success) { - expect.fail("result not found when expected"); - } - - // Behaviour - expect(result.success).toStrictEqual(true); - expect(result.value.id).toStrictEqual(0); - expect(result.value.url).toStrictEqual("test-url"); - expect(getWorkflowRunJobStepMock).toHaveBeenCalledOnce(); - expect(fetchWorkflowRunUrlMock).toHaveBeenCalledOnce(); - - // Logging - assertNoneCalled(); - }); - - it("finds the ID on the second iteration", async () => { - getWorkflowRunJobStepMock - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([testId]); - fetchWorkflowRunUrlMock.mockResolvedValue("test-url"); - - const result = await attemptToFindRunId(new RegExp(testId), [0, 0]); - if (!result.success) { - expect.fail("result not found when expected"); - } - - // Behaviour - expect(result.success).toStrictEqual(true); - expect(result.value.id).toStrictEqual(0); - expect(result.value.url).toStrictEqual("test-url"); - expect(getWorkflowRunJobStepMock).toHaveBeenCalledTimes(2); - expect(fetchWorkflowRunUrlMock).toHaveBeenCalledOnce(); - - // Logging - assertNoneCalled(); - }); - - it("finds the ID among many steps", async () => { - getWorkflowRunJobStepMock.mockResolvedValueOnce([ - "first", - "second", - "third", - testId, - ]); - fetchWorkflowRunUrlMock.mockResolvedValue("test-url"); - - const result = await attemptToFindRunId(new RegExp(testId), [0]); - if (!result.success) { - expect.fail("result not found when expected"); - } - - // Behaviour - expect(result.success).toStrictEqual(true); - expect(result.value.id).toStrictEqual(0); - expect(result.value.url).toStrictEqual("test-url"); - expect(getWorkflowRunJobStepMock).toHaveBeenCalledOnce(); - expect(fetchWorkflowRunUrlMock).toHaveBeenCalledOnce(); - - // Logging - assertNoneCalled(); - }); - - it("does nothing if called with an empty array", async () => { - const result = await attemptToFindRunId(new RegExp(testId), []); - if (result.success) { - expect.fail("result found when none expected"); - } - - // Behaviour - expect(result.success).toStrictEqual(false); - expect(result.reason).toStrictEqual("invalid input"); - expect(getWorkflowRunJobStepMock).not.toHaveBeenCalled(); - expect(fetchWorkflowRunUrlMock).not.toHaveBeenCalled(); - - // Logging - assertNoneCalled(); - }); - - describe("server error retries", () => { - beforeEach(() => { - vi.spyOn( - constants, - "WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MAX", - "get", - ).mockReturnValue(3); - vi.spyOn( - constants, - "WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MS", - "get", - ).mockReturnValue(500); - - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("fails on exceeded server errors", async () => { - vi.spyOn( - constants, - "WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MAX", - "get", - ).mockReturnValue(3); - vi.spyOn( - constants, - "WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MS", - "get", - ).mockReturnValue(500); - - getWorkflowRunJobStepMock.mockRejectedValue(new Error("Server Error")); - - const attemptToFindRunIdPromise = attemptToFindRunId( - new RegExp(testId), - [0], - ); - - // Advance past the sleeps - await vi.runAllTimersAsync(); - - const result = await attemptToFindRunIdPromise; - if (result.success) { - expect.fail("result found when none expected"); - } - - // Behaviour - expect(result.success).toStrictEqual(false); - expect(getWorkflowRunJobStepMock).toHaveBeenCalledTimes(4); // initial + retries - expect(fetchWorkflowRunUrlMock).not.toHaveBeenCalled(); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledTimes(3); - const debugLineSnapshot = `"Encountered a Server Error while attempting to fetch steps, retrying in 500ms"`; - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - debugLineSnapshot, - ); - expect(coreDebugLogMock.mock.calls[1]?.[0]).toMatchInlineSnapshot( - debugLineSnapshot, - ); - expect(coreDebugLogMock.mock.calls[2]?.[0]).toMatchInlineSnapshot( - debugLineSnapshot, - ); - }); - }); - - it("should throw an unhandled error", async () => { - const unhandledError = new Error("Unhandled Error"); - getWorkflowRunJobStepMock.mockRejectedValue(unhandledError); - - await expect(() => - attemptToFindRunId(new RegExp(testId), [0]), - ).rejects.toThrow(unhandledError); - - // Behaviour - expect(getWorkflowRunJobStepMock).toHaveBeenCalledOnce(); - expect(fetchWorkflowRunUrlMock).not.toHaveBeenCalled(); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Unhandled error has occurred: Unhandled Error"`, - ); - }); - - it("should throw a non-error", async () => { - const thrownValue = "thrown"; - getWorkflowRunJobStepMock.mockRejectedValue(thrownValue); - - await expect(() => - attemptToFindRunId(new RegExp(testId), [0]), - ).rejects.toThrow(thrownValue); - - // Behaviour - expect(getWorkflowRunJobStepMock).toHaveBeenCalledOnce(); - expect(fetchWorkflowRunUrlMock).not.toHaveBeenCalled(); - - // Logging - assertNoneCalled(); - }); - }); - - describe("handleAction", () => { - let setFailedSpy: MockInstance; - let setOutputSpy: MockInstance; - - beforeEach(() => { - setFailedSpy = vi.spyOn(core, "setFailed"); - setOutputSpy = vi.spyOn(core, "setOutput"); - }); - - describe("handleActionSuccess", () => { - it("should set the action output and status", () => { - handleActionSuccess(0, "test-url"); - - // Behaviour - expect(setFailedSpy).not.toHaveBeenCalled(); - expect(setOutputSpy).toHaveBeenCalledTimes(2); - expect(setOutputSpy.mock.calls[0]?.[0]).toStrictEqual( - ActionOutputs.runId, - ); - expect(setOutputSpy.mock.calls[0]?.[1]).toStrictEqual(0); - expect(setOutputSpy.mock.calls[1]?.[0]).toStrictEqual( - ActionOutputs.runUrl, - ); - expect(setOutputSpy.mock.calls[1]?.[1]).toStrictEqual("test-url"); - - // Logging - assertOnlyCalled(coreInfoLogMock); - expect(coreInfoLogMock).toHaveBeenCalledOnce(); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - ` - "Successfully identified remote Run: - Run ID: 0 - URL: test-url" - `, - ); - }); - }); - - describe("handleActionFail", () => { - it("should set the action output and status", () => { - handleActionFail(); - - // Behaviour - expect(setFailedSpy).toHaveBeenCalled(); - expect(setOutputSpy).not.toHaveBeenCalled(); - - // Logging - assertOnlyCalled(coreErrorLogMock); - expect(coreErrorLogMock).toHaveBeenCalledOnce(); - expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Failed: Timeout exceeded while attempting to get Run ID"`, - ); - }); - }); - }); - - describe("getRunIdAndUrl", () => { - const distinctId = crypto.randomUUID(); - const distinctIdRegex = new RegExp(distinctId); - const workflowId = 123; - const branch: utils.BranchNameResult = Object.freeze({ - isTag: false, - ref: "/refs/heads/main", - branchName: "main", - }); - const startTime = 1700000000000; - - let defaultOpts: GetRunIdAndUrlOpts; - - const apiFetchWorkflowRunIdsMock: MockInstance< - typeof api.fetchWorkflowRunIds - > = vi.spyOn(api, "fetchWorkflowRunIds"); - const apiFetchWorkflowRunJobStepsMock: MockInstance< - typeof api.fetchWorkflowRunJobSteps - > = vi.spyOn(api, "fetchWorkflowRunJobSteps"); - const apiFetchWorkflowRunUrlMock: MockInstance< - typeof api.fetchWorkflowRunUrl - > = vi.spyOn(api, "fetchWorkflowRunUrl"); - const apiRetryOrTimeoutMock: MockInstance = - vi.spyOn(api, "retryOrTimeout"); - const utilSleepMock: MockInstance = vi.spyOn( - utils, - "sleep", - ); - - beforeEach(() => { - vi.useFakeTimers({ now: startTime }); - - defaultOpts = { - startTime: startTime, - branch: branch, - distinctIdRegex: distinctIdRegex, - workflowId: workflowId, - workflowTimeoutMs: 100, - workflowJobStepsRetryMs: 5, - }; - - utilSleepMock.mockImplementation( - (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), - ); - }); - - afterEach(() => { - vi.useRealTimers(); - - vi.resetAllMocks(); - }); - - it("should return the ID when found", async () => { - const runId = 0; - const runUrl = "test-url"; - apiRetryOrTimeoutMock.mockResolvedValue({ - success: true, - value: [runId], - }); - apiFetchWorkflowRunJobStepsMock.mockResolvedValue([distinctId]); - apiFetchWorkflowRunUrlMock.mockResolvedValue(runUrl); - - const run = await getRunIdAndUrl({ - ...defaultOpts, - workflowTimeoutMs: 1000, - }); - - if (!run.success) { - expect.fail("expected call to succeed"); - } - - // Behaviour - expect(run.value.id).toStrictEqual(runId); - expect(run.value.url).toStrictEqual(runUrl); - - expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); - expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); - expect(apiFetchWorkflowRunIdsMock).not.toHaveBeenCalled(); - expect(utilSleepMock).not.toHaveBeenCalled(); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); - expect(coreDebugLogMock.mock.calls[1]?.[0]).toMatchSnapshot(); - }); - - it("should call retryOrTimeout with the larger WORKFLOW_FETCH_TIMEOUT_MS timeout value", async () => { - const workflowFetchTimeoutMs = 1000; - const workflowTimeoutMs = 100; - apiRetryOrTimeoutMock.mockResolvedValue({ - success: true, - value: [0], - }); - apiFetchWorkflowRunJobStepsMock.mockResolvedValue([distinctId]); - vi.spyOn(constants, "WORKFLOW_FETCH_TIMEOUT_MS", "get").mockReturnValue( - workflowFetchTimeoutMs, - ); - - await getRunIdAndUrl({ - ...defaultOpts, - workflowTimeoutMs: workflowTimeoutMs, - }); - - // Behaviour - expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); - expect(apiRetryOrTimeoutMock.mock.calls[0]?.[1]).toStrictEqual( - workflowFetchTimeoutMs, - ); - expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); - expect(apiFetchWorkflowRunIdsMock).not.toHaveBeenCalled(); - expect(utilSleepMock).not.toHaveBeenCalled(); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Attempting to get step names for Run IDs: [0]"`, - ); - }); - - it("should call retryOrTimeout with the larger workflowTimeoutMs timeout value", async () => { - const workflowFetchTimeoutMs = 100; - const workflowTimeoutMs = 1000; - apiRetryOrTimeoutMock.mockResolvedValue({ - success: true, - value: [0], - }); - apiFetchWorkflowRunJobStepsMock.mockResolvedValue([distinctId]); - vi.spyOn(constants, "WORKFLOW_FETCH_TIMEOUT_MS", "get").mockReturnValue( - workflowFetchTimeoutMs, - ); - - await getRunIdAndUrl({ - ...defaultOpts, - workflowTimeoutMs: workflowTimeoutMs, - }); - - // Behaviour - expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); - expect(apiRetryOrTimeoutMock.mock.calls[0]?.[1]).toStrictEqual( - workflowTimeoutMs, - ); - expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); - expect(apiFetchWorkflowRunIdsMock).not.toHaveBeenCalled(); - expect(utilSleepMock).not.toHaveBeenCalled(); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); - }); - - it("called fetchWorkflowRunIds with the provided workflowId and branch", async () => { - apiRetryOrTimeoutMock.mockImplementation(async (retryFunc) => { - await retryFunc(); - return { - success: true, - value: [0], - }; - }); - apiFetchWorkflowRunJobStepsMock.mockResolvedValue([distinctId]); - apiFetchWorkflowRunUrlMock.mockResolvedValue("test-url"); - - await getRunIdAndUrl(defaultOpts); - - // Behaviour - expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); - expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); - - expect(apiFetchWorkflowRunIdsMock).toHaveBeenCalledOnce(); - expect(apiFetchWorkflowRunIdsMock.mock.lastCall?.[0]).toStrictEqual( - workflowId, - ); - expect(apiFetchWorkflowRunIdsMock.mock.lastCall?.[1]).toStrictEqual( - branch, - ); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); - }); - - it("should retry until an ID is found", async () => { - const runId = 0; - const runUrl = "test-url"; - apiRetryOrTimeoutMock - .mockResolvedValue({ - success: true, - value: [runId], - }) - .mockResolvedValueOnce({ success: true, value: [] }) - .mockResolvedValueOnce({ success: true, value: [] }); - apiFetchWorkflowRunJobStepsMock.mockResolvedValue([distinctId]); - apiFetchWorkflowRunUrlMock.mockResolvedValue(runUrl); - - const retryMs = 5000; - const timeoutMs = 60 * 60 * 100; - - const getRunIdAndUrlPromise = getRunIdAndUrl({ - ...defaultOpts, - workflowTimeoutMs: timeoutMs, - workflowJobStepsRetryMs: retryMs, - }); - - // First attempt - expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); - await vi.advanceTimersByTimeAsync(1); // deplete queue - - assertOnlyCalled(coreInfoLogMock); - - expect(coreInfoLogMock).toHaveBeenCalledTimes(2); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); - expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatchSnapshot(); - - expect(utilSleepMock).toHaveBeenCalledOnce(); - expect(utilSleepMock).toHaveBeenCalledWith(retryMs); - - resetLogMocks(); - await vi.advanceTimersByTimeAsync(retryMs); - - // Second attempt - expect(apiRetryOrTimeoutMock).toHaveBeenCalledTimes(2); - - assertOnlyCalled(coreInfoLogMock); - - expect(coreInfoLogMock).toHaveBeenCalledTimes(2); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); - expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatchSnapshot(); - - expect(utilSleepMock).toHaveBeenCalledTimes(2); - expect(utilSleepMock).toHaveBeenCalledWith(retryMs * 2); - - resetLogMocks(); - await vi.advanceTimersByTimeAsync(retryMs * 2); - - // Third attempt - expect(apiRetryOrTimeoutMock).toHaveBeenCalledTimes(3); - expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); - expect(apiFetchWorkflowRunUrlMock).toHaveBeenCalledOnce(); - - assertOnlyCalled(coreDebugLogMock); - - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); - - expect(utilSleepMock).toHaveBeenCalledTimes(2); - resetLogMocks(); - - // Result - const run = await getRunIdAndUrlPromise; - if (!run.success) { - expect.fail("expected call to succeed"); - } - expect(run.value.id).toStrictEqual(runId); - expect(run.value.url).toStrictEqual(runUrl); - expect(apiRetryOrTimeoutMock).toHaveBeenCalledTimes(3); - expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); - expect(apiFetchWorkflowRunIdsMock).not.toHaveBeenCalled(); - expect(apiFetchWorkflowRunUrlMock).toHaveBeenCalledOnce(); - assertNoneCalled(); - }); - - it("should timeout when unable failing to get the run IDs", async () => { - apiRetryOrTimeoutMock.mockResolvedValue({ - success: false, - reason: "timeout", - }); - - // Behaviour - const getRunIdAndUrlPromise = getRunIdAndUrl({ - ...defaultOpts, - }); - await vi.advanceTimersByTimeAsync(1000); - - const run = await getRunIdAndUrlPromise; - - if (run.success) { - expect.fail("expected call to fail"); - } - - // Behaviour - expect(run.reason).toStrictEqual("timeout"); - - expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); - expect(apiFetchWorkflowRunJobStepsMock).not.toHaveBeenCalled(); - expect(apiFetchWorkflowRunIdsMock).not.toHaveBeenCalled(); - expect(apiFetchWorkflowRunUrlMock).not.toHaveBeenCalled(); - expect(utilSleepMock).not.toHaveBeenCalled(); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatch( - /Timed out while attempting to fetch Workflow Run IDs, waited [0-9]+ms/, - ); - }); - - it("should timeout when unable to find over time", async () => { - const runId = 0; - const runUrl = "test-url"; - apiRetryOrTimeoutMock.mockResolvedValue({ - success: true, - value: [runId], - }); - apiFetchWorkflowRunJobStepsMock.mockResolvedValue([]); - apiFetchWorkflowRunUrlMock.mockResolvedValue(runUrl); - - const retryMs = 3000; - const timeoutMs = 15 * 1000; - - const getRunIdAndUrlPromise = getRunIdAndUrl({ - ...defaultOpts, - workflowTimeoutMs: timeoutMs, - workflowJobStepsRetryMs: retryMs, - }); - - // First attempt - expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); - await vi.advanceTimersByTimeAsync(1); // deplete queue - expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); - assertOnlyCalled(coreDebugLogMock, coreInfoLogMock); - - expect(coreInfoLogMock).toHaveBeenCalledTimes(2); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); - expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatchSnapshot(); - - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); - - expect(utilSleepMock).toHaveBeenCalledOnce(); - expect(utilSleepMock).toHaveBeenCalledWith(retryMs); - - resetLogMocks(); - await vi.advanceTimersByTimeAsync(retryMs); - - // Second attempt - expect(apiRetryOrTimeoutMock).toHaveBeenCalledTimes(2); - expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledTimes(2); - assertOnlyCalled(coreDebugLogMock, coreInfoLogMock); - - expect(coreInfoLogMock).toHaveBeenCalledTimes(2); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); - expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatchSnapshot(); - - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); - - expect(utilSleepMock).toHaveBeenCalledTimes(2); - expect(utilSleepMock).toHaveBeenCalledWith(retryMs * 2); - - resetLogMocks(); - await vi.advanceTimersByTimeAsync(retryMs * 2); - - // Timeout attempt - expect(apiRetryOrTimeoutMock).toHaveBeenCalledTimes(3); - expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledTimes(3); - assertOnlyCalled(coreDebugLogMock, coreInfoLogMock); - - expect(coreInfoLogMock).toHaveBeenCalledTimes(2); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); - expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatch( - /Waiting for \d{4,5}ms before the next attempt\.\.\./, - ); - - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); - - expect(utilSleepMock).toHaveBeenCalledTimes(3); - const elapsedTime = Date.now() - defaultOpts.startTime; // `waitTime` should be using `workflowTimeoutMs` at this point - expect(utilSleepMock.mock.lastCall?.[0]).approximately( - timeoutMs - elapsedTime, - 5, - ); - - resetLogMocks(); - await vi.advanceTimersByTimeAsync(retryMs * 3); - - // Result - const run = await getRunIdAndUrlPromise; - if (run.success) { - expect.fail("expected call to fail"); - } - expect(run.reason).toStrictEqual("timeout"); - expect(apiRetryOrTimeoutMock).toHaveBeenCalledTimes(3); - expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledTimes(3); - expect(apiFetchWorkflowRunIdsMock).not.toHaveBeenCalled(); - expect(apiFetchWorkflowRunUrlMock).not.toHaveBeenCalled(); - assertNoneCalled(); - }); - }); -}); diff --git a/src/return-dispatch.ts b/src/return-dispatch.ts deleted file mode 100644 index c5819587..00000000 --- a/src/return-dispatch.ts +++ /dev/null @@ -1,196 +0,0 @@ -import * as core from "@actions/core"; - -import { ActionOutputs } from "./action.ts"; -import * as api from "./api.ts"; -import * as constants from "./constants.ts"; -import type { Result } from "./types.ts"; -import { sleep, type BranchNameResult } from "./utils.ts"; - -export function shouldRetryOrThrow( - error: Error, - currentAttempts: number, -): boolean { - switch (error.message) { - case "Server Error": { - if ( - currentAttempts < constants.WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MAX - ) { - core.debug( - "Encountered a Server Error while attempting to fetch steps, " + - `retrying in ${constants.WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MS}ms`, - ); - - return true; - } - return false; - } - case "Not Found": { - core.debug("Could not identify ID in run, continuing..."); - return false; - } - default: { - core.debug(`Unhandled error has occurred: ${error.message}`); - throw error; - } - } -} - -/** - * Attempt to read the distinct ID in the steps for each existing run ID. - */ -export async function attemptToFindRunId( - idRegex: RegExp, - workflowRunIds: number[], -): Promise> { - if (workflowRunIds.length === 0) { - return { - success: false, - reason: "invalid input", - }; - } - - let currentWorkflowRunIndex = 0; - let currentFetchWorkflowRunJobStepsAttempt = 0; - while (currentWorkflowRunIndex < workflowRunIds.length) { - const id = workflowRunIds[currentWorkflowRunIndex]; - if (id === undefined) { - break; - } - - try { - const steps = await api.fetchWorkflowRunJobSteps(id); - - for (const step of steps) { - if (idRegex.test(step)) { - const url = await api.fetchWorkflowRunUrl(id); - return { success: true, value: { id, url } }; - } - } - } catch (error) { - if (!(error instanceof Error)) { - throw error; - } - - const shouldRetry = shouldRetryOrThrow( - error, - currentFetchWorkflowRunJobStepsAttempt, - ); - if (shouldRetry) { - currentFetchWorkflowRunJobStepsAttempt++; - await sleep(constants.WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MS); - // Continue without increasing the current index to retry the same ID. - continue; - } - } - - currentFetchWorkflowRunJobStepsAttempt = 0; - currentWorkflowRunIndex++; - } - - return { success: false, reason: "timeout" }; -} - -/** - * Attempt to get the upstream workflow ID if given a string, otherwise - * use the workflow config as the ID number. - */ -export async function getWorkflowId( - workflow: string | number, -): Promise { - if (typeof workflow === "number") { - // Already asserted is a non-NaN number during config construction - return workflow; - } - - core.info(`Fetching Workflow ID for ${workflow}...`); - const workflowId = await api.fetchWorkflowId(workflow); - core.info(`Fetched Workflow ID: ${workflowId}`); - return workflowId; -} - -export function handleActionSuccess(id: number, url: string): void { - core.info( - "Successfully identified remote Run:\n" + - ` Run ID: ${id}\n` + - ` URL: ${url}`, - ); - core.setOutput(ActionOutputs.runId, id); - core.setOutput(ActionOutputs.runUrl, url); -} - -export function handleActionFail(): void { - core.error("Failed: Timeout exceeded while attempting to get Run ID"); - core.setFailed("Timeout exceeded while attempting to get Run ID"); -} - -export interface GetRunIdAndUrlOpts { - startTime: number; - branch: BranchNameResult; - distinctIdRegex: RegExp; - workflowId: number; - workflowTimeoutMs: number; - workflowJobStepsRetryMs: number; -} -export async function getRunIdAndUrl({ - startTime, - branch, - distinctIdRegex, - workflowId, - workflowTimeoutMs, - workflowJobStepsRetryMs, -}: GetRunIdAndUrlOpts): Promise> { - const startTimeISO = new Date(startTime).toISOString(); - const retryTimeout = Math.max( - constants.WORKFLOW_FETCH_TIMEOUT_MS, - workflowTimeoutMs, - ); - - let attemptNo = 0; - let elapsedTime = Date.now() - startTime; - while (elapsedTime < workflowTimeoutMs) { - attemptNo++; - - // Get all runs for a given workflow ID - const fetchWorkflowRunIds = await api.retryOrTimeout( - () => api.fetchWorkflowRunIds(workflowId, branch, startTimeISO), - retryTimeout, - ); - if (!fetchWorkflowRunIds.success) { - core.debug( - `Timed out while attempting to fetch Workflow Run IDs, waited ${Date.now() - startTime}ms`, - ); - break; - } - - const workflowRunIds = fetchWorkflowRunIds.value; - - if (workflowRunIds.length > 0) { - core.debug( - `Attempting to get step names for Run IDs: [${workflowRunIds.join(", ")}]`, - ); - - const result = await attemptToFindRunId(distinctIdRegex, workflowRunIds); - if (result.success) { - return result; - } - - core.info( - `Exhausted searching IDs in known runs, attempt ${attemptNo}...`, - ); - } else { - core.info(`No Run IDs found for workflow, attempt ${attemptNo}...`); - } - - const waitTime = Math.min( - workflowJobStepsRetryMs * attemptNo, // Lineal backoff - workflowTimeoutMs - elapsedTime, // Ensure we don't exceed the timeout - ); - - core.info(`Waiting for ${waitTime}ms before the next attempt...`); - await sleep(waitTime); - - elapsedTime = Date.now() - startTime; - } - - return { success: false, reason: "timeout" }; -} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index c863d057..00000000 --- a/src/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type Result = ResultSuccess | ResultTimeout | ResultInvalidInput; - -interface ResultSuccess { - success: true; - value: T; -} - -interface ResultTimeout { - success: false; - reason: "timeout"; -} - -interface ResultInvalidInput { - success: false; - reason: "invalid input"; -} diff --git a/src/utils.spec.ts b/src/utils.spec.ts deleted file mode 100644 index bbda2c65..00000000 --- a/src/utils.spec.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { - afterAll, - afterEach, - beforeAll, - describe, - expect, - it, - vi, -} from "vitest"; - -import { mockLoggingFunctions } from "./test-utils/logging.mock.ts"; -import { - createDistinctIdRegex, - escapeRegExp, - getBranchName, - logInfoForBranchNameResult, - sleep, -} from "./utils.ts"; - -vi.mock("@actions/core"); - -describe("utils", () => { - const { - coreDebugLogMock, - coreInfoLogMock, - coreWarningLogMock, - assertOnlyCalled, - assertNoneCalled, - } = mockLoggingFunctions(); - - afterEach(() => { - vi.resetAllMocks(); - }); - - afterAll(() => { - vi.restoreAllMocks(); - }); - - describe("getBranchNameFromRef", () => { - // We want to assert that the props are properly set in - // the union of the return type - interface BranchNameResultUnion { - branchName?: string; - isTag: boolean; - ref: string; - } - - it("should return the branch name for a valid branch ref", () => { - const branchName = "cool_feature"; - const ref = `/refs/heads/${branchName}`; - const branch = getBranchName(ref) as BranchNameResultUnion; - - // Behaviour - expect(branch.isTag).toStrictEqual(false); - expect(branch.branchName).toStrictEqual(branchName); - expect(branch.ref).toStrictEqual(ref); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"getBranchNameFromRef: Filtered branch name: /refs/heads/cool_feature"`, - ); - }); - - it("should return the branch name for a valid branch ref without a leading slash", () => { - const branchName = "cool_feature"; - const ref = `refs/heads/${branchName}`; - const branch = getBranchName(ref) as BranchNameResultUnion; - - // Behaviour - expect(branch.isTag).toStrictEqual(false); - expect(branch.branchName).toStrictEqual(branchName); - expect(branch.ref).toStrictEqual(ref); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"getBranchNameFromRef: Filtered branch name: refs/heads/cool_feature"`, - ); - }); - - it("should return undefined for an invalid branch ref", () => { - const ref = "refs/heads/"; - const branch = getBranchName(ref) as BranchNameResultUnion; - - // Behaviour - expect(branch.isTag).toStrictEqual(false); - expect(branch.branchName).toBeUndefined(); - expect(branch.ref).toStrictEqual(ref); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"getBranchName: failed to get branch for ref: refs/heads/, please raise an issue with this git ref."`, - ); - }); - - it("should return isTag true if the ref is for a tag", () => { - const ref = "refs/tags/v1.0.1"; - const branch = getBranchName(ref) as BranchNameResultUnion; - - // Behaviour - expect(branch.isTag).toStrictEqual(true); - expect(branch.branchName).toBeUndefined(); - expect(branch.ref).toStrictEqual(ref); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Unable to filter branch, unsupported ref: refs/tags/v1.0.1"`, - ); - }); - - it("should return isTag true if the ref is for an invalid tag", () => { - const ref = "refs/tags/"; - const branch = getBranchName(ref) as BranchNameResultUnion; - - // Behaviour - expect(branch.isTag).toStrictEqual(true); - expect(branch.branchName).toBeUndefined(); - expect(branch.ref).toStrictEqual(ref); - - // Logging - assertOnlyCalled(coreDebugLogMock); - expect(coreDebugLogMock).toHaveBeenCalledOnce(); - expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Unable to filter branch, unsupported ref: refs/tags/"`, - ); - }); - }); - - describe("logInfoForBranchNameResult", () => { - it("should log when finding a tag", () => { - const ref = "refs/tags/v1.0.1"; - const branch = getBranchName(ref); - coreDebugLogMock.mockReset(); - - logInfoForBranchNameResult(branch, ref); - - // Logging - assertOnlyCalled(coreInfoLogMock); - expect(coreInfoLogMock).toHaveBeenCalledOnce(); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Tag found for 'refs/tags/v1.0.1', branch filtering will not be used"`, - ); - }); - - it("should log when finding a branch", () => { - const branchName = "cool_feature"; - const ref = `/refs/heads/${branchName}`; - const branch = getBranchName(ref); - coreDebugLogMock.mockReset(); - - logInfoForBranchNameResult(branch, ref); - - // Logging - assertOnlyCalled(coreInfoLogMock); - expect(coreInfoLogMock).toHaveBeenCalledOnce(); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Branch found for '/refs/heads/cool_feature': cool_feature"`, - ); - }); - - it("should log when nothing is found", () => { - const ref = "refs/heads/"; - const branch = getBranchName(ref); - coreDebugLogMock.mockReset(); - - logInfoForBranchNameResult(branch, ref); - - // Logging - assertOnlyCalled(coreInfoLogMock); - expect(coreInfoLogMock).toHaveBeenCalledOnce(); - expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Branch not found for 'refs/heads/', branch filtering will not be used"`, - ); - }); - }); - - describe("sleep", () => { - beforeAll(() => { - vi.useFakeTimers(); - }); - - afterAll(() => { - vi.useRealTimers(); - }); - - it("should sleep for n ms", async () => { - const sleepTime = 1000; - - // This is more of a smoke test than anything else - const sleepPromise = sleep(sleepTime); - await vi.advanceTimersByTimeAsync(1000); - - await expect(sleepPromise).resolves.toBeUndefined(); - - assertNoneCalled(); - }); - }); - - describe("escapeRegExp", () => { - const escaped = "\\^\\$\\.\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|\\\\"; - const unescaped = "^$.*+?()[]{}|\\"; - - it("should escape values", () => { - expect(escapeRegExp(unescaped + unescaped)).toBe(escaped + escaped); - assertNoneCalled(); - }); - - it("should handle strings with nothing to escape", () => { - expect(escapeRegExp("abc")).toBe("abc"); - assertNoneCalled(); - }); - - it("should return an empty string for empty values", () => { - expect(escapeRegExp("")).toEqual(""); - assertNoneCalled(); - }); - }); - - describe("createDistinctIdRegex", () => { - it("should return a regex without warning if the input is safe", () => { - expect(createDistinctIdRegex("test-cfg")).toStrictEqual( - new RegExp("test-cfg"), - ); - assertNoneCalled(); - }); - - it("should return a regex with warning if the input is required escaping", () => { - const input = "test$.*+?()[]{}|\\cfg"; - const escapedInput = escapeRegExp(input); - - const distinctId = createDistinctIdRegex(input); - - // Behaviour - expect(distinctId).toStrictEqual(new RegExp(escapedInput)); - - // Logging - assertOnlyCalled(coreWarningLogMock); - expect(coreWarningLogMock).toHaveBeenCalledOnce(); - expect(coreWarningLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Unescaped characters found in distinctId input, using: test\\$\\.\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|\\\\cfg"`, - ); - }); - }); -}); diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index b80813cd..00000000 --- a/src/utils.ts +++ /dev/null @@ -1,102 +0,0 @@ -import * as core from "@actions/core"; - -function getBranchNameFromRef(ref: string): string | undefined { - const refItems = ref.split(/\/?refs\/heads\//); - if (refItems.length > 1 && (refItems[1]?.length ?? 0) > 0) { - return refItems[1]; - } -} - -function isTagRef(ref: string): boolean { - return new RegExp(/\/?refs\/tags\//).test(ref); -} - -interface RefBranch { - branchName?: string; - isTag: false; - ref: string; -} - -interface RefTag { - isTag: true; - ref: string; -} - -export type BranchNameResult = RefBranch | RefTag; - -export function getBranchName(ref: string): BranchNameResult { - if (isTagRef(ref)) { - core.debug(`Unable to filter branch, unsupported ref: ${ref}`); - return { isTag: true, ref }; - } - - /** - * The listRepoWorkflows request only accepts a branch name and not a ref (for some reason). - * - * Attempt to filter the branch name specifically and use that. - */ - const branch = getBranchNameFromRef(ref); - if (branch) { - core.debug(`getBranchNameFromRef: Filtered branch name: ${ref}`); - } else { - core.debug( - `getBranchName: failed to get branch for ref: ${ref}, please raise an issue with this git ref.`, - ); - } - return { branchName: branch, isTag: false, ref }; -} - -export function logInfoForBranchNameResult( - branch: BranchNameResult, - ref: string, -): void { - if (branch.isTag) { - core.info(`Tag found for '${ref}', branch filtering will not be used`); - } else if (branch.branchName) { - core.info(`Branch found for '${ref}': ${branch.branchName}`); - } else { - core.info( - `Branch not found for '${ref}', branch filtering will not be used`, - ); - } -} - -export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Used to match `RegExp` - * [syntax characters](http://ecma-international.org/ecma-262/7.0/#sec-patterns). - * - * https://github.com/lodash/lodash/blob/main/src/escapeRegExp.ts - */ -const reRegExpChar = /[\\^$.*+?()[\]{}|]/g; -const reHasRegExpChar = RegExp(reRegExpChar.source); - -/** - * Escapes the `RegExp` special characters "^", "$", "\", ".", "*", "+", - * "?", "(", ")", "[", "]", "{", "}", and "|" in `string`. - * - * https://github.com/lodash/lodash/blob/main/src/escapeRegExp.ts - */ -export function escapeRegExp(str: string): string { - return reHasRegExpChar.test(str) - ? str.replace(reRegExpChar, "\\$&") - : str || ""; -} - -/** - * If the input distinct ID contains unescaped characters, log the - * escaped distinct ID as a warning. - */ -export function createDistinctIdRegex(distinctId: string): RegExp { - const escapedDistinctId = escapeRegExp(distinctId); - if (distinctId !== escapedDistinctId) { - core.warning( - `Unescaped characters found in distinctId input, using: ${escapedDistinctId}`, - ); - } - - return new RegExp(escapedDistinctId); -} From d4426422034e8b361a1ee87287d9ebacd6967b6f Mon Sep 17 00:00:00 2001 From: Alex Miller Date: Wed, 29 Jul 2026 15:38:02 +1200 Subject: [PATCH 2/6] fix: handle 400 when `return_run_details` is unsupported --- src/api.spec.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/api.ts | 39 ++++++++++++++++++++++++++++++--------- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/api.spec.ts b/src/api.spec.ts index 3b0f99b8..1703b987 100644 --- a/src/api.spec.ts +++ b/src/api.spec.ts @@ -143,6 +143,48 @@ describe("API", () => { ); }); + it("should restate the server requirement for a 400 rejecting the unknown field", async () => { + // Servers predating `return_run_details` reject it outright rather than + // ignoring it, so the empty 204 path is never reached. + const requestError = Object.assign( + new Error('Unknown request body field: "return_run_details"'), + { status: 400 }, + ); + vi.spyOn( + mockOctokit.rest.actions, + "createWorkflowDispatch", + ).mockRejectedValue(requestError); + + // Behaviour + await expect(dispatchWorkflow()).rejects.toThrow( + "Dispatch did not return the run details, this action requires github.com or GHES >=3.21", + ); + // The original message is retained for diagnosis + await expect(dispatchWorkflow()).rejects.toThrow( + 'Unknown request body field: "return_run_details"', + ); + + // Logging + assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); + }); + + it("should not restate the server requirement for other failures", async () => { + const requestError = Object.assign(new Error("No ref found"), { + status: 422, + }); + vi.spyOn( + mockOctokit.rest.actions, + "createWorkflowDispatch", + ).mockRejectedValue(requestError); + + // Behaviour + await expect(dispatchWorkflow()).rejects.toThrow("No ref found"); + await expect(dispatchWorkflow()).rejects.not.toThrow("GHES"); + + // Logging + assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); + }); + it.each([ ["an empty body", {}], ["a body missing the run ID", { html_url: "https://github.com" }], diff --git a/src/api.ts b/src/api.ts index dc145c4b..127050c4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -57,6 +57,30 @@ function readDispatchedRun(data: unknown): DispatchedWorkflowRun | undefined { * Throws if the server does not report the run details, which requires * github.com or GitHub Enterprise Server 3.21 or newer. */ +const RUN_DETAILS_UNSUPPORTED = + "Dispatch did not return the run details, this action requires github.com or GHES >=3.21"; + +/** + * Servers predating `return_run_details` reject the unknown field with a 400 + * rather than ignoring it, so the empty 204 path is never reached on them. + * Restate the requirement, keeping the original message for diagnosis. + * + * https://github.com/cli/cli/issues/12672 + */ +function asUnsupportedRunDetailsError(error: unknown): Error | undefined { + if (!(error instanceof Error) || !("status" in error)) { + return undefined; + } + + if (error.status !== 400) { + return undefined; + } + + return new Error(`${RUN_DETAILS_UNSUPPORTED} (${error.message})`, { + cause: error, + }); +} + export async function dispatchWorkflow(): Promise { try { // https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event @@ -79,13 +103,9 @@ export async function dispatchWorkflow(): Promise { ); } - // Servers that do not support `return_run_details` ignore it and respond - // with an empty 204. const dispatchedRun = readDispatchedRun(response.data); if (dispatchedRun === undefined) { - throw new Error( - "Dispatch did not return the run details, this action requires github.com or GHES >=3.21", - ); + throw new Error(RUN_DETAILS_UNSUPPORTED); } core.info( @@ -102,12 +122,13 @@ export async function dispatchWorkflow(): Promise { return dispatchedRun; } catch (error) { - if (error instanceof Error) { + const reportedError = asUnsupportedRunDetailsError(error) ?? error; + if (reportedError instanceof Error) { core.error( - `dispatchWorkflow: An unexpected error has occurred: ${error.message}`, + `dispatchWorkflow: An unexpected error has occurred: ${reportedError.message}`, ); - core.debug(error.stack ?? ""); + core.debug(reportedError.stack ?? ""); } - throw error; + throw reportedError; } } From d1ff7c09eb236fe40f1738bc2e1bd8966c78b427 Mon Sep 17 00:00:00 2001 From: Alex Miller Date: Wed, 29 Jul 2026 15:38:44 +1200 Subject: [PATCH 3/6] docs: note the API support requirements --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5fd5fb0b..70ae063d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ One of the following, depending on the token type: - [Create a workflow dispatch event](https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event) - POST `/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches` - Sent with `return_run_details: true`, so the response carries the new run's ID and URL + - Requires github.com or GitHub Enterprise Server 3.21+, older servers cannot return the run details For more information please see [api.ts](./src/api.ts). From 59ee4b91e73de661d95640d97d1ae97b66faf5ac Mon Sep 17 00:00:00 2001 From: Alex Miller Date: Wed, 29 Jul 2026 15:53:54 +1200 Subject: [PATCH 4/6] fix: clarify unsupported-server dispatch errors moved docstring --- src/api.spec.ts | 6 +++++- src/api.ts | 20 +++++++++++++------- src/main.spec.ts | 16 ++-------------- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/api.spec.ts b/src/api.spec.ts index 1703b987..605cf142 100644 --- a/src/api.spec.ts +++ b/src/api.spec.ts @@ -135,11 +135,15 @@ describe("API", () => { await expect(dispatchWorkflow()).rejects.toThrow( "Dispatch did not return the run details, this action requires github.com or GHES >=3.21", ); + // A 204 means the dispatch itself succeeded, which the error must convey + await expect(dispatchWorkflow()).rejects.toThrow( + "The workflow was dispatched but its run cannot be identified", + ); // Logging assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"dispatchWorkflow: An unexpected error has occurred: Dispatch did not return the run details, this action requires github.com or GHES >=3.21"`, + `"dispatchWorkflow: An unexpected error has occurred: Dispatch did not return the run details, this action requires github.com or GHES >=3.21. The workflow was dispatched but its run cannot be identified"`, ); }); diff --git a/src/api.ts b/src/api.ts index 127050c4..5794ab92 100644 --- a/src/api.ts +++ b/src/api.ts @@ -51,12 +51,6 @@ function readDispatchedRun(data: unknown): DispatchedWorkflowRun | undefined { return { id, url }; } -/** - * Dispatch the workflow and return the run that it created. - * - * Throws if the server does not report the run details, which requires - * github.com or GitHub Enterprise Server 3.21 or newer. - */ const RUN_DETAILS_UNSUPPORTED = "Dispatch did not return the run details, this action requires github.com or GHES >=3.21"; @@ -65,6 +59,9 @@ const RUN_DETAILS_UNSUPPORTED = * rather than ignoring it, so the empty 204 path is never reached on them. * Restate the requirement, keeping the original message for diagnosis. * + * Matching on the status alone is deliberate. The 400 message shape is + * undocumented and may differ between GHES versions. + * * https://github.com/cli/cli/issues/12672 */ function asUnsupportedRunDetailsError(error: unknown): Error | undefined { @@ -81,6 +78,12 @@ function asUnsupportedRunDetailsError(error: unknown): Error | undefined { }); } +/** + * Dispatch the workflow and return the run that it created. + * + * Throws if the server does not report the run details, which requires + * github.com or GitHub Enterprise Server 3.21 or newer. + */ export async function dispatchWorkflow(): Promise { try { // https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event @@ -105,7 +108,10 @@ export async function dispatchWorkflow(): Promise { const dispatchedRun = readDispatchedRun(response.data); if (dispatchedRun === undefined) { - throw new Error(RUN_DETAILS_UNSUPPORTED); + // Unlike the 400 path, the dispatch itself has succeeded here. + throw new Error( + `${RUN_DETAILS_UNSUPPORTED}. The workflow was dispatched but its run cannot be identified`, + ); } core.info( diff --git a/src/main.spec.ts b/src/main.spec.ts index 44dc9f26..c0deeb31 100644 --- a/src/main.spec.ts +++ b/src/main.spec.ts @@ -20,12 +20,8 @@ vi.mock("./action.ts"); vi.mock("./api.ts"); describe("main", () => { - const { - coreDebugLogMock, - coreErrorLogMock, - coreInfoLogMock, - assertOnlyCalled, - } = mockLoggingFunctions(); + const { coreDebugLogMock, coreErrorLogMock, assertOnlyCalled } = + mockLoggingFunctions(); const testCfg: action.ActionConfig = { ref: "test-ref", workflow: "test-workflow", @@ -178,12 +174,4 @@ describe("main", () => { expect(coreDebugLogMock).toHaveBeenCalledOnce(); expect(coreDebugLogMock.mock.calls[0]?.[0]).toStrictEqual(testError); }); - - it("should not log info as there is nothing to report beyond the dispatch", async () => { - apiDispatchWorkflowMock.mockResolvedValue({ id: 123, url: "test-url" }); - - await main(); - - expect(coreInfoLogMock).not.toHaveBeenCalled(); - }); }); From 484339582ca391263cbf81879ff7616d86089d15 Mon Sep 17 00:00:00 2001 From: Alex Miller Date: Wed, 29 Jul 2026 15:55:08 +1200 Subject: [PATCH 5/6] cicd: support v4 release tagging --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 717ddc6f..1282abfa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: - v1.* - v2.* - v3.* + - v4.* permissions: contents: write From e7c66745245a8043d13b60432955d5e3429daba7 Mon Sep 17 00:00:00 2001 From: Alex Miller Date: Wed, 29 Jul 2026 15:21:47 +1200 Subject: [PATCH 6/6] build: generate new build --- dist/index.mjs | 512 +++++-------------------------------------------- 1 file changed, 47 insertions(+), 465 deletions(-) diff --git a/dist/index.mjs b/dist/index.mjs index 87e50904..8b9e87ee 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -20146,17 +20146,11 @@ function debug(message) { function error(message, properties = {}) { issueCommand("error", toCommandProperties(properties), message instanceof Error ? message.toString() : message); } -function warning(message, properties = {}) { - issueCommand("warning", toCommandProperties(properties), message instanceof Error ? message.toString() : message); -} function info(message) { process.stdout.write(message + os4.EOL); } // src/action.ts -import { randomUUID as randomUUID2 } from "node:crypto"; -var WORKFLOW_TIMEOUT_SECONDS = 5 * 60; -var WORKFLOW_JOB_STEPS_RETRY_SECONDS = 5; function getConfig() { return { token: getInput("token", { required: true }), @@ -20166,10 +20160,7 @@ function getConfig() { workflow: tryGetWorkflowAsNumber( getInput("workflow", { required: true }) ), - workflowInputs: getWorkflowInputs(getInput("workflow_inputs")), - workflowTimeoutSeconds: getNumberFromValue(getInput("workflow_timeout_seconds")) ?? WORKFLOW_TIMEOUT_SECONDS, - workflowJobStepsRetrySeconds: getNumberFromValue(getInput("workflow_job_steps_retry_seconds")) ?? WORKFLOW_JOB_STEPS_RETRY_SECONDS, - distinctId: getOptionalWorkflowValue(getInput("distinct_id")) ?? randomUUID2() + workflowInputs: getWorkflowInputs(getInput("workflow_inputs")) }; } function getNumberFromValue(value) { @@ -20217,9 +20208,6 @@ function tryGetWorkflowAsNumber(workflowInput) { return workflowInput; } } -function getOptionalWorkflowValue(workflowInput) { - return workflowInput || void 0; -} // node_modules/.pnpm/@actions+github@9.1.1/node_modules/@actions/github/lib/context.js import { readFileSync, existsSync as existsSync2 } from "fs"; @@ -23967,93 +23955,6 @@ function getOctokit(token, options, ...additionalPlugins) { return new GitHubWithPlugins(getOctokitOptions(token, options)); } -// src/etags.ts -var etagStore = /* @__PURE__ */ new Map(); -async function withEtag(endpoint2, params, requester) { - const { etag, savedResponse } = getEtag(endpoint2, params) ?? {}; - const paramsWithEtag = { ...params }; - if (etag) - paramsWithEtag.headers = { - "If-None-Match": etag, - ...params.headers ?? {} - }; - const response = await requester(paramsWithEtag); - if (response.status === 304 && etag && etag === extractEtag(response) && savedResponse !== void 0) { - return savedResponse; - } - rememberEtag(endpoint2, params, response); - return response; -} -function extractEtag(response) { - if ("string" !== typeof response.headers.etag) return; - return response.headers.etag.split('"')[1] ?? ""; -} -function getEtag(endpoint2, params) { - return etagStore.get(JSON.stringify({ endpoint: endpoint2, params })); -} -function rememberEtag(endpoint2, params, response) { - const etag = extractEtag(response); - if (!etag) return; - etagStore.set(JSON.stringify({ endpoint: endpoint2, params }), { - etag, - savedResponse: response - }); -} - -// src/utils.ts -function getBranchNameFromRef(ref) { - const refItems = ref.split(/\/?refs\/heads\//); - if (refItems.length > 1 && (refItems[1]?.length ?? 0) > 0) { - return refItems[1]; - } -} -function isTagRef(ref) { - return new RegExp(/\/?refs\/tags\//).test(ref); -} -function getBranchName(ref) { - if (isTagRef(ref)) { - debug(`Unable to filter branch, unsupported ref: ${ref}`); - return { isTag: true, ref }; - } - const branch = getBranchNameFromRef(ref); - if (branch) { - debug(`getBranchNameFromRef: Filtered branch name: ${ref}`); - } else { - debug( - `getBranchName: failed to get branch for ref: ${ref}, please raise an issue with this git ref.` - ); - } - return { branchName: branch, isTag: false, ref }; -} -function logInfoForBranchNameResult(branch, ref) { - if (branch.isTag) { - info(`Tag found for '${ref}', branch filtering will not be used`); - } else if (branch.branchName) { - info(`Branch found for '${ref}': ${branch.branchName}`); - } else { - info( - `Branch not found for '${ref}', branch filtering will not be used` - ); - } -} -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} -var reRegExpChar = /[\\^$.*+?()[\]{}|]/g; -var reHasRegExpChar = RegExp(reRegExpChar.source); -function escapeRegExp(str) { - return reHasRegExpChar.test(str) ? str.replace(reRegExpChar, "\\$&") : str || ""; -} -function createDistinctIdRegex(distinctId) { - const escapedDistinctId = escapeRegExp(distinctId); - if (distinctId !== escapedDistinctId) { - warning( - `Unescaped characters found in distinctId input, using: ${escapedDistinctId}` - ); - } - return new RegExp(escapedDistinctId); -} - // src/api.ts var config; var octokit; @@ -24061,370 +23962,72 @@ function init(cfg) { config = cfg ?? getConfig(); octokit = getOctokit(config.token); } -async function dispatchWorkflow(distinctId) { +function readDispatchedRun(data) { + if (typeof data !== "object" || data === null) { + return void 0; + } + const { workflow_run_id: id, html_url: url } = data; + if (typeof id !== "number" || typeof url !== "string") { + return void 0; + } + return { id, url }; +} +var RUN_DETAILS_UNSUPPORTED = "Dispatch did not return the run details, this action requires github.com or GHES >=3.21"; +function asUnsupportedRunDetailsError(error2) { + if (!(error2 instanceof Error) || !("status" in error2)) { + return void 0; + } + if (error2.status !== 400) { + return void 0; + } + return new Error(`${RUN_DETAILS_UNSUPPORTED} (${error2.message})`, { + cause: error2 + }); +} +async function dispatchWorkflow() { try { const response = await octokit.rest.actions.createWorkflowDispatch({ owner: config.owner, repo: config.repo, workflow_id: config.workflow, ref: config.ref, - inputs: { - ...config.workflowInputs ?? void 0, - distinct_id: distinctId - } + inputs: config.workflowInputs, + // The docs omit `return_run_details`. It is specified only in the OpenAPI + // description, which is what conditions the 200 and 204 responses on it. + // see: https://github.com/github/rest-api-description/tree/main/descriptions/api.github.com + return_run_details: true }); if (!(response.status == 204 || response.status == 200)) { throw new Error( `Failed to dispatch action, expected 200 or 204 but received ${response.status}` ); } + const dispatchedRun = readDispatchedRun(response.data); + if (dispatchedRun === void 0) { + throw new Error( + `${RUN_DETAILS_UNSUPPORTED}. The workflow was dispatched but its run cannot be identified` + ); + } info( `Successfully dispatched workflow: Repository: ${config.owner}/${config.repo} Branch: ${config.ref} Workflow: ${config.workflow} ` + (config.workflowInputs ? ` Workflow Inputs: ${JSON.stringify(config.workflowInputs)} -` : ``) + ` Distinct ID: ${distinctId}` - ); - } catch (error2) { - if (error2 instanceof Error) { - error( - `dispatchWorkflow: An unexpected error has occurred: ${error2.message}` - ); - debug(error2.stack ?? ""); - } - throw error2; - } -} -async function fetchWorkflowId(workflowFilename) { - try { - const sanitisedFilename = workflowFilename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").trim(); - const filenameRegex = new RegExp(`/${sanitisedFilename}`); - const workflowIterator = octokit.paginate.iterator( - octokit.rest.actions.listRepoWorkflows, - { - owner: config.owner, - repo: config.repo - } - ); - let workflowId; - let workflowIdUrl; - for await (const response of workflowIterator) { - if (response.status !== 200) { - throw new Error( - `Failed to fetch workflows, expected 200 but received ${response.status}` - ); - } - const workflowData = response.data.find( - (workflow) => filenameRegex.test(workflow.path) - ); - workflowId = workflowData?.id; - if (workflowId !== void 0) { - workflowIdUrl = workflowData?.html_url; - break; - } - } - if (workflowId === void 0) { - throw new Error(`Unable to find ID for Workflow: ${workflowFilename}`); - } - info( - `Fetched Workflow ID: - Repository: ${config.owner}/${config.repo} - Workflow ID: '${workflowId}' - Input Filename: '${workflowFilename}' - Sanitised Filename: '${sanitisedFilename}' - URL: ${workflowIdUrl}` - ); - return workflowId; - } catch (error2) { - if (error2 instanceof Error) { - error( - `fetchWorkflowId: An unexpected error has occurred: ${error2.message}` - ); - debug(error2.stack ?? ""); - } - throw error2; - } -} -async function fetchWorkflowRunUrl(runId) { - try { - const response = await octokit.rest.actions.getWorkflowRun({ - owner: config.owner, - repo: config.repo, - run_id: runId - }); - if (response.status !== 200) { - throw new Error( - `Failed to fetch Workflow Run state, expected 200 but received ${response.status}` - ); - } - debug( - `Fetched Run: - Repository: ${config.owner}/${config.repo} - Run ID: ${runId} - URL: ${response.data.html_url}` - ); - return response.data.html_url; - } catch (error2) { - if (error2 instanceof Error) { - error( - `fetchWorkflowRunUrl: An unexpected error has occurred: ${error2.message}` - ); - debug(error2.stack ?? ""); - } - throw error2; - } -} -async function fetchWorkflowRunIds(workflowId, branch, startTimeISO) { - try { - const useBranchFilter = !branch.isTag && branch.branchName !== void 0 && branch.branchName !== ""; - const createdFrom = `>=${startTimeISO}`; - const response = await withEtag( - "listWorkflowRuns", - { - owner: config.owner, - repo: config.repo, - workflow_id: workflowId, - created: createdFrom, - event: "workflow_dispatch", - ...useBranchFilter ? { - branch: branch.branchName, - per_page: 10 - } : { - per_page: 20 - } - }, - async (params) => { - return await octokit.rest.actions.listWorkflowRuns(params); - } +` : ``) + ` Run ID: ${dispatchedRun.id} + Run URL: ${dispatchedRun.url}` ); - if (response.status !== 200) { - throw new Error( - `Failed to fetch Workflow runs, expected 200 but received ${response.status}` - ); - } - const runIds = response.data.workflow_runs.map( - (workflowRun) => workflowRun.id - ); - const branchMsg = useBranchFilter ? `true (${branch.branchName})` : `false (${branch.ref})`; - debug( - `Fetched Workflow Runs: - Repository: ${config.owner}/${config.repo} - Branch Filter: ${branchMsg} - Workflow ID: ${workflowId} - Created: ${createdFrom} - Runs Fetched: [${runIds.join(", ")}]` - ); - return runIds; + return dispatchedRun; } catch (error2) { - if (error2 instanceof Error) { + const reportedError = asUnsupportedRunDetailsError(error2) ?? error2; + if (reportedError instanceof Error) { error( - `fetchWorkflowRunIds: An unexpected error has occurred: ${error2.message}` - ); - debug(error2.stack ?? ""); - } - throw error2; - } -} -async function fetchWorkflowRunJobSteps(runId) { - try { - const response = await withEtag( - "listJobsForWorkflowRun", - { - owner: config.owner, - repo: config.repo, - run_id: runId, - filter: "latest" - }, - async (params) => { - return await octokit.rest.actions.listJobsForWorkflowRun(params); - } - ); - if (response.status !== 200) { - throw new Error( - `Failed to fetch Workflow Run Jobs, expected 200 but received ${response.status}` - ); - } - const jobs = response.data.jobs.map((job) => ({ - id: job.id, - steps: job.steps?.map((step) => step.name) ?? [] - })); - const steps = Array.from(new Set(jobs.flatMap((job) => job.steps))); - debug( - `Fetched Workflow Run Job Steps: - Repository: ${config.owner}/${config.repo} - Workflow Run ID: ${runId} - Jobs Fetched: [${jobs.map((job) => job.id).join(", ")}] - Steps Fetched: [${steps.map((step) => `"${step}"`).join(", ")}]` - ); - return steps; - } catch (error2) { - if (error2 instanceof Error) { - error( - `fetchWorkflowRunJobSteps: An unexpected error has occurred: ${error2.message}` - ); - debug(error2.stack ?? ""); - } - throw error2; - } -} -async function retryOrTimeout(retryFunc, timeoutMs) { - const startTime = Date.now(); - let elapsedTime = 0; - while (elapsedTime < timeoutMs) { - const response = await retryFunc(); - if (response.length > 0) { - return { success: true, value: response }; - } - await sleep(1e3); - elapsedTime = Date.now() - startTime; - } - return { success: false, reason: "timeout" }; -} - -// src/constants.ts -var WORKFLOW_FETCH_TIMEOUT_MS = 60 * 1e3; -var WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MAX = 3; -var WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MS = 500; - -// src/return-dispatch.ts -function shouldRetryOrThrow(error2, currentAttempts) { - switch (error2.message) { - case "Server Error": { - if (currentAttempts < WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MAX) { - debug( - `Encountered a Server Error while attempting to fetch steps, retrying in ${WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MS}ms` - ); - return true; - } - return false; - } - case "Not Found": { - debug("Could not identify ID in run, continuing..."); - return false; - } - default: { - debug(`Unhandled error has occurred: ${error2.message}`); - throw error2; - } - } -} -async function attemptToFindRunId(idRegex, workflowRunIds) { - if (workflowRunIds.length === 0) { - return { - success: false, - reason: "invalid input" - }; - } - let currentWorkflowRunIndex = 0; - let currentFetchWorkflowRunJobStepsAttempt = 0; - while (currentWorkflowRunIndex < workflowRunIds.length) { - const id = workflowRunIds[currentWorkflowRunIndex]; - if (id === void 0) { - break; - } - try { - const steps = await fetchWorkflowRunJobSteps(id); - for (const step of steps) { - if (idRegex.test(step)) { - const url = await fetchWorkflowRunUrl(id); - return { success: true, value: { id, url } }; - } - } - } catch (error2) { - if (!(error2 instanceof Error)) { - throw error2; - } - const shouldRetry = shouldRetryOrThrow( - error2, - currentFetchWorkflowRunJobStepsAttempt + `dispatchWorkflow: An unexpected error has occurred: ${reportedError.message}` ); - if (shouldRetry) { - currentFetchWorkflowRunJobStepsAttempt++; - await sleep(WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MS); - continue; - } + debug(reportedError.stack ?? ""); } - currentFetchWorkflowRunJobStepsAttempt = 0; - currentWorkflowRunIndex++; + throw reportedError; } - return { success: false, reason: "timeout" }; -} -async function getWorkflowId(workflow) { - if (typeof workflow === "number") { - return workflow; - } - info(`Fetching Workflow ID for ${workflow}...`); - const workflowId = await fetchWorkflowId(workflow); - info(`Fetched Workflow ID: ${workflowId}`); - return workflowId; -} -function handleActionSuccess(id, url) { - info( - `Successfully identified remote Run: - Run ID: ${id} - URL: ${url}` - ); - setOutput("run_id" /* runId */, id); - setOutput("run_url" /* runUrl */, url); -} -function handleActionFail() { - error("Failed: Timeout exceeded while attempting to get Run ID"); - setFailed("Timeout exceeded while attempting to get Run ID"); -} -async function getRunIdAndUrl({ - startTime, - branch, - distinctIdRegex, - workflowId, - workflowTimeoutMs, - workflowJobStepsRetryMs -}) { - const startTimeISO = new Date(startTime).toISOString(); - const retryTimeout = Math.max( - WORKFLOW_FETCH_TIMEOUT_MS, - workflowTimeoutMs - ); - let attemptNo = 0; - let elapsedTime = Date.now() - startTime; - while (elapsedTime < workflowTimeoutMs) { - attemptNo++; - const fetchWorkflowRunIds2 = await retryOrTimeout( - () => fetchWorkflowRunIds(workflowId, branch, startTimeISO), - retryTimeout - ); - if (!fetchWorkflowRunIds2.success) { - debug( - `Timed out while attempting to fetch Workflow Run IDs, waited ${Date.now() - startTime}ms` - ); - break; - } - const workflowRunIds = fetchWorkflowRunIds2.value; - if (workflowRunIds.length > 0) { - debug( - `Attempting to get step names for Run IDs: [${workflowRunIds.join(", ")}]` - ); - const result = await attemptToFindRunId(distinctIdRegex, workflowRunIds); - if (result.success) { - return result; - } - info( - `Exhausted searching IDs in known runs, attempt ${attemptNo}...` - ); - } else { - info(`No Run IDs found for workflow, attempt ${attemptNo}...`); - } - const waitTime = Math.min( - workflowJobStepsRetryMs * attemptNo, - // Lineal backoff - workflowTimeoutMs - elapsedTime - // Ensure we don't exceed the timeout - ); - info(`Waiting for ${waitTime}ms before the next attempt...`); - await sleep(waitTime); - elapsedTime = Date.now() - startTime; - } - return { success: false, reason: "timeout" }; } // src/main.ts @@ -24433,31 +24036,10 @@ async function main() { const startTime = Date.now(); const config2 = getConfig(); init(config2); - const workflowId = await getWorkflowId(config2.workflow); - await dispatchWorkflow(config2.distinctId); - info("Attempt to extract branch name from ref..."); - const branch = getBranchName(config2.ref); - logInfoForBranchNameResult(branch, config2.ref); - const distinctIdRegex = createDistinctIdRegex(config2.distinctId); - info("Attempting to identify run ID from steps..."); - debug( - `Attempting to identify run ID for ${config2.workflow} (${workflowId})` - ); - const result = await getRunIdAndUrl({ - startTime, - branch, - distinctIdRegex, - workflowId, - workflowTimeoutMs: config2.workflowTimeoutSeconds * 1e3, - workflowJobStepsRetryMs: config2.workflowJobStepsRetrySeconds * 1e3 - }); - if (result.success) { - handleActionSuccess(result.value.id, result.value.url); - debug(`Completed (${Date.now() - startTime}ms)`); - } else { - handleActionFail(); - debug(`Timed out (${Date.now() - startTime}ms)`); - } + const dispatchedRun = await dispatchWorkflow(); + setOutput("run_id" /* runId */, dispatchedRun.id); + setOutput("run_url" /* runUrl */, dispatchedRun.url); + debug(`Completed (${Date.now() - startTime}ms)`); } catch (error2) { if (error2 instanceof Error) { const failureMsg = `Failed: An unhandled error has occurred: ${error2.message}`;