├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── renovate.json └── workflows │ ├── action.yml │ ├── codeql-analysis.yml │ ├── dispatch.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .release-it.json ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── action.yml ├── dist └── index.mjs ├── esbuild.config.mjs ├── eslint.config.mjs ├── mise.toml ├── package.json ├── pnpm-lock.yaml ├── src ├── __snapshots__ │ └── return-dispatch.spec.ts.snap ├── action.spec.ts ├── action.ts ├── api.spec.ts ├── api.ts ├── constants.ts ├── main.spec.ts ├── main.ts ├── reset.d.ts ├── return-dispatch.spec.ts ├── return-dispatch.ts ├── test-utils │ └── logging.mock.ts ├── types.ts ├── utils.spec.ts └── utils.ts ├── tsconfig.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | indent_size = 2 7 | insert_final_newline = true 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/** -diff linguist-generated=true 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: codex0 2 | open_collective: codex-nz 3 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended", "group:allNonMajor", "schedule:monthly"], 4 | "timezone": "Pacific/Auckland", 5 | "labels": ["dependencies"], 6 | "rangeStrategy": "bump", 7 | "lockFileMaintenance": { 8 | "enabled": true, 9 | "automerge": true, 10 | "schedule": ["every 3 months on the first day of the month"] 11 | }, 12 | "packageRules": [ 13 | { 14 | "matchDepTypes": ["devDependencies"], 15 | "automerge": true 16 | }, 17 | { 18 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 19 | "automerge": true 20 | }, 21 | { 22 | "matchUpdateTypes": ["major"] 23 | } 24 | ], 25 | "vulnerabilityAlerts": { 26 | "labels": ["security"], 27 | "automerge": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/action.yml: -------------------------------------------------------------------------------- 1 | name: Action Test 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set target branch 14 | run: echo "TARGET_BRANCH=${{ github.ref }}" >> $GITHUB_ENV 15 | - name: Set target branch for PR event 16 | if: ${{ github.event_name == 'pull_request' }} 17 | run: echo "TARGET_BRANCH=${{ github.event.pull_request.head.ref }}" >> $GITHUB_ENV 18 | - name: Dispatch and return Run ID 19 | id: return_dispatch 20 | uses: ./ 21 | with: 22 | token: ${{ secrets.TOKEN }} 23 | ref: ${{ env.TARGET_BRANCH }} 24 | repo: return-dispatch 25 | owner: codex- 26 | workflow: dispatch.yml 27 | workflow_inputs: '{"cake":"delicious"}' 28 | workflow_timeout_seconds: 30 29 | workflow_job_steps_retry_seconds: 10 30 | - name: Evaluate that the Run ID output has been set 31 | run: | 32 | if [ "${{ steps.return_dispatch.outputs.run_id }}" == "" ]; then 33 | echo "Failed to return Run ID" 34 | exit 1 35 | fi 36 | - name: Evaluate that the Run URL output has been set 37 | run: | 38 | if [ "${{ steps.return_dispatch.outputs.run_url }}" == "" ]; then 39 | echo "Failed to return Run ID" 40 | exit 1 41 | fi 42 | - name: Output fetched Run ID 43 | run: echo ${{ steps.return_dispatch.outputs.run_id }} 44 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: 30 1 * * 0 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | strategy: 20 | fail-fast: false 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | - name: Initialize CodeQL 25 | uses: github/codeql-action/init@v3 26 | with: 27 | languages: TypeScript 28 | source-root: src 29 | - name: Perform CodeQL Analysis 30 | uses: github/codeql-action/analyze@v3 31 | -------------------------------------------------------------------------------- /.github/workflows/dispatch.yml: -------------------------------------------------------------------------------- 1 | name: action-test 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | distinct_id: 6 | required: true 7 | cake: # Test for input passthrough. 8 | required: true 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: echo distinct ID ${{ github.event.inputs.distinct_id }} 15 | run: echo ${{ github.event.inputs.distinct_id }} 16 | - name: echo input passthrough 17 | run: echo ${{ github.event.inputs.cake }} 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - v1.* 6 | - v2.* 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | env: 14 | CI: true 15 | GITHUB_TOKEN: ${{ secrets.ACTION_GITHUB_TOKEN }} 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 # Need history for changelog generation 21 | - uses: jdx/mise-action@v2 22 | - run: pnpm i 23 | - run: pnpm run build 24 | # We need to make sure the checked-in `index.js` actually matches what we expect it to be. 25 | - name: Compare the expected and actual dist/ directories 26 | run: | 27 | if [ "$(git diff --ignore-space-at-eol dist/ | wc -l)" -gt "0" ]; then 28 | echo "Detected uncommitted changes after build. See status below:" 29 | git diff 30 | exit 1 31 | fi 32 | - run: pnpm exec changelogithub 33 | env: 34 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: jdx/mise-action@v2 14 | - run: pnpm i 15 | - name: build 16 | run: pnpm run build:types 17 | - name: test 18 | id: test 19 | if: ${{ always() }} 20 | run: pnpm run test 21 | - name: lint 22 | if: ${{ always() }} 23 | run: pnpm run lint 24 | - name: style 25 | if: ${{ always() }} 26 | run: pnpm run format:check 27 | codecov: # Send only a single coverage report per run 28 | needs: [build] 29 | timeout-minutes: 15 30 | env: 31 | CI: true 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: jdx/mise-action@v2 36 | - run: pnpm i 37 | - name: test 38 | run: pnpm run test:coverage 39 | - name: codecov 40 | uses: codecov/codecov-action@v5 41 | env: 42 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Coverage directory used by tools like istanbul 5 | coverage 6 | *.lcov 7 | 8 | # OS metadata 9 | .DS_Store 10 | Thumbs.db 11 | 12 | # Ignore built ts files 13 | lib 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "changelog": "npx auto-changelog --stdout --commit-limit false --unreleased --template https://raw.githubusercontent.com/release-it/release-it/master/templates/changelog-compact.hbs" 4 | }, 5 | "github": { 6 | "release": true 7 | }, 8 | "hooks": { 9 | "after:bump": "npx auto-changelog -p" 10 | }, 11 | "npm": { 12 | "publish": false 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "streetsidesoftware.code-spell-checker", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode", 6 | "editorconfig.editorconfig" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Octokit", 4 | "camelcase", 5 | "jszip" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018-2022 Alex Miller and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Action: return-dispatch 2 | 3 | [![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) 4 | 5 | Dispatch an action to a foreign repository and output the newly created run ID. 6 | 7 | This Action exists as a workaround for the issue where dispatching an action to foreign repository does not return any kind of identifier. 8 | 9 | ## Usage 10 | 11 | Ensure you have configured your remote action correctly, see below for an example. 12 | 13 | ### Dispatching Repository Action 14 | 15 | ```yaml 16 | steps: 17 | - name: Dispatch an action and get the run ID and URL 18 | uses: codex-/return-dispatch@v2 19 | id: return_dispatch 20 | with: 21 | token: ${{ secrets.TOKEN }} # Note this is NOT GITHUB_TOKEN but a PAT 22 | ref: target_branch # or refs/heads/target_branch 23 | repo: repository-name 24 | owner: repository-owner 25 | workflow: automation-test.yml 26 | workflow_inputs: '{ "some_input": "value" }' # Optional 27 | workflow_timeout_seconds: 120 # Default: 300 28 | workflow_job_steps_retry_seconds: 29 | # Lineal backoff retry attempts are made where the attempt count is 30 | # the magnitude and the scaling value is `workflow_job_steps_retry_seconds` 31 | 10 # Default: 5 32 | distinct_id: someDistinctId # Optional 33 | 34 | - name: Use the output run ID and URL 35 | run: | 36 | echo ${{steps.return_dispatch.outputs.run_id}} 37 | echo ${{steps.return_dispatch.outputs.run_url}} 38 | ``` 39 | 40 | ### Receiving Repository Action 41 | 42 | In the earliest possible stage for the Action, add the input into the name. 43 | 44 | As every step needs a `uses` or `run`, simply `echo` the ID or similar to satisfy this requirement. 45 | 46 | ```yaml 47 | name: action-test 48 | on: 49 | workflow_dispatch: 50 | inputs: 51 | distinct_id: 52 | 53 | jobs: 54 | test: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: echo distinct ID ${{ github.event.inputs.distinct_id }} 58 | run: echo ${{ github.event.inputs.distinct_id }} 59 | ``` 60 | 61 | ## Token 62 | 63 | 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. 64 | 65 | ### Permissions Required 66 | 67 | The permissions required for this action to function correctly are: 68 | 69 | - `repo` scope 70 | - You may get away with simply having `repo:public_repo` 71 | - `repo` is definitely needed if the repository is private. 72 | - `actions:read` 73 | - `actions:write` 74 | 75 | ### APIs Used 76 | 77 | For the sake of transparency please note that this action uses the following API calls: 78 | 79 | - [Create a workflow dispatch event](https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event) 80 | - POST `/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches` 81 | - Permissions: 82 | - `repo` 83 | - `actions:write` 84 | - [List repository workflows](https://docs.github.com/en/rest/actions/workflows#list-repository-workflows) 85 | - GET `/repos/{owner}/{repo}/actions/workflows` 86 | - Permissions: 87 | - `repo` 88 | - `actions:read` 89 | - [List workflow runs](https://docs.github.com/en/rest/actions/workflow-runs#list-workflow-runs-for-a-repository) 90 | - GET `/repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs` 91 | - Permissions: 92 | - `repo` 93 | - [List jobs for a workflow run](https://docs.github.com/en/rest/actions/workflow-jobs#list-jobs-for-a-workflow-run) 94 | - GET `/repos/{owner}/{repo}/actions/runs/{run_id}/jobs` 95 | - Permissions: 96 | - `repo` 97 | - `actions:read` 98 | 99 | For more information please see [api.ts](./src/api.ts). 100 | 101 | ## Where does this help? 102 | 103 | 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. 104 | 105 | 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). 106 | 107 | ## Flow 108 | 109 | ```ascii 110 | ┌─────────────────┐ 111 | │ │ 112 | │ Dispatch Action │ 113 | │ │ 114 | │ with unique ID │ 115 | │ │ 116 | └───────┬─────────┘ 117 | │ 118 | │ 119 | ▼ ┌───────────────┐ 120 | ┌────────────────┐ │ │ 121 | │ │ │ Request steps │ 122 | │ Request top 10 ├────────────────►│ │ 123 | │ │ │ for each run │ 124 | │ workflow runs │ │ │ 125 | │ │◄────────────────┤ and search │ 126 | └───────┬────────┘ Retry │ │ 127 | │ └───────┬───────┘ 128 | │ │ 129 | Timeout │ │ 130 | │ │ 131 | ▼ ▼ 132 | ┌──────┐ ┌───────────────┐ 133 | │ Fail │ │ Output run ID │ 134 | └──────┘ └───────────────┘ 135 | ``` 136 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Return Dispatch 2 | description: Dispatch an action and return the new run ID. 3 | author: Alex Miller 4 | branding: 5 | icon: refresh-cw 6 | color: green 7 | outputs: 8 | run_id: 9 | description: The identified Run ID. 10 | run_url: 11 | description: The identified Run URL. 12 | inputs: 13 | token: 14 | description: GitHub Personal Access Token for making API requests. 15 | required: true 16 | ref: 17 | description: The git reference for the workflow. The reference can be a branch or tag name. 18 | required: true 19 | repo: 20 | description: Repository of the action to dispatch. 21 | required: true 22 | owner: 23 | description: Owner of the given repository. 24 | required: true 25 | workflow: 26 | description: Workflow to return an ID for. Can be the ID or the workflow filename. 27 | required: true 28 | workflow_inputs: 29 | description: A flat JSON object, only supports strings, numbers, and booleans (as per workflow inputs API). 30 | workflow_timeout_seconds: 31 | description: Time until giving up waiting for the start of the workflow run. 32 | default: 300 33 | workflow_job_steps_retry_seconds: 34 | description: | 35 | The interval (in seconds) to wait between retries. A linear backoff strategy is used, where the wait time 36 | increases by this value with each attempt (e.g., 1st retry = this value, 2nd retry = 2x this value, etc.). 37 | default: 5 38 | distinct_id: 39 | description: Specify a static string to use instead of a random distinct ID. 40 | 41 | runs: 42 | using: node20 43 | main: dist/index.mjs 44 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { analyzeMetafile, build } from "esbuild"; 3 | 4 | (async () => { 5 | try { 6 | const startTime = Date.now(); 7 | console.info( 8 | chalk.bold(`🚀 ${chalk.blueBright("return-dispatch")} Build\n`), 9 | ); 10 | 11 | const result = await build({ 12 | entryPoints: ["./src/main.ts"], 13 | outfile: "dist/index.mjs", 14 | metafile: true, 15 | bundle: true, 16 | format: "esm", 17 | platform: "node", 18 | target: ["node20"], 19 | treeShaking: true, 20 | // Ensure require is properly defined: https://github.com/evanw/esbuild/issues/1921 21 | banner: { 22 | js: 23 | "import { createRequire as __return_dispatch_cr } from 'node:module';\n" + 24 | "const require = __return_dispatch_cr(import.meta.url);", 25 | }, 26 | }); 27 | 28 | const analysis = await analyzeMetafile(result.metafile); 29 | console.info(`📝 Bundle Analysis:${analysis}`); 30 | 31 | console.info( 32 | `${chalk.bold.green("✔ Bundled successfully!")} (${ 33 | Date.now() - startTime 34 | }ms)`, 35 | ); 36 | } catch (error) { 37 | console.error(`🧨 ${chalk.red.bold("Failed:")} ${error.message}`); 38 | console.debug(`📚 ${chalk.blueBright.bold("Stack:")} ${error.stack}`); 39 | process.exit(1); 40 | } 41 | })(); 42 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import jsEslint from "@eslint/js"; 4 | import eslintConfigPrettier from "eslint-config-prettier"; 5 | import eslintPluginImportX from "eslint-plugin-import-x"; 6 | import * as tsEslint from "typescript-eslint"; 7 | 8 | export default tsEslint.config( 9 | jsEslint.configs.recommended, 10 | eslintPluginImportX.flatConfigs.recommended, 11 | eslintPluginImportX.flatConfigs.typescript, 12 | ...tsEslint.configs.strictTypeChecked, 13 | ...tsEslint.configs.stylisticTypeChecked, 14 | { 15 | languageOptions: { 16 | parserOptions: { 17 | projectService: { 18 | allowDefaultProject: ["*.js", "*.mjs"], 19 | }, 20 | tsconfigRootDir: import.meta.dirname, 21 | }, 22 | }, 23 | }, 24 | { 25 | ignores: [ 26 | "**/coverage", 27 | "**/dist", 28 | "**/esbuild.config.mjs", 29 | "**/vitest.config.ts", 30 | ], 31 | }, 32 | { 33 | rules: { 34 | "@typescript-eslint/await-thenable": "warn", 35 | "@typescript-eslint/explicit-function-return-type": "warn", 36 | "@typescript-eslint/no-explicit-any": "off", 37 | "@typescript-eslint/no-floating-promises": [ 38 | "warn", 39 | { ignoreIIFE: true, ignoreVoid: false }, 40 | ], 41 | "@typescript-eslint/no-shadow": "error", 42 | "@typescript-eslint/no-unused-vars": [ 43 | "warn", 44 | { argsIgnorePattern: "^_" }, 45 | ], 46 | "@typescript-eslint/restrict-template-expressions": [ 47 | "error", 48 | { 49 | allowNever: true, 50 | allowNumber: true, 51 | }, 52 | ], 53 | "import-x/no-extraneous-dependencies": [ 54 | "error", 55 | { 56 | devDependencies: true, 57 | optionalDependencies: true, 58 | peerDependencies: true, 59 | }, 60 | ], 61 | "import-x/order": [ 62 | "warn", 63 | { "newlines-between": "always", alphabetize: { order: "asc" } }, 64 | ], 65 | "no-console": ["warn"], 66 | }, 67 | }, 68 | { 69 | files: ["**/*.spec.ts"], 70 | rules: { 71 | "@typescript-eslint/explicit-function-return-type": "off", 72 | "@typescript-eslint/no-non-null-assertion": "off", 73 | "@typescript-eslint/no-unsafe-assignment": "off", 74 | "@typescript-eslint/no-unsafe-member-access": "off", 75 | }, 76 | }, 77 | { 78 | files: ["**/*.js", "**/*.mjs"], 79 | ...tsEslint.configs.disableTypeChecked, 80 | }, 81 | eslintConfigPrettier, 82 | ); 83 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "20.19.2" 3 | pnpm = "10.11.0" 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "return-dispatch", 3 | "version": "2.0.4", 4 | "private": true, 5 | "description": "Dispatch an action and output the run ID.", 6 | "main": "lib/main.js", 7 | "type": "module", 8 | "scripts": { 9 | "build": "pnpm run build:types && pnpm run build:bundle", 10 | "build:bundle": "node ./esbuild.config.mjs", 11 | "build:types": "tsc", 12 | "format:check": "prettier --check **/*.ts", 13 | "format": "pnpm run format:check --write", 14 | "lint": "eslint .", 15 | "lint:fix": "pnpm run lint --fix", 16 | "release": "release-it", 17 | "test": "vitest", 18 | "test:coverage": "vitest --coverage" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/Codex-/return-dispatch.git" 23 | }, 24 | "keywords": [ 25 | "actions", 26 | "node", 27 | "setup" 28 | ], 29 | "author": "Alex Miller", 30 | "license": "MIT", 31 | "dependencies": { 32 | "@actions/core": "^1.11.1", 33 | "@actions/github": "^6.0.1" 34 | }, 35 | "devDependencies": { 36 | "@eslint/js": "^9.28.0", 37 | "@opentf/std": "^0.13.0", 38 | "@total-typescript/ts-reset": "^0.6.1", 39 | "@types/node": "~20.17.57", 40 | "@typescript-eslint/eslint-plugin": "^8.33.0", 41 | "@typescript-eslint/parser": "^8.33.0", 42 | "@vitest/coverage-v8": "^3.1.4", 43 | "chalk": "^5.4.1", 44 | "changelogithub": "^13.15.0", 45 | "esbuild": "^0.25.5", 46 | "eslint": "^9.28.0", 47 | "eslint-config-prettier": "^10.1.5", 48 | "eslint-import-resolver-typescript": "^4.4.2", 49 | "eslint-plugin-import-x": "^4.15.0", 50 | "prettier": "3.5.3", 51 | "typescript": "^5.8.3", 52 | "typescript-eslint": "^8.33.0", 53 | "vitest": "^3.1.4" 54 | }, 55 | "pnpm": { 56 | "onlyBuiltDependencies": [ 57 | "esbuild" 58 | ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/__snapshots__/return-dispatch.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`return-dispatch > getRunIdAndUrl > called fetchWorkflowRunIds with the provided workflowId and branch 1`] = `"Attempting to get step names for Run IDs: [0]"`; 4 | 5 | exports[`return-dispatch > getRunIdAndUrl > should call retryOrTimeout with the larger workflowTimeoutMs timeout value 1`] = `"Attempting to get step names for Run IDs: [0]"`; 6 | 7 | exports[`return-dispatch > getRunIdAndUrl > should retry until an ID is found 1`] = `"No Run IDs found for workflow, attempt 1..."`; 8 | 9 | exports[`return-dispatch > getRunIdAndUrl > should retry until an ID is found 2`] = `"Waiting for 5000ms before the next attempt..."`; 10 | 11 | exports[`return-dispatch > getRunIdAndUrl > should retry until an ID is found 3`] = `"No Run IDs found for workflow, attempt 2..."`; 12 | 13 | exports[`return-dispatch > getRunIdAndUrl > should retry until an ID is found 4`] = `"Waiting for 10000ms before the next attempt..."`; 14 | 15 | exports[`return-dispatch > getRunIdAndUrl > should retry until an ID is found 5`] = `"Attempting to get step names for Run IDs: [0]"`; 16 | 17 | exports[`return-dispatch > getRunIdAndUrl > should return the ID when found 1`] = `"Attempting to get step names for Run IDs: [0]"`; 18 | 19 | exports[`return-dispatch > getRunIdAndUrl > should return the ID when found 2`] = `undefined`; 20 | 21 | exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 1`] = `"Exhausted searching IDs in known runs, attempt 1..."`; 22 | 23 | exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 2`] = `"Waiting for 3000ms before the next attempt..."`; 24 | 25 | exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 3`] = `"Attempting to get step names for Run IDs: [0]"`; 26 | 27 | exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 4`] = `"Exhausted searching IDs in known runs, attempt 2..."`; 28 | 29 | exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 5`] = `"Waiting for 6000ms before the next attempt..."`; 30 | 31 | exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 6`] = `"Attempting to get step names for Run IDs: [0]"`; 32 | 33 | exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 7`] = `"Exhausted searching IDs in known runs, attempt 3..."`; 34 | 35 | exports[`return-dispatch > getRunIdAndUrl > should timeout when unable to find over time 8`] = `"Attempting to get step names for Run IDs: [0]"`; 36 | -------------------------------------------------------------------------------- /src/action.spec.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from "node:crypto"; 2 | 3 | import * as core from "@actions/core"; 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 5 | 6 | import { type ActionConfig, getConfig } from "./action.ts"; 7 | 8 | vi.mock("node:crypto", () => ({ 9 | randomUUID: vi.fn(), 10 | })); 11 | vi.mock("@actions/core"); 12 | 13 | describe("Action", () => { 14 | const workflowInputs = { 15 | cake: "delicious", 16 | }; 17 | 18 | describe("getConfig", () => { 19 | // Represent the process.env inputs. 20 | let mockEnvConfig: any; 21 | 22 | beforeEach(() => { 23 | mockEnvConfig = { 24 | token: "secret", 25 | ref: "feature_branch", 26 | repo: "repository", 27 | owner: "owner", 28 | workflow: "workflow_name", 29 | workflow_inputs: JSON.stringify(workflowInputs), 30 | workflow_timeout_seconds: "60", 31 | workflow_job_steps_retry_seconds: "3", 32 | distinct_id: "distinct_id", 33 | }; 34 | 35 | vi.spyOn(core, "getInput").mockImplementation((input: string): string => { 36 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 37 | switch (input) { 38 | case "token": 39 | return mockEnvConfig.token; 40 | case "ref": 41 | return mockEnvConfig.ref; 42 | case "repo": 43 | return mockEnvConfig.repo; 44 | case "owner": 45 | return mockEnvConfig.owner; 46 | case "workflow": 47 | return mockEnvConfig.workflow; 48 | case "workflow_inputs": 49 | return mockEnvConfig.workflow_inputs; 50 | case "workflow_timeout_seconds": 51 | return mockEnvConfig.workflow_timeout_seconds; 52 | case "workflow_job_steps_retry_seconds": 53 | return mockEnvConfig.workflow_job_steps_retry_seconds; 54 | case "distinct_id": 55 | return mockEnvConfig.distinct_id; 56 | default: 57 | throw new Error("invalid input requested"); 58 | } 59 | /* eslint-enable @typescript-eslint/no-unsafe-return */ 60 | }); 61 | }); 62 | 63 | afterEach(() => { 64 | vi.restoreAllMocks(); 65 | }); 66 | 67 | it("should return a valid config", () => { 68 | const config: ActionConfig = getConfig(); 69 | 70 | // Assert that the numbers / types have been properly loaded. 71 | expect(config.token).toStrictEqual("secret"); 72 | expect(config.ref).toStrictEqual("feature_branch"); 73 | expect(config.repo).toStrictEqual("repository"); 74 | expect(config.owner).toStrictEqual("owner"); 75 | expect(config.workflow).toStrictEqual("workflow_name"); 76 | expect(config.workflowInputs).toStrictEqual(workflowInputs); 77 | expect(config.workflowTimeoutSeconds).toStrictEqual(60); 78 | expect(config.workflowJobStepsRetrySeconds).toStrictEqual(3); 79 | expect(config.distinctId).toStrictEqual("distinct_id"); 80 | }); 81 | 82 | it("should have a number for a workflow when given a workflow ID", () => { 83 | mockEnvConfig.workflow = "123456"; 84 | const config: ActionConfig = getConfig(); 85 | 86 | expect(config.workflow).toStrictEqual(123456); 87 | }); 88 | 89 | it("should provide a default workflow timeout if none is supplied", () => { 90 | mockEnvConfig.workflow_timeout_seconds = ""; 91 | const config: ActionConfig = getConfig(); 92 | 93 | expect(config.workflowTimeoutSeconds).toStrictEqual(300); 94 | }); 95 | 96 | it("should provide a default workflow job step retry if none is supplied", () => { 97 | mockEnvConfig.workflow_job_steps_retry_seconds = ""; 98 | const config: ActionConfig = getConfig(); 99 | 100 | expect(config.workflowJobStepsRetrySeconds).toStrictEqual(5); 101 | }); 102 | 103 | it("should handle no inputs being provided", () => { 104 | mockEnvConfig.workflow_inputs = ""; 105 | const config: ActionConfig = getConfig(); 106 | 107 | expect(config.workflowInputs).toBeUndefined(); 108 | }); 109 | 110 | it("should throw if invalid workflow inputs JSON is provided", () => { 111 | mockEnvConfig.workflow_inputs = "{"; 112 | 113 | expect(() => getConfig()).toThrowError(); 114 | }); 115 | 116 | it("should handle workflow inputs JSON containing strings numbers or booleans", () => { 117 | mockEnvConfig.workflow_inputs = 118 | '{"cake":"delicious","pie":9001,"parfait":false}'; 119 | 120 | expect(() => getConfig()).not.toThrowError(); 121 | }); 122 | 123 | it("should throw if a workflow inputs JSON doesn't contain strings numbers or booleans", () => { 124 | const debugMock = vi 125 | .spyOn(core, "debug") 126 | .mockImplementation(() => undefined); 127 | 128 | const callAndAssert = (input: string, errorMsg: string) => { 129 | mockEnvConfig.workflow_inputs = input; 130 | expect(() => getConfig()).toThrowError(errorMsg); 131 | expect(debugMock).toHaveBeenCalledOnce(); 132 | debugMock.mockReset(); 133 | }; 134 | 135 | callAndAssert('{"pie":{"powerLevel":9001}}', '"pie" value is object'); 136 | callAndAssert('{"vegetable":null}', '"vegetable" value is null'); 137 | callAndAssert('{"fruit":[]}', '"fruit" value is Array'); 138 | }); 139 | 140 | it("should handle no distinct_id being provided", () => { 141 | const v4Mock = vi.mocked(randomUUID); 142 | v4Mock.mockImplementationOnce(() => "test-mocked-uuid-is-used"); 143 | mockEnvConfig.distinct_id = ""; 144 | const config: ActionConfig = getConfig(); 145 | 146 | expect(config.distinctId).toStrictEqual("test-mocked-uuid-is-used"); 147 | expect(v4Mock).toHaveBeenCalledOnce(); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /src/action.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from "node:crypto"; 2 | 3 | import * as core from "@actions/core"; 4 | 5 | const WORKFLOW_TIMEOUT_SECONDS = 5 * 60; 6 | const WORKFLOW_JOB_STEPS_RETRY_SECONDS = 5; 7 | 8 | /** 9 | * action.yaml definition. 10 | */ 11 | export interface ActionConfig { 12 | /** 13 | * GitHub API token for making requests. 14 | */ 15 | token: string; 16 | 17 | /** 18 | * The git reference for the workflow. The reference can be a branch or tag name. 19 | */ 20 | ref: string; 21 | 22 | /** 23 | * Repository of the action to await. 24 | */ 25 | repo: string; 26 | 27 | /** 28 | * Owner of the given repository. 29 | */ 30 | owner: string; 31 | 32 | /** 33 | * Workflow to return an ID for. Can be the ID or the workflow filename. 34 | */ 35 | workflow: string | number; 36 | 37 | /** 38 | * A flat JSON object, only supports strings (as per workflow inputs API). 39 | */ 40 | workflowInputs?: ActionWorkflowInputs; 41 | 42 | /** 43 | * Time until giving up on identifying the Run ID. 44 | */ 45 | workflowTimeoutSeconds: number; 46 | 47 | /** 48 | * Time in retries for identifying the Run ID. 49 | */ 50 | workflowJobStepsRetrySeconds: number; 51 | 52 | /** 53 | * Specify a static ID to use instead of a distinct ID. 54 | */ 55 | distinctId: string; 56 | } 57 | 58 | type ActionWorkflowInputs = Record; 59 | 60 | export enum ActionOutputs { 61 | runId = "run_id", 62 | runUrl = "run_url", 63 | } 64 | 65 | export function getConfig(): ActionConfig { 66 | return { 67 | token: core.getInput("token", { required: true }), 68 | ref: core.getInput("ref", { required: true }), 69 | repo: core.getInput("repo", { required: true }), 70 | owner: core.getInput("owner", { required: true }), 71 | workflow: tryGetWorkflowAsNumber( 72 | core.getInput("workflow", { required: true }), 73 | ), 74 | workflowInputs: getWorkflowInputs(core.getInput("workflow_inputs")), 75 | workflowTimeoutSeconds: 76 | getNumberFromValue(core.getInput("workflow_timeout_seconds")) ?? 77 | WORKFLOW_TIMEOUT_SECONDS, 78 | workflowJobStepsRetrySeconds: 79 | getNumberFromValue(core.getInput("workflow_job_steps_retry_seconds")) ?? 80 | WORKFLOW_JOB_STEPS_RETRY_SECONDS, 81 | distinctId: 82 | getOptionalWorkflowValue(core.getInput("distinct_id")) ?? randomUUID(), 83 | }; 84 | } 85 | 86 | function getNumberFromValue(value: string): number | undefined { 87 | if (value === "") { 88 | return undefined; 89 | } 90 | 91 | try { 92 | const num = parseInt(value); 93 | 94 | if (isNaN(num)) { 95 | throw new Error("Parsed value is NaN"); 96 | } 97 | 98 | return num; 99 | } catch { 100 | throw new Error(`Unable to parse value: ${value}`); 101 | } 102 | } 103 | 104 | function getWorkflowInputs( 105 | workflowInputs: string, 106 | ): ActionWorkflowInputs | undefined { 107 | if (workflowInputs === "") { 108 | return undefined; 109 | } 110 | 111 | try { 112 | const parsedJson = JSON.parse(workflowInputs) as Record; 113 | for (const key of Object.keys(parsedJson)) { 114 | const value = parsedJson[key]; 115 | const type = 116 | value === null ? "null" : Array.isArray(value) ? "Array" : typeof value; 117 | 118 | if (!["string", "number", "boolean"].includes(type)) { 119 | throw new Error( 120 | `Expected value to be string, number, or boolean. "${key}" value is ${type}`, 121 | ); 122 | } 123 | } 124 | return parsedJson as ActionWorkflowInputs; 125 | } catch (error) { 126 | core.error("Failed to parse workflow_inputs JSON"); 127 | if (error instanceof Error) { 128 | core.debug(error.stack ?? ""); 129 | } 130 | throw error; 131 | } 132 | } 133 | 134 | function tryGetWorkflowAsNumber(workflowInput: string): string | number { 135 | try { 136 | // We can assume that the string is defined and not empty at this point. 137 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 138 | return getNumberFromValue(workflowInput)!; 139 | } catch { 140 | // Assume using a workflow name instead of an ID. 141 | return workflowInput; 142 | } 143 | } 144 | 145 | /** 146 | * We want empty strings to simply be undefined. 147 | * 148 | * While simple, make it very clear that the usage of `||` 149 | * is intentional here. 150 | */ 151 | function getOptionalWorkflowValue(workflowInput: string): string | undefined { 152 | return workflowInput || undefined; 153 | } 154 | -------------------------------------------------------------------------------- /src/api.spec.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as github from "@actions/github"; 3 | import { 4 | afterAll, 5 | afterEach, 6 | beforeEach, 7 | describe, 8 | expect, 9 | it, 10 | vi, 11 | } from "vitest"; 12 | 13 | import type { ActionConfig } from "./action.ts"; 14 | import { 15 | dispatchWorkflow, 16 | fetchWorkflowId, 17 | fetchWorkflowRunIds, 18 | fetchWorkflowRunJobSteps, 19 | fetchWorkflowRunUrl, 20 | init, 21 | retryOrTimeout, 22 | } from "./api.ts"; 23 | import { mockLoggingFunctions } from "./test-utils/logging.mock.ts"; 24 | import { getBranchName } from "./utils.ts"; 25 | 26 | vi.mock("@actions/core"); 27 | vi.mock("@actions/github"); 28 | 29 | interface MockResponse { 30 | data: any; 31 | status: number; 32 | } 33 | 34 | function* mockPageIterator( 35 | apiMethod: (params: P) => T, 36 | params: P, 37 | ): Generator { 38 | yield apiMethod(params); 39 | } 40 | 41 | const mockOctokit = { 42 | rest: { 43 | actions: { 44 | createWorkflowDispatch: (_req?: any): Promise => { 45 | throw new Error("Should be mocked"); 46 | }, 47 | getWorkflowRun: (_req?: any): Promise => { 48 | throw new Error("Should be mocked"); 49 | }, 50 | listRepoWorkflows: (_req?: any): Promise => { 51 | throw new Error("Should be mocked"); 52 | }, 53 | listWorkflowRuns: (_req?: any): Promise => { 54 | throw new Error("Should be mocked"); 55 | }, 56 | downloadWorkflowRunLogs: (_req?: any): Promise => { 57 | throw new Error("Should be mocked"); 58 | }, 59 | listJobsForWorkflowRun: (_req?: any): Promise => { 60 | throw new Error("Should be mocked"); 61 | }, 62 | }, 63 | }, 64 | paginate: { 65 | iterator: mockPageIterator, 66 | }, 67 | }; 68 | 69 | describe("API", () => { 70 | const { 71 | coreDebugLogMock, 72 | coreInfoLogMock, 73 | coreErrorLogMock, 74 | assertOnlyCalled, 75 | } = mockLoggingFunctions(); 76 | 77 | afterAll(() => { 78 | vi.restoreAllMocks(); 79 | }); 80 | 81 | beforeEach(() => { 82 | vi.spyOn(core, "getInput").mockImplementation((key: string) => { 83 | switch (key) { 84 | case "token": 85 | return "token"; 86 | case "ref": 87 | return "ref"; 88 | case "repo": 89 | return "repo"; 90 | case "owner": 91 | return "owner"; 92 | case "workflow": 93 | return "workflow"; 94 | case "workflow_inputs": 95 | return JSON.stringify({ testInput: "test" }); 96 | case "workflow_timeout_seconds": 97 | return "30"; 98 | case "workflow_job_steps_retry_seconds": 99 | return "5"; 100 | default: 101 | return ""; 102 | } 103 | }); 104 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 105 | vi.spyOn(github, "getOctokit").mockReturnValue(mockOctokit as any); 106 | init(); 107 | }); 108 | 109 | afterEach(() => { 110 | vi.resetAllMocks(); 111 | }); 112 | 113 | describe("dispatchWorkflow", () => { 114 | it("should resolve after a successful dispatch", async () => { 115 | vi.spyOn( 116 | mockOctokit.rest.actions, 117 | "createWorkflowDispatch", 118 | ).mockReturnValue( 119 | Promise.resolve({ 120 | data: undefined, 121 | status: 204, 122 | }), 123 | ); 124 | 125 | // Behaviour 126 | await expect(dispatchWorkflow("")).resolves.not.toThrow(); 127 | 128 | // Logging 129 | assertOnlyCalled(coreInfoLogMock); 130 | expect(coreInfoLogMock).toHaveBeenCalledOnce(); 131 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot(` 132 | "Successfully dispatched workflow: 133 | Repository: owner/repo 134 | Branch: ref 135 | Workflow: workflow 136 | Workflow Inputs: {"testInput":"test"} 137 | Distinct ID: " 138 | `); 139 | }); 140 | 141 | it("should throw if a non-204 status is returned", async () => { 142 | const errorStatus = 401; 143 | vi.spyOn( 144 | mockOctokit.rest.actions, 145 | "createWorkflowDispatch", 146 | ).mockReturnValue( 147 | Promise.resolve({ 148 | data: undefined, 149 | status: errorStatus, 150 | }), 151 | ); 152 | 153 | // Behaviour 154 | await expect(dispatchWorkflow("")).rejects.toThrow( 155 | `Failed to dispatch action, expected 204 but received ${errorStatus}`, 156 | ); 157 | 158 | // Logging 159 | assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); 160 | expect(coreErrorLogMock).toHaveBeenCalledOnce(); 161 | expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 162 | `"dispatchWorkflow: An unexpected error has occurred: Failed to dispatch action, expected 204 but received 401"`, 163 | ); 164 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 165 | }); 166 | 167 | it("should dispatch with a distinctId in the inputs", async () => { 168 | const distinctId = "50b4f5fa-f9ce-4661-80e6-6d660a4a3a0d"; 169 | let dispatchedId: string | undefined; 170 | vi.spyOn( 171 | mockOctokit.rest.actions, 172 | "createWorkflowDispatch", 173 | ).mockImplementation((req?: any) => { 174 | dispatchedId = req.inputs.distinct_id; 175 | 176 | return Promise.resolve({ 177 | data: undefined, 178 | status: 204, 179 | }); 180 | }); 181 | 182 | // Behaviour 183 | await expect(dispatchWorkflow(distinctId)).resolves.not.toThrow(); 184 | expect(dispatchedId).toStrictEqual(distinctId); 185 | 186 | // Logging 187 | assertOnlyCalled(coreInfoLogMock); 188 | expect(coreInfoLogMock).toHaveBeenCalledOnce(); 189 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 190 | ` 191 | "Successfully dispatched workflow: 192 | Repository: owner/repo 193 | Branch: ref 194 | Workflow: workflow 195 | Workflow Inputs: {"testInput":"test"} 196 | Distinct ID: 50b4f5fa-f9ce-4661-80e6-6d660a4a3a0d" 197 | `, 198 | ); 199 | }); 200 | }); 201 | 202 | describe("fetchWorkflowId", () => { 203 | it("should return the workflow ID for a given workflow filename", async () => { 204 | const mockData = [ 205 | { 206 | id: 0, 207 | path: ".github/workflows/cake.yml", 208 | }, 209 | { 210 | id: 1, 211 | path: ".github/workflows/pie.yml", 212 | }, 213 | { 214 | id: 2, 215 | path: ".github/workflows/slice.yml", 216 | }, 217 | ]; 218 | vi.spyOn(mockOctokit.rest.actions, "listRepoWorkflows").mockReturnValue( 219 | Promise.resolve({ 220 | data: mockData, 221 | status: 200, 222 | }), 223 | ); 224 | 225 | // Behaviour 226 | expect(await fetchWorkflowId("slice.yml")).toStrictEqual(mockData[2]!.id); 227 | 228 | // Logging 229 | assertOnlyCalled(coreInfoLogMock); 230 | expect(coreInfoLogMock).toHaveBeenCalledOnce(); 231 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 232 | ` 233 | "Fetched Workflow ID: 234 | Repository: owner/repo 235 | Workflow ID: '2' 236 | Input Filename: 'slice.yml' 237 | Sanitised Filename: 'slice\\.yml' 238 | URL: undefined" 239 | `, 240 | ); 241 | }); 242 | 243 | it("should throw if a non-200 status is returned", async () => { 244 | const errorStatus = 401; 245 | vi.spyOn(mockOctokit.rest.actions, "listRepoWorkflows").mockReturnValue( 246 | Promise.resolve({ 247 | data: undefined, 248 | status: errorStatus, 249 | }), 250 | ); 251 | 252 | // Behaviour 253 | await expect(fetchWorkflowId("implode")).rejects.toThrow( 254 | `Failed to fetch workflows, expected 200 but received ${errorStatus}`, 255 | ); 256 | 257 | // Logging 258 | assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); 259 | expect(coreErrorLogMock).toHaveBeenCalledOnce(); 260 | expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 261 | `"fetchWorkflowId: An unexpected error has occurred: Failed to fetch workflows, expected 200 but received 401"`, 262 | ); 263 | }); 264 | 265 | it("should throw if a given workflow name cannot be found in the response", async () => { 266 | const workflowName = "slice"; 267 | vi.spyOn(mockOctokit.rest.actions, "listRepoWorkflows").mockReturnValue( 268 | Promise.resolve({ 269 | data: [], 270 | status: 200, 271 | }), 272 | ); 273 | 274 | // Behaviour 275 | await expect(fetchWorkflowId(workflowName)).rejects.toThrow( 276 | `Unable to find ID for Workflow: ${workflowName}`, 277 | ); 278 | 279 | // Logging 280 | assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); 281 | expect(coreErrorLogMock).toHaveBeenCalledOnce(); 282 | expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 283 | `"fetchWorkflowId: An unexpected error has occurred: Unable to find ID for Workflow: slice"`, 284 | ); 285 | }); 286 | 287 | it("should return the workflow ID when the name is a substring of another workflow name", async () => { 288 | const mockData = [ 289 | { 290 | id: 0, 291 | path: ".github/workflows/small-cake.yml", 292 | }, 293 | { 294 | id: 1, 295 | path: ".github/workflows/big-cake.yml", 296 | }, 297 | { 298 | id: 2, 299 | path: ".github/workflows/cake.yml", 300 | }, 301 | ]; 302 | vi.spyOn(mockOctokit.rest.actions, "listRepoWorkflows").mockReturnValue( 303 | Promise.resolve({ 304 | data: mockData, 305 | status: 200, 306 | }), 307 | ); 308 | 309 | // Behaviour 310 | expect(await fetchWorkflowId("cake.yml")).toStrictEqual(mockData[2]!.id); 311 | 312 | // Logging 313 | assertOnlyCalled(coreInfoLogMock); 314 | expect(coreInfoLogMock).toHaveBeenCalledOnce(); 315 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 316 | ` 317 | "Fetched Workflow ID: 318 | Repository: owner/repo 319 | Workflow ID: '2' 320 | Input Filename: 'cake.yml' 321 | Sanitised Filename: 'cake\\.yml' 322 | URL: undefined" 323 | `, 324 | ); 325 | }); 326 | }); 327 | 328 | describe("fetchWorkflowRunIds", () => { 329 | const workflowIdCfg: ActionConfig = { 330 | token: "secret", 331 | ref: "/refs/heads/feature_branch", 332 | repo: "repository", 333 | owner: "owner", 334 | workflow: "workflow_name", 335 | workflowInputs: { testInput: "test" }, 336 | workflowTimeoutSeconds: 60, 337 | workflowJobStepsRetrySeconds: 3, 338 | distinctId: "test-uuid", 339 | }; 340 | 341 | beforeEach(() => { 342 | init(workflowIdCfg); 343 | }); 344 | 345 | it("should get the run IDs for a given workflow ID", async () => { 346 | const branch = getBranchName(workflowIdCfg.ref); 347 | coreDebugLogMock.mockReset(); 348 | 349 | const mockData = { 350 | total_count: 3, 351 | workflow_runs: [{ id: 0 }, { id: 1 }, { id: 2 }], 352 | }; 353 | vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockReturnValue( 354 | Promise.resolve({ 355 | data: mockData, 356 | status: 200, 357 | }), 358 | ); 359 | 360 | // Behaviour 361 | await expect(fetchWorkflowRunIds(0, branch)).resolves.toStrictEqual( 362 | mockData.workflow_runs.map((run) => run.id), 363 | ); 364 | 365 | // Logging 366 | assertOnlyCalled(coreDebugLogMock); 367 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 368 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 369 | ` 370 | "Fetched Workflow Runs: 371 | Repository: owner/repository 372 | Branch Filter: true (feature_branch) 373 | Workflow ID: 0 374 | Runs Fetched: [0, 1, 2]" 375 | `, 376 | ); 377 | }); 378 | 379 | it("should throw if a non-200 status is returned", async () => { 380 | const branch = getBranchName(workflowIdCfg.ref); 381 | coreDebugLogMock.mockReset(); 382 | 383 | const errorStatus = 401; 384 | vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockReturnValue( 385 | Promise.resolve({ 386 | data: undefined, 387 | status: errorStatus, 388 | }), 389 | ); 390 | 391 | // Behaviour 392 | await expect(fetchWorkflowRunIds(0, branch)).rejects.toThrow( 393 | `Failed to fetch Workflow runs, expected 200 but received ${errorStatus}`, 394 | ); 395 | 396 | // Logging 397 | assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); 398 | expect(coreErrorLogMock).toHaveBeenCalled(); 399 | expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 400 | `"fetchWorkflowRunIds: An unexpected error has occurred: Failed to fetch Workflow runs, expected 200 but received 401"`, 401 | ); 402 | }); 403 | 404 | it("should return an empty array if there are no runs", async () => { 405 | const branch = getBranchName(workflowIdCfg.ref); 406 | coreDebugLogMock.mockReset(); 407 | 408 | const mockData = { 409 | total_count: 0, 410 | workflow_runs: [], 411 | }; 412 | vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockReturnValue( 413 | Promise.resolve({ 414 | data: mockData, 415 | status: 200, 416 | }), 417 | ); 418 | 419 | // Behaviour 420 | await expect(fetchWorkflowRunIds(0, branch)).resolves.toStrictEqual([]); 421 | 422 | // Logging 423 | assertOnlyCalled(coreDebugLogMock); 424 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 425 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 426 | ` 427 | "Fetched Workflow Runs: 428 | Repository: owner/repository 429 | Branch Filter: true (feature_branch) 430 | Workflow ID: 0 431 | Runs Fetched: []" 432 | `, 433 | ); 434 | }); 435 | 436 | it("should filter by branch name", async () => { 437 | const branch = getBranchName("/refs/heads/master"); 438 | coreDebugLogMock.mockReset(); 439 | 440 | let parsedRef!: string; 441 | vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockImplementation( 442 | (req: any) => { 443 | parsedRef = req.branch; 444 | const mockResponse: MockResponse = { 445 | data: { 446 | total_count: 0, 447 | workflow_runs: [], 448 | }, 449 | status: 200, 450 | }; 451 | return Promise.resolve(mockResponse); 452 | }, 453 | ); 454 | 455 | // Behaviour 456 | await expect(fetchWorkflowRunIds(0, branch)).resolves.not.toThrow(); 457 | expect(parsedRef).toStrictEqual("master"); 458 | 459 | // Logging 460 | assertOnlyCalled(coreDebugLogMock); 461 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 462 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 463 | ` 464 | "Fetched Workflow Runs: 465 | Repository: owner/repository 466 | Branch Filter: true (master) 467 | Workflow ID: 0 468 | Runs Fetched: []" 469 | `, 470 | ); 471 | }); 472 | 473 | it("should not use a branch filter if using a tag ref", async () => { 474 | const branch = getBranchName("/refs/tags/1.5.0"); 475 | coreDebugLogMock.mockReset(); 476 | 477 | let parsedRef!: string; 478 | vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockImplementation( 479 | (req: any) => { 480 | parsedRef = req.branch; 481 | const mockResponse: MockResponse = { 482 | data: { 483 | total_count: 0, 484 | workflow_runs: [], 485 | }, 486 | status: 200, 487 | }; 488 | return Promise.resolve(mockResponse); 489 | }, 490 | ); 491 | 492 | // Behaviour 493 | await expect(fetchWorkflowRunIds(0, branch)).resolves.not.toThrow(); 494 | expect(parsedRef).toBeUndefined(); 495 | 496 | // Logging 497 | assertOnlyCalled(coreDebugLogMock); 498 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 499 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 500 | ` 501 | "Fetched Workflow Runs: 502 | Repository: owner/repository 503 | Branch Filter: false (/refs/tags/1.5.0) 504 | Workflow ID: 0 505 | Runs Fetched: []" 506 | `, 507 | ); 508 | }); 509 | 510 | it("should not use a branch filter if non-standard ref", async () => { 511 | const branch = getBranchName("/refs/cake"); 512 | coreDebugLogMock.mockReset(); 513 | 514 | let parsedRef!: string; 515 | vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockImplementation( 516 | (req: any) => { 517 | parsedRef = req.branch; 518 | const mockResponse: MockResponse = { 519 | data: { 520 | total_count: 0, 521 | workflow_runs: [], 522 | }, 523 | status: 200, 524 | }; 525 | return Promise.resolve(mockResponse); 526 | }, 527 | ); 528 | 529 | // Behaviour 530 | await expect(fetchWorkflowRunIds(0, branch)).resolves.not.toThrow(); 531 | expect(parsedRef).toBeUndefined(); 532 | 533 | // Logging 534 | assertOnlyCalled(coreDebugLogMock); 535 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 536 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 537 | ` 538 | "Fetched Workflow Runs: 539 | Repository: owner/repository 540 | Branch Filter: false (/refs/cake) 541 | Workflow ID: 0 542 | Runs Fetched: []" 543 | `, 544 | ); 545 | }); 546 | }); 547 | 548 | describe("fetchWorkflowRunJobSteps", () => { 549 | it("should get the step names for a given Workflow Run ID", async () => { 550 | const mockData = { 551 | total_count: 1, 552 | jobs: [ 553 | { 554 | id: 0, 555 | steps: [ 556 | { 557 | name: "Test Step 1", 558 | number: 1, 559 | }, 560 | { 561 | name: "Test Step 2", 562 | number: 2, 563 | }, 564 | ], 565 | }, 566 | ], 567 | }; 568 | vi.spyOn( 569 | mockOctokit.rest.actions, 570 | "listJobsForWorkflowRun", 571 | ).mockReturnValue( 572 | Promise.resolve({ 573 | data: mockData, 574 | status: 200, 575 | }), 576 | ); 577 | 578 | // Behaviour 579 | await expect(fetchWorkflowRunJobSteps(0)).resolves.toStrictEqual([ 580 | "Test Step 1", 581 | "Test Step 2", 582 | ]); 583 | 584 | // Logging 585 | assertOnlyCalled(coreDebugLogMock); 586 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 587 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 588 | ` 589 | "Fetched Workflow Run Job Steps: 590 | Repository: owner/repo 591 | Workflow Run ID: 0 592 | Jobs Fetched: [0] 593 | Steps Fetched: ["Test Step 1", "Test Step 2"]" 594 | `, 595 | ); 596 | }); 597 | 598 | it("should throw if a non-200 status is returned", async () => { 599 | const errorStatus = 401; 600 | vi.spyOn( 601 | mockOctokit.rest.actions, 602 | "listJobsForWorkflowRun", 603 | ).mockReturnValue( 604 | Promise.resolve({ 605 | data: undefined, 606 | status: errorStatus, 607 | }), 608 | ); 609 | 610 | // Behaviour 611 | await expect(fetchWorkflowRunJobSteps(0)).rejects.toThrow( 612 | `Failed to fetch Workflow Run Jobs, expected 200 but received ${errorStatus}`, 613 | ); 614 | 615 | // Logging 616 | assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); 617 | expect(coreErrorLogMock).toHaveBeenCalledOnce(); 618 | expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 619 | `"fetchWorkflowRunJobSteps: An unexpected error has occurred: Failed to fetch Workflow Run Jobs, expected 200 but received 401"`, 620 | ); 621 | }); 622 | 623 | it("should return an empty array if there are no steps", async () => { 624 | const mockData = { 625 | total_count: 1, 626 | jobs: [ 627 | { 628 | id: 0, 629 | steps: undefined, 630 | }, 631 | ], 632 | }; 633 | vi.spyOn( 634 | mockOctokit.rest.actions, 635 | "listJobsForWorkflowRun", 636 | ).mockReturnValue( 637 | Promise.resolve({ 638 | data: mockData, 639 | status: 200, 640 | }), 641 | ); 642 | 643 | // Behaviour 644 | await expect(fetchWorkflowRunJobSteps(0)).resolves.toStrictEqual([]); 645 | 646 | // Logging 647 | assertOnlyCalled(coreDebugLogMock); 648 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 649 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 650 | ` 651 | "Fetched Workflow Run Job Steps: 652 | Repository: owner/repo 653 | Workflow Run ID: 0 654 | Jobs Fetched: [0] 655 | Steps Fetched: []" 656 | `, 657 | ); 658 | }); 659 | }); 660 | 661 | describe("fetchWorkflowRunUrl", () => { 662 | it("should return the workflow run state for a given run ID", async () => { 663 | const mockData = { 664 | html_url: "master sword", 665 | }; 666 | vi.spyOn(mockOctokit.rest.actions, "getWorkflowRun").mockReturnValue( 667 | Promise.resolve({ 668 | data: mockData, 669 | status: 200, 670 | }), 671 | ); 672 | 673 | const url = await fetchWorkflowRunUrl(123456); 674 | expect(url).toStrictEqual(mockData.html_url); 675 | }); 676 | 677 | it("should throw if a non-200 status is returned", async () => { 678 | const errorStatus = 401; 679 | vi.spyOn(mockOctokit.rest.actions, "getWorkflowRun").mockReturnValue( 680 | Promise.resolve({ 681 | data: undefined, 682 | status: errorStatus, 683 | }), 684 | ); 685 | 686 | // Behaviour 687 | await expect(fetchWorkflowRunUrl(0)).rejects.toThrow( 688 | `Failed to fetch Workflow Run state, expected 200 but received ${errorStatus}`, 689 | ); 690 | 691 | // Logging 692 | assertOnlyCalled(coreErrorLogMock, coreDebugLogMock); 693 | expect(coreErrorLogMock).toHaveBeenCalledOnce(); 694 | expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 695 | `"fetchWorkflowRunUrl: An unexpected error has occurred: Failed to fetch Workflow Run state, expected 200 but received 401"`, 696 | ); 697 | }); 698 | }); 699 | 700 | describe("retryOrTimeout", () => { 701 | beforeEach(() => { 702 | vi.useFakeTimers(); 703 | }); 704 | 705 | afterEach(() => { 706 | vi.useRealTimers(); 707 | }); 708 | 709 | it("should return a result", async () => { 710 | const attemptResult = [0]; 711 | const attempt = () => Promise.resolve(attemptResult); 712 | 713 | const result = await retryOrTimeout(attempt, 1000); 714 | if (!result.success) { 715 | expect.fail("expected retryOrTimeout not to timeout"); 716 | } 717 | 718 | expect(result.success).toStrictEqual(true); 719 | expect(result.value).toStrictEqual(attemptResult); 720 | }); 721 | 722 | it("should return a timeout result if the given timeout is exceeded", async () => { 723 | // Never return data. 724 | const attempt = () => Promise.resolve([]); 725 | 726 | const retryOrTimeoutPromise = retryOrTimeout(attempt, 1000); 727 | await vi.advanceTimersByTimeAsync(2000); 728 | 729 | const result = await retryOrTimeoutPromise; 730 | if (result.success) { 731 | expect.fail("expected retryOrTimeout to timeout"); 732 | } 733 | 734 | expect(result.success).toStrictEqual(false); 735 | }); 736 | 737 | it("should retry to get a populated array", async () => { 738 | const attemptResult = [0]; 739 | const attempt = vi 740 | .fn() 741 | .mockResolvedValue(attemptResult) 742 | .mockResolvedValueOnce([]) 743 | .mockResolvedValueOnce([]); 744 | 745 | const retryOrDiePromise = retryOrTimeout(attempt, 5000); 746 | await vi.advanceTimersByTimeAsync(3000); 747 | 748 | const result = await retryOrDiePromise; 749 | if (!result.success) { 750 | expect.fail("expected retryOrTimeout not to timeout"); 751 | } 752 | 753 | expect(result.success).toStrictEqual(true); 754 | expect(result.value).toStrictEqual(attemptResult); 755 | expect(attempt).toHaveBeenCalledTimes(3); 756 | }); 757 | 758 | it("should iterate only once if timed out", async () => { 759 | const attempt = vi.fn(() => Promise.resolve([])); 760 | 761 | const retryOrTimeoutPromise = retryOrTimeout(attempt, 1000); 762 | 763 | expect(attempt).toHaveBeenCalledOnce(); 764 | 765 | await vi.advanceTimersByTimeAsync(2000); 766 | 767 | const result = await retryOrTimeoutPromise; 768 | 769 | if (result.success) { 770 | expect.fail("expected retryOrTimeout to timeout"); 771 | } 772 | expect(attempt).toHaveBeenCalledOnce(); 773 | 774 | expect(result.success).toStrictEqual(false); 775 | expect(result.reason).toStrictEqual("timeout"); 776 | }); 777 | }); 778 | }); 779 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as github from "@actions/github"; 3 | 4 | import { type ActionConfig, getConfig } from "./action.ts"; 5 | import type { Result } from "./types.ts"; 6 | import { sleep, type BranchNameResult } from "./utils.ts"; 7 | 8 | type Octokit = ReturnType; 9 | 10 | let config: ActionConfig; 11 | let octokit: Octokit; 12 | 13 | export function init(cfg?: ActionConfig): void { 14 | config = cfg ?? getConfig(); 15 | octokit = github.getOctokit(config.token); 16 | } 17 | 18 | export async function dispatchWorkflow(distinctId: string): Promise { 19 | try { 20 | // https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event 21 | const response = await octokit.rest.actions.createWorkflowDispatch({ 22 | owner: config.owner, 23 | repo: config.repo, 24 | workflow_id: config.workflow, 25 | ref: config.ref, 26 | inputs: { 27 | ...(config.workflowInputs ?? undefined), 28 | distinct_id: distinctId, 29 | }, 30 | }); 31 | 32 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 33 | if (response.status !== 204) { 34 | throw new Error( 35 | `Failed to dispatch action, expected 204 but received ${response.status}`, 36 | ); 37 | } 38 | 39 | core.info( 40 | "Successfully dispatched workflow:\n" + 41 | ` Repository: ${config.owner}/${config.repo}\n` + 42 | ` Branch: ${config.ref}\n` + 43 | ` Workflow: ${config.workflow}\n` + 44 | (config.workflowInputs 45 | ? ` Workflow Inputs: ${JSON.stringify(config.workflowInputs)}\n` 46 | : ``) + 47 | ` Distinct ID: ${distinctId}`, 48 | ); 49 | } catch (error) { 50 | if (error instanceof Error) { 51 | core.error( 52 | `dispatchWorkflow: An unexpected error has occurred: ${error.message}`, 53 | ); 54 | core.debug(error.stack ?? ""); 55 | } 56 | throw error; 57 | } 58 | } 59 | 60 | export async function fetchWorkflowId( 61 | workflowFilename: string, 62 | ): Promise { 63 | try { 64 | const sanitisedFilename = workflowFilename 65 | .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") 66 | .trim(); 67 | const filenameRegex = new RegExp(`/${sanitisedFilename}`); 68 | 69 | // https://docs.github.com/en/rest/actions/workflows#list-repository-workflows 70 | const workflowIterator = octokit.paginate.iterator( 71 | octokit.rest.actions.listRepoWorkflows, 72 | { 73 | owner: config.owner, 74 | repo: config.repo, 75 | }, 76 | ); 77 | let workflowId: number | undefined; 78 | let workflowIdUrl: string | undefined; 79 | for await (const response of workflowIterator) { 80 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 81 | if (response.status !== 200) { 82 | throw new Error( 83 | `Failed to fetch workflows, expected 200 but received ${response.status}`, 84 | ); 85 | } 86 | 87 | const workflowData = response.data.find((workflow) => 88 | filenameRegex.test(workflow.path), 89 | ); 90 | workflowId = workflowData?.id; 91 | 92 | if (workflowId !== undefined) { 93 | workflowIdUrl = workflowData?.html_url; 94 | break; 95 | } 96 | } 97 | 98 | if (workflowId === undefined) { 99 | throw new Error(`Unable to find ID for Workflow: ${workflowFilename}`); 100 | } 101 | 102 | core.info( 103 | `Fetched Workflow ID:\n` + 104 | ` Repository: ${config.owner}/${config.repo}\n` + 105 | ` Workflow ID: '${workflowId}'\n` + 106 | ` Input Filename: '${workflowFilename}'\n` + 107 | ` Sanitised Filename: '${sanitisedFilename}'\n` + 108 | ` URL: ${workflowIdUrl}`, 109 | ); 110 | 111 | return workflowId; 112 | } catch (error) { 113 | if (error instanceof Error) { 114 | core.error( 115 | `fetchWorkflowId: An unexpected error has occurred: ${error.message}`, 116 | ); 117 | core.debug(error.stack ?? ""); 118 | } 119 | throw error; 120 | } 121 | } 122 | 123 | export async function fetchWorkflowRunUrl(runId: number): Promise { 124 | try { 125 | // https://docs.github.com/en/rest/reference/actions#get-a-workflow-run 126 | const response = await octokit.rest.actions.getWorkflowRun({ 127 | owner: config.owner, 128 | repo: config.repo, 129 | run_id: runId, 130 | }); 131 | 132 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 133 | if (response.status !== 200) { 134 | throw new Error( 135 | `Failed to fetch Workflow Run state, expected 200 but received ${response.status}`, 136 | ); 137 | } 138 | 139 | core.debug( 140 | `Fetched Run:\n` + 141 | ` Repository: ${config.owner}/${config.repo}\n` + 142 | ` Run ID: ${runId}\n` + 143 | ` URL: ${response.data.html_url}`, 144 | ); 145 | 146 | return response.data.html_url; 147 | } catch (error) { 148 | if (error instanceof Error) { 149 | core.error( 150 | `fetchWorkflowRunUrl: An unexpected error has occurred: ${error.message}`, 151 | ); 152 | core.debug(error.stack ?? ""); 153 | } 154 | throw error; 155 | } 156 | } 157 | 158 | export async function fetchWorkflowRunIds( 159 | workflowId: number, 160 | branch: BranchNameResult, 161 | ): Promise { 162 | try { 163 | const useBranchFilter = 164 | !branch.isTag && 165 | branch.branchName !== undefined && 166 | branch.branchName !== ""; 167 | 168 | // https://docs.github.com/en/rest/actions/workflow-runs#list-workflow-runs-for-a-repository 169 | const response = await octokit.rest.actions.listWorkflowRuns({ 170 | owner: config.owner, 171 | repo: config.repo, 172 | workflow_id: workflowId, 173 | ...(useBranchFilter 174 | ? { 175 | branch: branch.branchName, 176 | per_page: 10, 177 | } 178 | : { 179 | per_page: 20, 180 | }), 181 | }); 182 | 183 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 184 | if (response.status !== 200) { 185 | throw new Error( 186 | `Failed to fetch Workflow runs, expected 200 but received ${response.status}`, 187 | ); 188 | } 189 | 190 | const runIds = response.data.workflow_runs.map( 191 | (workflowRun) => workflowRun.id, 192 | ); 193 | 194 | const branchMsg = useBranchFilter 195 | ? `true (${branch.branchName})` 196 | : `false (${branch.ref})`; 197 | core.debug( 198 | "Fetched Workflow Runs:\n" + 199 | ` Repository: ${config.owner}/${config.repo}\n` + 200 | ` Branch Filter: ${branchMsg}\n` + 201 | ` Workflow ID: ${workflowId}\n` + 202 | ` Runs Fetched: [${runIds.join(", ")}]`, 203 | ); 204 | 205 | return runIds; 206 | } catch (error) { 207 | if (error instanceof Error) { 208 | core.error( 209 | `fetchWorkflowRunIds: An unexpected error has occurred: ${error.message}`, 210 | ); 211 | core.debug(error.stack ?? ""); 212 | } 213 | throw error; 214 | } 215 | } 216 | 217 | export async function fetchWorkflowRunJobSteps( 218 | runId: number, 219 | ): Promise { 220 | try { 221 | // https://docs.github.com/en/rest/actions/workflow-jobs#list-jobs-for-a-workflow-run 222 | const response = await octokit.rest.actions.listJobsForWorkflowRun({ 223 | owner: config.owner, 224 | repo: config.repo, 225 | run_id: runId, 226 | filter: "latest", 227 | }); 228 | 229 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 230 | if (response.status !== 200) { 231 | throw new Error( 232 | `Failed to fetch Workflow Run Jobs, expected 200 but received ${response.status}`, 233 | ); 234 | } 235 | 236 | const jobs = response.data.jobs.map((job) => ({ 237 | id: job.id, 238 | steps: job.steps?.map((step) => step.name) ?? [], 239 | })); 240 | const steps = Array.from(new Set(jobs.flatMap((job) => job.steps))); 241 | 242 | core.debug( 243 | "Fetched Workflow Run Job Steps:\n" + 244 | ` Repository: ${config.owner}/${config.repo}\n` + 245 | ` Workflow Run ID: ${runId}\n` + 246 | ` Jobs Fetched: [${jobs.map((job) => job.id).join(", ")}]\n` + 247 | ` Steps Fetched: [${steps.map((step) => `"${step}"`).join(", ")}]`, 248 | ); 249 | 250 | return steps; 251 | } catch (error) { 252 | if (error instanceof Error) { 253 | core.error( 254 | `fetchWorkflowRunJobSteps: An unexpected error has occurred: ${error.message}`, 255 | ); 256 | core.debug(error.stack ?? ""); 257 | } 258 | throw error; 259 | } 260 | } 261 | 262 | /** 263 | * Attempt to get a non-empty array from the API. 264 | */ 265 | export async function retryOrTimeout( 266 | retryFunc: () => Promise, 267 | timeoutMs: number, 268 | ): Promise> { 269 | const startTime = Date.now(); 270 | let elapsedTime = 0; 271 | while (elapsedTime < timeoutMs) { 272 | const response = await retryFunc(); 273 | if (response.length > 0) { 274 | return { success: true, value: response }; 275 | } 276 | 277 | await sleep(1000); 278 | elapsedTime = Date.now() - startTime; 279 | } 280 | 281 | return { success: false, reason: "timeout" }; 282 | } 283 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-inferrable-types */ 2 | 3 | export const WORKFLOW_FETCH_TIMEOUT_MS: number = 60 * 1000; 4 | export const WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MAX: number = 3; 5 | export const WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MS: number = 500; 6 | -------------------------------------------------------------------------------- /src/main.spec.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import { 3 | afterAll, 4 | afterEach, 5 | beforeEach, 6 | describe, 7 | expect, 8 | it, 9 | vi, 10 | type MockInstance, 11 | } from "vitest"; 12 | 13 | import * as action from "./action.ts"; 14 | import * as api from "./api.ts"; 15 | import { main } from "./main.ts"; 16 | import * as returnDispatch from "./return-dispatch.ts"; 17 | import { mockLoggingFunctions } from "./test-utils/logging.mock.ts"; 18 | import * as utils from "./utils.ts"; 19 | 20 | vi.mock("@actions/core"); 21 | vi.mock("./action.ts"); 22 | vi.mock("./api.ts"); 23 | vi.mock("./return-dispatch.ts"); 24 | vi.mock("./utils.ts"); 25 | 26 | describe("main", () => { 27 | const { 28 | coreDebugLogMock, 29 | coreErrorLogMock, 30 | coreInfoLogMock, 31 | assertOnlyCalled, 32 | } = mockLoggingFunctions(); 33 | const testCfg: action.ActionConfig = { 34 | distinctId: "test-id", 35 | ref: "test-ref", 36 | workflow: "test-workflow", 37 | workflowTimeoutSeconds: 0, 38 | workflowJobStepsRetrySeconds: 0, 39 | } satisfies Partial as action.ActionConfig; 40 | const testBranch: utils.BranchNameResult = { 41 | branchName: "test-branch", 42 | isTag: false, 43 | ref: testCfg.ref, 44 | }; 45 | 46 | // Core 47 | let coreSetFailedMock: MockInstance; 48 | 49 | // Action 50 | let actionGetConfigMock: MockInstance; 51 | 52 | // API 53 | let apiDispatchWorkflowMock: MockInstance; 54 | let apiInitMock: MockInstance; 55 | 56 | // Utils 57 | let utilsGetBranchNameMock: MockInstance; 58 | let utilsLogInfoForBranchNameResult: MockInstance< 59 | typeof utils.logInfoForBranchNameResult 60 | >; 61 | let utilsCreateDistinctIdRegexMock: MockInstance< 62 | typeof utils.createDistinctIdRegex 63 | >; 64 | 65 | // Return Dispatch 66 | let returnDispatchGetRunIdAndUrlMock: MockInstance< 67 | typeof returnDispatch.getRunIdAndUrl 68 | >; 69 | let returnDispatchGetWorkflowIdMock: MockInstance< 70 | typeof returnDispatch.getWorkflowId 71 | >; 72 | let returnDispatchHandleFailMock: MockInstance< 73 | typeof returnDispatch.handleActionFail 74 | >; 75 | let returnDispatchHandleSuccessMock: MockInstance< 76 | typeof returnDispatch.handleActionSuccess 77 | >; 78 | 79 | afterAll(() => { 80 | vi.restoreAllMocks(); 81 | }); 82 | 83 | beforeEach(() => { 84 | vi.useFakeTimers(); 85 | 86 | coreSetFailedMock = vi.spyOn(core, "setFailed"); 87 | 88 | actionGetConfigMock = vi 89 | .spyOn(action, "getConfig") 90 | .mockReturnValue(testCfg); 91 | 92 | apiDispatchWorkflowMock = vi.spyOn(api, "dispatchWorkflow"); 93 | apiInitMock = vi.spyOn(api, "init"); 94 | 95 | utilsGetBranchNameMock = vi.spyOn(utils, "getBranchName"); 96 | utilsLogInfoForBranchNameResult = vi.spyOn( 97 | utils, 98 | "logInfoForBranchNameResult", 99 | ); 100 | utilsCreateDistinctIdRegexMock = vi.spyOn(utils, "createDistinctIdRegex"); 101 | 102 | returnDispatchGetRunIdAndUrlMock = vi.spyOn( 103 | returnDispatch, 104 | "getRunIdAndUrl", 105 | ); 106 | returnDispatchGetWorkflowIdMock = vi 107 | .spyOn(returnDispatch, "getWorkflowId") 108 | .mockResolvedValue(0); 109 | returnDispatchHandleFailMock = vi.spyOn(returnDispatch, "handleActionFail"); 110 | returnDispatchHandleSuccessMock = vi.spyOn( 111 | returnDispatch, 112 | "handleActionSuccess", 113 | ); 114 | }); 115 | 116 | afterEach(() => { 117 | vi.useRealTimers(); 118 | vi.resetAllMocks(); 119 | }); 120 | 121 | it("should successfully complete", async () => { 122 | const distinctIdRegex = new RegExp(testCfg.distinctId); 123 | const returnDispatchSuccessResult = { 124 | success: true, 125 | value: { 126 | id: 0, 127 | url: "test-url", 128 | }, 129 | } as const; 130 | 131 | utilsGetBranchNameMock.mockReturnValue(testBranch); 132 | utilsCreateDistinctIdRegexMock.mockReturnValue(distinctIdRegex); 133 | returnDispatchGetWorkflowIdMock.mockResolvedValue(0); 134 | returnDispatchGetRunIdAndUrlMock.mockResolvedValue( 135 | returnDispatchSuccessResult, 136 | ); 137 | 138 | await main(); 139 | 140 | // Behaviour 141 | // Setup 142 | expect(actionGetConfigMock).toHaveBeenCalledOnce(); 143 | expect(apiInitMock).toHaveBeenCalledOnce(); 144 | expect(apiInitMock).toHaveBeenCalledWith(testCfg); 145 | 146 | // Workflow ID 147 | expect(returnDispatchGetWorkflowIdMock).toHaveBeenCalledOnce(); 148 | expect(returnDispatchGetWorkflowIdMock).toHaveBeenCalledWith( 149 | testCfg.workflow, 150 | ); 151 | 152 | // Dispatch 153 | expect(apiDispatchWorkflowMock).toHaveBeenCalledOnce(); 154 | expect(apiDispatchWorkflowMock).toHaveBeenCalledWith(testCfg.distinctId); 155 | 156 | // Branch name 157 | expect(utilsGetBranchNameMock).toHaveBeenCalledOnce(); 158 | expect(utilsGetBranchNameMock).toHaveBeenCalledWith(testCfg.ref); 159 | expect(utilsLogInfoForBranchNameResult).toHaveBeenCalledOnce(); 160 | expect(utilsLogInfoForBranchNameResult).toHaveBeenCalledWith( 161 | testBranch, 162 | testCfg.ref, 163 | ); 164 | expect(utilsCreateDistinctIdRegexMock).toHaveBeenCalledOnce(); 165 | expect(utilsCreateDistinctIdRegexMock).toHaveBeenCalledWith( 166 | testCfg.distinctId, 167 | ); 168 | 169 | // Get run ID 170 | expect(returnDispatchGetRunIdAndUrlMock).toHaveBeenCalledOnce(); 171 | expect(returnDispatchGetRunIdAndUrlMock).toHaveBeenCalledWith({ 172 | startTime: Date.now(), 173 | branch: testBranch, 174 | distinctIdRegex: distinctIdRegex, 175 | workflowId: 0, 176 | workflowTimeoutMs: testCfg.workflowTimeoutSeconds * 1000, 177 | workflowJobStepsRetryMs: testCfg.workflowJobStepsRetrySeconds * 1000, 178 | }); 179 | 180 | // Result 181 | expect(coreSetFailedMock).not.toHaveBeenCalled(); 182 | expect(returnDispatchHandleFailMock).not.toHaveBeenCalled(); 183 | expect(returnDispatchHandleSuccessMock).toHaveBeenCalledOnce(); 184 | expect(returnDispatchHandleSuccessMock).toHaveBeenCalledWith( 185 | returnDispatchSuccessResult.value.id, 186 | returnDispatchSuccessResult.value.url, 187 | ); 188 | 189 | // Logging 190 | assertOnlyCalled(coreInfoLogMock, coreDebugLogMock); 191 | expect(coreInfoLogMock).toHaveBeenCalledTimes(2); 192 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 193 | `"Attempt to extract branch name from ref..."`, 194 | ); 195 | expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatchInlineSnapshot( 196 | `"Attempting to identify run ID from steps..."`, 197 | ); 198 | expect(coreDebugLogMock).toHaveBeenCalledTimes(2); 199 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 200 | `"Attempting to identify run ID for test-workflow (0)"`, 201 | ); 202 | expect(coreDebugLogMock.mock.calls[1]?.[0]).toMatchInlineSnapshot( 203 | `"Completed (0ms)"`, 204 | ); 205 | }); 206 | 207 | it("should fail for an unhandled error", async () => { 208 | const testError = new Error("test error"); 209 | actionGetConfigMock.mockImplementation(() => { 210 | throw testError; 211 | }); 212 | 213 | await main(); 214 | 215 | // Behaviour 216 | expect(actionGetConfigMock).toHaveBeenCalledOnce(); 217 | 218 | expect(apiInitMock).not.toHaveBeenCalled(); 219 | expect(returnDispatchGetWorkflowIdMock).not.toHaveBeenCalled(); 220 | expect(apiDispatchWorkflowMock).not.toHaveBeenCalled(); 221 | expect(utilsGetBranchNameMock).not.toHaveBeenCalled(); 222 | expect(utilsLogInfoForBranchNameResult).not.toHaveBeenCalled(); 223 | expect(returnDispatchGetRunIdAndUrlMock).not.toHaveBeenCalled(); 224 | expect(returnDispatchHandleFailMock).not.toHaveBeenCalled(); 225 | expect(returnDispatchHandleSuccessMock).not.toHaveBeenCalled(); 226 | 227 | expect(coreSetFailedMock).toHaveBeenCalledOnce(); 228 | expect(coreSetFailedMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 229 | `"Failed: An unhandled error has occurred: test error"`, 230 | ); 231 | 232 | // Logging 233 | assertOnlyCalled(coreDebugLogMock, coreErrorLogMock); 234 | expect(coreErrorLogMock).toHaveBeenCalledOnce(); 235 | expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 236 | `"Failed: An unhandled error has occurred: test error"`, 237 | ); 238 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 239 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toStrictEqual(testError.stack); 240 | }); 241 | 242 | it("should fail for an unhandled unknown", async () => { 243 | const testError = "some other error"; 244 | actionGetConfigMock.mockImplementation(() => { 245 | // eslint-disable-next-line @typescript-eslint/only-throw-error 246 | throw testError; 247 | }); 248 | 249 | await main(); 250 | 251 | // Behaviour 252 | expect(actionGetConfigMock).toHaveBeenCalledOnce(); 253 | 254 | expect(apiInitMock).not.toHaveBeenCalled(); 255 | expect(returnDispatchGetWorkflowIdMock).not.toHaveBeenCalled(); 256 | expect(apiDispatchWorkflowMock).not.toHaveBeenCalled(); 257 | expect(utilsGetBranchNameMock).not.toHaveBeenCalled(); 258 | expect(utilsLogInfoForBranchNameResult).not.toHaveBeenCalled(); 259 | expect(returnDispatchGetRunIdAndUrlMock).not.toHaveBeenCalled(); 260 | expect(returnDispatchHandleFailMock).not.toHaveBeenCalled(); 261 | expect(returnDispatchHandleSuccessMock).not.toHaveBeenCalled(); 262 | 263 | expect(coreSetFailedMock).toHaveBeenCalledOnce(); 264 | expect(coreSetFailedMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 265 | `"Failed: An unknown error has occurred: some other error"`, 266 | ); 267 | 268 | // Logging 269 | assertOnlyCalled(coreDebugLogMock, coreErrorLogMock); 270 | expect(coreErrorLogMock).toHaveBeenCalledOnce(); 271 | expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 272 | `"Failed: An unknown error has occurred: some other error"`, 273 | ); 274 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 275 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toStrictEqual(testError); 276 | }); 277 | }); 278 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | 3 | import { getConfig } from "./action.ts"; 4 | import * as api from "./api.ts"; 5 | import { 6 | getWorkflowId, 7 | handleActionFail, 8 | handleActionSuccess, 9 | getRunIdAndUrl, 10 | } from "./return-dispatch.ts"; 11 | import { 12 | createDistinctIdRegex, 13 | getBranchName, 14 | logInfoForBranchNameResult, 15 | } from "./utils.ts"; 16 | 17 | export async function main(): Promise { 18 | try { 19 | const startTime = Date.now(); 20 | 21 | const config = getConfig(); 22 | api.init(config); 23 | 24 | const workflowId = await getWorkflowId(config.workflow); 25 | 26 | // Dispatch the action 27 | await api.dispatchWorkflow(config.distinctId); 28 | 29 | // Attempt to get the branch from config ref 30 | core.info("Attempt to extract branch name from ref..."); 31 | const branch = getBranchName(config.ref); 32 | logInfoForBranchNameResult(branch, config.ref); 33 | 34 | const distinctIdRegex = createDistinctIdRegex(config.distinctId); 35 | 36 | core.info("Attempting to identify run ID from steps..."); 37 | core.debug( 38 | `Attempting to identify run ID for ${config.workflow} (${workflowId})`, 39 | ); 40 | 41 | const result = await getRunIdAndUrl({ 42 | startTime, 43 | branch, 44 | distinctIdRegex, 45 | workflowId, 46 | workflowTimeoutMs: config.workflowTimeoutSeconds * 1000, 47 | workflowJobStepsRetryMs: config.workflowJobStepsRetrySeconds * 1000, 48 | }); 49 | if (result.success) { 50 | handleActionSuccess(result.value.id, result.value.url); 51 | core.debug(`Completed (${Date.now() - startTime}ms)`); 52 | } else { 53 | handleActionFail(); 54 | core.debug(`Timed out (${Date.now() - startTime}ms)`); 55 | } 56 | } catch (error) { 57 | if (error instanceof Error) { 58 | const failureMsg = `Failed: An unhandled error has occurred: ${error.message}`; 59 | core.setFailed(failureMsg); 60 | core.error(failureMsg); 61 | core.debug(error.stack ?? ""); 62 | } else { 63 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 64 | const failureMsg = `Failed: An unknown error has occurred: ${error}`; 65 | core.setFailed(failureMsg); 66 | core.error(failureMsg); 67 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 68 | core.debug(error as any); 69 | } 70 | } 71 | } 72 | 73 | if (!process.env.VITEST) { 74 | await main(); 75 | } 76 | -------------------------------------------------------------------------------- /src/reset.d.ts: -------------------------------------------------------------------------------- 1 | // Do not add any other lines of code to this file! 2 | import "@total-typescript/ts-reset"; 3 | -------------------------------------------------------------------------------- /src/return-dispatch.spec.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from "node:crypto"; 2 | 3 | import * as core from "@actions/core"; 4 | import { 5 | afterAll, 6 | afterEach, 7 | beforeAll, 8 | beforeEach, 9 | describe, 10 | expect, 11 | it, 12 | vi, 13 | type MockInstance, 14 | } from "vitest"; 15 | 16 | import { ActionOutputs } from "./action.ts"; 17 | import * as api from "./api.ts"; 18 | import * as constants from "./constants.ts"; 19 | import { 20 | attemptToFindRunId, 21 | getRunIdAndUrl, 22 | getWorkflowId, 23 | handleActionFail, 24 | handleActionSuccess, 25 | shouldRetryOrThrow, 26 | type GetRunIdAndUrlOpts, 27 | } from "./return-dispatch.ts"; 28 | import { mockLoggingFunctions } from "./test-utils/logging.mock.ts"; 29 | import * as utils from "./utils.ts"; 30 | 31 | vi.mock("@actions/core"); 32 | vi.mock("./api.ts"); 33 | 34 | describe("return-dispatch", () => { 35 | const { 36 | coreDebugLogMock, 37 | coreErrorLogMock, 38 | coreInfoLogMock, 39 | assertOnlyCalled, 40 | assertNoneCalled, 41 | } = mockLoggingFunctions(); 42 | 43 | function resetLogMocks(): void { 44 | for (const logMock of [ 45 | coreDebugLogMock, 46 | coreInfoLogMock, 47 | coreErrorLogMock, 48 | ]) { 49 | logMock.mockReset(); 50 | } 51 | } 52 | 53 | afterAll(() => { 54 | vi.restoreAllMocks(); 55 | }); 56 | 57 | afterEach(() => { 58 | vi.resetAllMocks(); 59 | }); 60 | 61 | describe("fetchWorkflowId", () => { 62 | let fetchWorkflowIdMock: MockInstance; 63 | 64 | beforeAll(() => { 65 | fetchWorkflowIdMock = vi.spyOn(api, "fetchWorkflowId"); 66 | }); 67 | 68 | it("should return the workflow ID without calling the API if given a number", async () => { 69 | const workflowId = await getWorkflowId(123); 70 | 71 | // Behaviour 72 | expect(workflowId).toStrictEqual(123); 73 | expect(fetchWorkflowIdMock).not.toHaveBeenCalled(); 74 | 75 | // Logging 76 | assertNoneCalled(); 77 | }); 78 | 79 | it("should return the workflow ID from API if given a string", async () => { 80 | fetchWorkflowIdMock.mockImplementationOnce(() => Promise.resolve(123)); 81 | const workflowId = await getWorkflowId("hello.yml"); 82 | 83 | // Behaviour 84 | expect(workflowId).toStrictEqual(123); 85 | expect(fetchWorkflowIdMock).toHaveBeenCalled(); 86 | 87 | // Logging 88 | assertOnlyCalled(coreInfoLogMock); 89 | expect(coreInfoLogMock).toHaveBeenCalledTimes(2); 90 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 91 | `"Fetching Workflow ID for hello.yml..."`, 92 | ); 93 | expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatchInlineSnapshot( 94 | `"Fetched Workflow ID: 123"`, 95 | ); 96 | }); 97 | 98 | it("should throw if any API error occurs", async () => { 99 | fetchWorkflowIdMock.mockImplementationOnce(() => 100 | Promise.reject(new Error()), 101 | ); 102 | const workflowIdPromise = getWorkflowId("hello.yml"); 103 | 104 | // Behaviour 105 | await expect(workflowIdPromise).rejects.toThrowError(); 106 | 107 | // Logging 108 | assertOnlyCalled(coreInfoLogMock); 109 | expect(coreInfoLogMock).toHaveBeenCalledOnce(); 110 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 111 | `"Fetching Workflow ID for hello.yml..."`, 112 | ); 113 | }); 114 | }); 115 | 116 | describe("shouldRetryOrThrow", () => { 117 | beforeEach(() => { 118 | vi.spyOn( 119 | constants, 120 | "WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MAX", 121 | "get", 122 | ).mockReturnValue(3); 123 | }); 124 | 125 | it('should retry on "Server error" and max attempts not exceeded', () => { 126 | const testErr = new Error("Server Error"); 127 | 128 | // Behaviour 129 | expect(shouldRetryOrThrow(testErr, 0)).toStrictEqual(true); 130 | 131 | // Logging 132 | assertOnlyCalled(coreDebugLogMock); 133 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 134 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 135 | `"Encountered a Server Error while attempting to fetch steps, retrying in 500ms"`, 136 | ); 137 | }); 138 | 139 | it('should retry on "Server error" and max attempts not exceeded', () => { 140 | const testErr = new Error("Server Error"); 141 | 142 | // Behaviour 143 | expect(shouldRetryOrThrow(testErr, 5)).toStrictEqual(false); 144 | 145 | // Logging 146 | assertNoneCalled(); 147 | }); 148 | 149 | it('should log on "Not Found"', () => { 150 | const testErr = new Error("Not Found"); 151 | 152 | // Behaviour 153 | expect(shouldRetryOrThrow(testErr, 0)).toStrictEqual(false); 154 | 155 | // Logging 156 | assertOnlyCalled(coreDebugLogMock); 157 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 158 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 159 | `"Could not identify ID in run, continuing..."`, 160 | ); 161 | }); 162 | 163 | it("re-throw on unhandled error", () => { 164 | const testErr = new Error("Unhandled Error"); 165 | 166 | // Behaviour 167 | expect(() => shouldRetryOrThrow(testErr, 0)).toThrow(testErr); 168 | 169 | // Logging 170 | assertOnlyCalled(coreDebugLogMock); 171 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 172 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 173 | `"Unhandled error has occurred: Unhandled Error"`, 174 | ); 175 | }); 176 | }); 177 | 178 | describe("attemptToFindRunId", () => { 179 | const testId = randomUUID(); 180 | 181 | let getWorkflowRunJobStepMock: MockInstance< 182 | typeof api.fetchWorkflowRunJobSteps 183 | >; 184 | let fetchWorkflowRunUrlMock: MockInstance; 185 | 186 | beforeEach(() => { 187 | getWorkflowRunJobStepMock = vi.spyOn(api, "fetchWorkflowRunJobSteps"); 188 | fetchWorkflowRunUrlMock = vi.spyOn(api, "fetchWorkflowRunUrl"); 189 | }); 190 | 191 | it("should return a not found result if there is nothing to iterate on", async () => { 192 | const result = await attemptToFindRunId(new RegExp(testId), []); 193 | if (result.success) { 194 | expect.fail("result found when none expected"); 195 | } 196 | 197 | // Behaviour 198 | expect(result.success).toStrictEqual(false); 199 | expect(getWorkflowRunJobStepMock).not.toHaveBeenCalled(); 200 | expect(fetchWorkflowRunUrlMock).not.toHaveBeenCalled(); 201 | 202 | // Logging 203 | assertNoneCalled(); 204 | }); 205 | 206 | it("should return a not found result if there is only undefined to iterate on", async () => { 207 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 208 | const result = await attemptToFindRunId(new RegExp(testId), [ 209 | undefined as any, 210 | ]); 211 | if (result.success) { 212 | expect.fail("result found when none expected"); 213 | } 214 | 215 | // Behaviour 216 | expect(result.success).toStrictEqual(false); 217 | expect(getWorkflowRunJobStepMock).not.toHaveBeenCalled(); 218 | expect(fetchWorkflowRunUrlMock).not.toHaveBeenCalled(); 219 | 220 | // Logging 221 | assertNoneCalled(); 222 | }); 223 | 224 | it("finds the ID on the first iteration", async () => { 225 | getWorkflowRunJobStepMock.mockResolvedValueOnce([testId]); 226 | fetchWorkflowRunUrlMock.mockResolvedValue("test-url"); 227 | 228 | const result = await attemptToFindRunId(new RegExp(testId), [0]); 229 | if (!result.success) { 230 | expect.fail("result not found when expected"); 231 | } 232 | 233 | // Behaviour 234 | expect(result.success).toStrictEqual(true); 235 | expect(result.value.id).toStrictEqual(0); 236 | expect(result.value.url).toStrictEqual("test-url"); 237 | expect(getWorkflowRunJobStepMock).toHaveBeenCalledOnce(); 238 | expect(fetchWorkflowRunUrlMock).toHaveBeenCalledOnce(); 239 | 240 | // Logging 241 | assertNoneCalled(); 242 | }); 243 | 244 | it("finds the ID on the second iteration", async () => { 245 | getWorkflowRunJobStepMock 246 | .mockResolvedValueOnce([]) 247 | .mockResolvedValueOnce([testId]); 248 | fetchWorkflowRunUrlMock.mockResolvedValue("test-url"); 249 | 250 | const result = await attemptToFindRunId(new RegExp(testId), [0, 0]); 251 | if (!result.success) { 252 | expect.fail("result not found when expected"); 253 | } 254 | 255 | // Behaviour 256 | expect(result.success).toStrictEqual(true); 257 | expect(result.value.id).toStrictEqual(0); 258 | expect(result.value.url).toStrictEqual("test-url"); 259 | expect(getWorkflowRunJobStepMock).toHaveBeenCalledTimes(2); 260 | expect(fetchWorkflowRunUrlMock).toHaveBeenCalledOnce(); 261 | 262 | // Logging 263 | assertNoneCalled(); 264 | }); 265 | 266 | it("finds the ID among many steps", async () => { 267 | getWorkflowRunJobStepMock.mockResolvedValueOnce([ 268 | "first", 269 | "second", 270 | "third", 271 | testId, 272 | ]); 273 | fetchWorkflowRunUrlMock.mockResolvedValue("test-url"); 274 | 275 | const result = await attemptToFindRunId(new RegExp(testId), [0]); 276 | if (!result.success) { 277 | expect.fail("result not found when expected"); 278 | } 279 | 280 | // Behaviour 281 | expect(result.success).toStrictEqual(true); 282 | expect(result.value.id).toStrictEqual(0); 283 | expect(result.value.url).toStrictEqual("test-url"); 284 | expect(getWorkflowRunJobStepMock).toHaveBeenCalledOnce(); 285 | expect(fetchWorkflowRunUrlMock).toHaveBeenCalledOnce(); 286 | 287 | // Logging 288 | assertNoneCalled(); 289 | }); 290 | 291 | it("does nothing if called with an empty array", async () => { 292 | const result = await attemptToFindRunId(new RegExp(testId), []); 293 | if (result.success) { 294 | expect.fail("result found when none expected"); 295 | } 296 | 297 | // Behaviour 298 | expect(result.success).toStrictEqual(false); 299 | expect(result.reason).toStrictEqual("invalid input"); 300 | expect(getWorkflowRunJobStepMock).not.toHaveBeenCalled(); 301 | expect(fetchWorkflowRunUrlMock).not.toHaveBeenCalled(); 302 | 303 | // Logging 304 | assertNoneCalled(); 305 | }); 306 | 307 | describe("server error retries", () => { 308 | beforeEach(() => { 309 | vi.spyOn( 310 | constants, 311 | "WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MAX", 312 | "get", 313 | ).mockReturnValue(3); 314 | vi.spyOn( 315 | constants, 316 | "WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MS", 317 | "get", 318 | ).mockReturnValue(500); 319 | 320 | vi.useFakeTimers(); 321 | }); 322 | 323 | afterEach(() => { 324 | vi.useRealTimers(); 325 | }); 326 | 327 | it("fails on exceeded server errors", async () => { 328 | vi.spyOn( 329 | constants, 330 | "WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MAX", 331 | "get", 332 | ).mockReturnValue(3); 333 | vi.spyOn( 334 | constants, 335 | "WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MS", 336 | "get", 337 | ).mockReturnValue(500); 338 | 339 | getWorkflowRunJobStepMock.mockRejectedValue(new Error("Server Error")); 340 | 341 | const attemptToFindRunIdPromise = attemptToFindRunId( 342 | new RegExp(testId), 343 | [0], 344 | ); 345 | 346 | // Advance past the sleeps 347 | await vi.runAllTimersAsync(); 348 | 349 | const result = await attemptToFindRunIdPromise; 350 | if (result.success) { 351 | expect.fail("result found when none expected"); 352 | } 353 | 354 | // Behaviour 355 | expect(result.success).toStrictEqual(false); 356 | expect(getWorkflowRunJobStepMock).toHaveBeenCalledTimes(4); // initial + retries 357 | expect(fetchWorkflowRunUrlMock).not.toHaveBeenCalled(); 358 | 359 | // Logging 360 | assertOnlyCalled(coreDebugLogMock); 361 | expect(coreDebugLogMock).toHaveBeenCalledTimes(3); 362 | const debugLineSnapshot = `"Encountered a Server Error while attempting to fetch steps, retrying in 500ms"`; 363 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 364 | debugLineSnapshot, 365 | ); 366 | expect(coreDebugLogMock.mock.calls[1]?.[0]).toMatchInlineSnapshot( 367 | debugLineSnapshot, 368 | ); 369 | expect(coreDebugLogMock.mock.calls[2]?.[0]).toMatchInlineSnapshot( 370 | debugLineSnapshot, 371 | ); 372 | }); 373 | }); 374 | 375 | it("should throw an unhandled error", async () => { 376 | const unhandledError = new Error("Unhandled Error"); 377 | getWorkflowRunJobStepMock.mockRejectedValue(unhandledError); 378 | 379 | await expect(() => 380 | attemptToFindRunId(new RegExp(testId), [0]), 381 | ).rejects.toThrowError(unhandledError); 382 | 383 | // Behaviour 384 | expect(getWorkflowRunJobStepMock).toHaveBeenCalledOnce(); 385 | expect(fetchWorkflowRunUrlMock).not.toHaveBeenCalled(); 386 | 387 | // Logging 388 | assertOnlyCalled(coreDebugLogMock); 389 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 390 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 391 | `"Unhandled error has occurred: Unhandled Error"`, 392 | ); 393 | }); 394 | 395 | it("should throw a non-error", async () => { 396 | const thrownValue = "thrown"; 397 | getWorkflowRunJobStepMock.mockRejectedValue(thrownValue); 398 | 399 | await expect(() => 400 | attemptToFindRunId(new RegExp(testId), [0]), 401 | ).rejects.toThrow(thrownValue); 402 | 403 | // Behaviour 404 | expect(getWorkflowRunJobStepMock).toHaveBeenCalledOnce(); 405 | expect(fetchWorkflowRunUrlMock).not.toHaveBeenCalled(); 406 | 407 | // Logging 408 | assertNoneCalled(); 409 | }); 410 | }); 411 | 412 | describe("handleAction", () => { 413 | let setFailedSpy: MockInstance; 414 | let setOutputSpy: MockInstance; 415 | 416 | beforeEach(() => { 417 | setFailedSpy = vi.spyOn(core, "setFailed"); 418 | setOutputSpy = vi.spyOn(core, "setOutput"); 419 | }); 420 | 421 | describe("handleActionSuccess", () => { 422 | it("should set the action output and status", () => { 423 | handleActionSuccess(0, "test-url"); 424 | 425 | // Behaviour 426 | expect(setFailedSpy).not.toHaveBeenCalled(); 427 | expect(setOutputSpy).toHaveBeenCalledTimes(2); 428 | expect(setOutputSpy.mock.calls[0]?.[0]).toStrictEqual( 429 | ActionOutputs.runId, 430 | ); 431 | expect(setOutputSpy.mock.calls[0]?.[1]).toStrictEqual(0); 432 | expect(setOutputSpy.mock.calls[1]?.[0]).toStrictEqual( 433 | ActionOutputs.runUrl, 434 | ); 435 | expect(setOutputSpy.mock.calls[1]?.[1]).toStrictEqual("test-url"); 436 | 437 | // Logging 438 | assertOnlyCalled(coreInfoLogMock); 439 | expect(coreInfoLogMock).toHaveBeenCalledOnce(); 440 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 441 | ` 442 | "Successfully identified remote Run: 443 | Run ID: 0 444 | URL: test-url" 445 | `, 446 | ); 447 | }); 448 | }); 449 | 450 | describe("handleActionFail", () => { 451 | it("should set the action output and status", () => { 452 | handleActionFail(); 453 | 454 | // Behaviour 455 | expect(setFailedSpy).toHaveBeenCalled(); 456 | expect(setOutputSpy).not.toHaveBeenCalled(); 457 | 458 | // Logging 459 | assertOnlyCalled(coreErrorLogMock); 460 | expect(coreErrorLogMock).toHaveBeenCalledOnce(); 461 | expect(coreErrorLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 462 | `"Failed: Timeout exceeded while attempting to get Run ID"`, 463 | ); 464 | }); 465 | }); 466 | }); 467 | 468 | describe("getRunIdAndUrl", () => { 469 | const distinctId = crypto.randomUUID(); 470 | const distinctIdRegex = new RegExp(distinctId); 471 | const workflowId = 123; 472 | const branch: utils.BranchNameResult = Object.freeze({ 473 | isTag: false, 474 | ref: "/refs/heads/main", 475 | branchName: "main", 476 | }); 477 | const defaultOpts: GetRunIdAndUrlOpts = { 478 | startTime: Date.now(), 479 | branch: branch, 480 | distinctIdRegex: distinctIdRegex, 481 | workflowId: workflowId, 482 | workflowTimeoutMs: 100, 483 | workflowJobStepsRetryMs: 5, 484 | }; 485 | 486 | let apiFetchWorkflowRunIdsMock: MockInstance< 487 | typeof api.fetchWorkflowRunIds 488 | >; 489 | let apiFetchWorkflowRunJobStepsMock: MockInstance< 490 | typeof api.fetchWorkflowRunJobSteps 491 | >; 492 | let apiFetchWorkflowRunUrlMock: MockInstance< 493 | typeof api.fetchWorkflowRunUrl 494 | >; 495 | let apiRetryOrTimeoutMock: MockInstance; 496 | let utilSleepMock: MockInstance; 497 | 498 | beforeEach(() => { 499 | vi.useFakeTimers(); 500 | 501 | apiFetchWorkflowRunIdsMock = vi.spyOn(api, "fetchWorkflowRunIds"); 502 | apiFetchWorkflowRunJobStepsMock = vi.spyOn( 503 | api, 504 | "fetchWorkflowRunJobSteps", 505 | ); 506 | apiFetchWorkflowRunUrlMock = vi.spyOn(api, "fetchWorkflowRunUrl"); 507 | apiRetryOrTimeoutMock = vi.spyOn(api, "retryOrTimeout"); 508 | 509 | utilSleepMock = vi 510 | .spyOn(utils, "sleep") 511 | .mockImplementation( 512 | (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), 513 | ); 514 | }); 515 | 516 | afterEach(() => { 517 | vi.useRealTimers(); 518 | 519 | vi.resetAllMocks(); 520 | }); 521 | 522 | it("should return the ID when found", async () => { 523 | const runId = 0; 524 | const runUrl = "test-url"; 525 | apiRetryOrTimeoutMock.mockResolvedValue({ 526 | success: true, 527 | value: [runId], 528 | }); 529 | apiFetchWorkflowRunJobStepsMock.mockResolvedValue([distinctId]); 530 | apiFetchWorkflowRunUrlMock.mockResolvedValue(runUrl); 531 | 532 | const run = await getRunIdAndUrl({ 533 | ...defaultOpts, 534 | workflowTimeoutMs: 1000, 535 | }); 536 | 537 | if (!run.success) { 538 | expect.fail("expected call to succeed"); 539 | } 540 | 541 | // Behaviour 542 | expect(run.value.id).toStrictEqual(runId); 543 | expect(run.value.url).toStrictEqual(runUrl); 544 | 545 | expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); 546 | expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); 547 | expect(apiFetchWorkflowRunIdsMock).not.toHaveBeenCalled(); 548 | expect(utilSleepMock).not.toHaveBeenCalled(); 549 | 550 | // Logging 551 | assertOnlyCalled(coreDebugLogMock); 552 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 553 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); 554 | expect(coreDebugLogMock.mock.calls[1]?.[0]).toMatchSnapshot(); 555 | }); 556 | 557 | it("should call retryOrTimeout with the larger WORKFLOW_FETCH_TIMEOUT_MS timeout value", async () => { 558 | const workflowFetchTimeoutMs = 1000; 559 | const workflowTimeoutMs = 100; 560 | apiRetryOrTimeoutMock.mockResolvedValue({ 561 | success: true, 562 | value: [0], 563 | }); 564 | apiFetchWorkflowRunJobStepsMock.mockResolvedValue([distinctId]); 565 | vi.spyOn(constants, "WORKFLOW_FETCH_TIMEOUT_MS", "get").mockReturnValue( 566 | workflowFetchTimeoutMs, 567 | ); 568 | 569 | await getRunIdAndUrl({ 570 | ...defaultOpts, 571 | workflowTimeoutMs: workflowTimeoutMs, 572 | }); 573 | 574 | // Behaviour 575 | expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); 576 | expect(apiRetryOrTimeoutMock.mock.calls[0]?.[1]).toStrictEqual( 577 | workflowFetchTimeoutMs, 578 | ); 579 | expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); 580 | expect(apiFetchWorkflowRunIdsMock).not.toHaveBeenCalled(); 581 | expect(utilSleepMock).not.toHaveBeenCalled(); 582 | 583 | // Logging 584 | assertOnlyCalled(coreDebugLogMock); 585 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 586 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 587 | `"Attempting to get step names for Run IDs: [0]"`, 588 | ); 589 | }); 590 | 591 | it("should call retryOrTimeout with the larger workflowTimeoutMs timeout value", async () => { 592 | const workflowFetchTimeoutMs = 100; 593 | const workflowTimeoutMs = 1000; 594 | apiRetryOrTimeoutMock.mockResolvedValue({ 595 | success: true, 596 | value: [0], 597 | }); 598 | apiFetchWorkflowRunJobStepsMock.mockResolvedValue([distinctId]); 599 | vi.spyOn(constants, "WORKFLOW_FETCH_TIMEOUT_MS", "get").mockReturnValue( 600 | workflowFetchTimeoutMs, 601 | ); 602 | 603 | await getRunIdAndUrl({ 604 | ...defaultOpts, 605 | workflowTimeoutMs: workflowTimeoutMs, 606 | }); 607 | 608 | // Behaviour 609 | expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); 610 | expect(apiRetryOrTimeoutMock.mock.calls[0]?.[1]).toStrictEqual( 611 | workflowTimeoutMs, 612 | ); 613 | expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); 614 | expect(apiFetchWorkflowRunIdsMock).not.toHaveBeenCalled(); 615 | expect(utilSleepMock).not.toHaveBeenCalled(); 616 | 617 | // Logging 618 | assertOnlyCalled(coreDebugLogMock); 619 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 620 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); 621 | }); 622 | 623 | it("called fetchWorkflowRunIds with the provided workflowId and branch", async () => { 624 | apiRetryOrTimeoutMock.mockImplementation(async (retryFunc) => { 625 | await retryFunc(); 626 | return { 627 | success: true, 628 | value: [0], 629 | }; 630 | }); 631 | apiFetchWorkflowRunJobStepsMock.mockResolvedValue([distinctId]); 632 | apiFetchWorkflowRunUrlMock.mockResolvedValue("test-url"); 633 | 634 | await getRunIdAndUrl(defaultOpts); 635 | 636 | // Behaviour 637 | expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); 638 | expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); 639 | 640 | expect(apiFetchWorkflowRunIdsMock).toHaveBeenCalledOnce(); 641 | expect(apiFetchWorkflowRunIdsMock.mock.lastCall?.[0]).toStrictEqual( 642 | workflowId, 643 | ); 644 | expect(apiFetchWorkflowRunIdsMock.mock.lastCall?.[1]).toStrictEqual( 645 | branch, 646 | ); 647 | 648 | // Logging 649 | assertOnlyCalled(coreDebugLogMock); 650 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 651 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); 652 | }); 653 | 654 | it("should retry until an ID is found", async () => { 655 | const runId = 0; 656 | const runUrl = "test-url"; 657 | apiRetryOrTimeoutMock 658 | .mockResolvedValue({ 659 | success: true, 660 | value: [runId], 661 | }) 662 | .mockResolvedValueOnce({ success: true, value: [] }) 663 | .mockResolvedValueOnce({ success: true, value: [] }); 664 | apiFetchWorkflowRunJobStepsMock.mockResolvedValue([distinctId]); 665 | apiFetchWorkflowRunUrlMock.mockResolvedValue(runUrl); 666 | 667 | const retryMs = 5000; 668 | const timeoutMs = 60 * 60 * 100; 669 | 670 | const getRunIdAndUrlPromise = getRunIdAndUrl({ 671 | ...defaultOpts, 672 | workflowTimeoutMs: timeoutMs, 673 | workflowJobStepsRetryMs: retryMs, 674 | }); 675 | 676 | // First attempt 677 | expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); 678 | await vi.advanceTimersByTimeAsync(1); // deplete queue 679 | 680 | assertOnlyCalled(coreInfoLogMock); 681 | 682 | expect(coreInfoLogMock).toHaveBeenCalledTimes(2); 683 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); 684 | expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatchSnapshot(); 685 | 686 | expect(utilSleepMock).toHaveBeenCalledOnce(); 687 | expect(utilSleepMock).toHaveBeenCalledWith(retryMs); 688 | 689 | resetLogMocks(); 690 | await vi.advanceTimersByTimeAsync(retryMs); 691 | 692 | // Second attempt 693 | expect(apiRetryOrTimeoutMock).toHaveBeenCalledTimes(2); 694 | 695 | assertOnlyCalled(coreInfoLogMock); 696 | 697 | expect(coreInfoLogMock).toHaveBeenCalledTimes(2); 698 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); 699 | expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatchSnapshot(); 700 | 701 | expect(utilSleepMock).toHaveBeenCalledTimes(2); 702 | expect(utilSleepMock).toHaveBeenCalledWith(retryMs * 2); 703 | 704 | resetLogMocks(); 705 | await vi.advanceTimersByTimeAsync(retryMs * 2); 706 | 707 | // Third attempt 708 | expect(apiRetryOrTimeoutMock).toHaveBeenCalledTimes(3); 709 | expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); 710 | expect(apiFetchWorkflowRunUrlMock).toHaveBeenCalledOnce(); 711 | 712 | assertOnlyCalled(coreDebugLogMock); 713 | 714 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 715 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); 716 | 717 | expect(utilSleepMock).toHaveBeenCalledTimes(2); 718 | resetLogMocks(); 719 | 720 | // Result 721 | const run = await getRunIdAndUrlPromise; 722 | if (!run.success) { 723 | expect.fail("expected call to succeed"); 724 | } 725 | expect(run.value.id).toStrictEqual(runId); 726 | expect(run.value.url).toStrictEqual(runUrl); 727 | expect(apiRetryOrTimeoutMock).toHaveBeenCalledTimes(3); 728 | expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); 729 | expect(apiFetchWorkflowRunIdsMock).not.toHaveBeenCalled(); 730 | expect(apiFetchWorkflowRunUrlMock).toHaveBeenCalledOnce(); 731 | assertNoneCalled(); 732 | }); 733 | 734 | it("should timeout when unable failing to get the run IDs", async () => { 735 | apiRetryOrTimeoutMock.mockResolvedValue({ 736 | success: false, 737 | reason: "timeout", 738 | }); 739 | 740 | // Behaviour 741 | const getRunIdAndUrlPromise = getRunIdAndUrl({ 742 | ...defaultOpts, 743 | }); 744 | await vi.advanceTimersByTimeAsync(1000); 745 | 746 | const run = await getRunIdAndUrlPromise; 747 | 748 | if (run.success) { 749 | expect.fail("expected call to fail"); 750 | } 751 | 752 | // Behaviour 753 | expect(run.reason).toStrictEqual("timeout"); 754 | 755 | expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); 756 | expect(apiFetchWorkflowRunJobStepsMock).not.toHaveBeenCalled(); 757 | expect(apiFetchWorkflowRunIdsMock).not.toHaveBeenCalled(); 758 | expect(apiFetchWorkflowRunUrlMock).not.toHaveBeenCalled(); 759 | expect(utilSleepMock).not.toHaveBeenCalled(); 760 | 761 | // Logging 762 | assertOnlyCalled(coreDebugLogMock); 763 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 764 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatch( 765 | /Timed out while attempting to fetch Workflow Run IDs, waited [0-9]+ms/, 766 | ); 767 | }); 768 | 769 | it("should timeout when unable to find over time", async () => { 770 | const runId = 0; 771 | const runUrl = "test-url"; 772 | apiRetryOrTimeoutMock.mockResolvedValue({ 773 | success: true, 774 | value: [runId], 775 | }); 776 | apiFetchWorkflowRunJobStepsMock.mockResolvedValue([]); 777 | apiFetchWorkflowRunUrlMock.mockResolvedValue(runUrl); 778 | 779 | const retryMs = 3000; 780 | const timeoutMs = 15 * 1000; 781 | 782 | const getRunIdAndUrlPromise = getRunIdAndUrl({ 783 | ...defaultOpts, 784 | workflowTimeoutMs: timeoutMs, 785 | workflowJobStepsRetryMs: retryMs, 786 | }); 787 | 788 | // First attempt 789 | expect(apiRetryOrTimeoutMock).toHaveBeenCalledOnce(); 790 | await vi.advanceTimersByTimeAsync(1); // deplete queue 791 | expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledOnce(); 792 | assertOnlyCalled(coreDebugLogMock, coreInfoLogMock); 793 | 794 | expect(coreInfoLogMock).toHaveBeenCalledTimes(2); 795 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); 796 | expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatchSnapshot(); 797 | 798 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 799 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); 800 | 801 | expect(utilSleepMock).toHaveBeenCalledOnce(); 802 | expect(utilSleepMock).toHaveBeenCalledWith(retryMs); 803 | 804 | resetLogMocks(); 805 | await vi.advanceTimersByTimeAsync(retryMs); 806 | 807 | // Second attempt 808 | expect(apiRetryOrTimeoutMock).toHaveBeenCalledTimes(2); 809 | expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledTimes(2); 810 | assertOnlyCalled(coreDebugLogMock, coreInfoLogMock); 811 | 812 | expect(coreInfoLogMock).toHaveBeenCalledTimes(2); 813 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); 814 | expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatchSnapshot(); 815 | 816 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 817 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); 818 | 819 | expect(utilSleepMock).toHaveBeenCalledTimes(2); 820 | expect(utilSleepMock).toHaveBeenCalledWith(retryMs * 2); 821 | 822 | resetLogMocks(); 823 | await vi.advanceTimersByTimeAsync(retryMs * 2); 824 | 825 | // Timeout attempt 826 | expect(apiRetryOrTimeoutMock).toHaveBeenCalledTimes(3); 827 | expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledTimes(3); 828 | assertOnlyCalled(coreDebugLogMock, coreInfoLogMock); 829 | 830 | expect(coreInfoLogMock).toHaveBeenCalledTimes(2); 831 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); 832 | expect(coreInfoLogMock.mock.calls[1]?.[0]).toMatch( 833 | /Waiting for \d{4,5}ms before the next attempt\.\.\./, 834 | ); 835 | 836 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 837 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchSnapshot(); 838 | 839 | expect(utilSleepMock).toHaveBeenCalledTimes(3); 840 | const elapsedTime = Date.now() - defaultOpts.startTime; // `waitTime` should be using `workflowTimeoutMs` at this point 841 | expect(utilSleepMock.mock.lastCall?.[0]).approximately( 842 | timeoutMs - elapsedTime, 843 | 5, 844 | ); 845 | 846 | resetLogMocks(); 847 | await vi.advanceTimersByTimeAsync(retryMs * 3); 848 | 849 | // Result 850 | const run = await getRunIdAndUrlPromise; 851 | if (run.success) { 852 | expect.fail("expected call to fail"); 853 | } 854 | expect(run.reason).toStrictEqual("timeout"); 855 | expect(apiRetryOrTimeoutMock).toHaveBeenCalledTimes(3); 856 | expect(apiFetchWorkflowRunJobStepsMock).toHaveBeenCalledTimes(3); 857 | expect(apiFetchWorkflowRunIdsMock).not.toHaveBeenCalled(); 858 | expect(apiFetchWorkflowRunUrlMock).not.toHaveBeenCalled(); 859 | assertNoneCalled(); 860 | }); 861 | }); 862 | }); 863 | -------------------------------------------------------------------------------- /src/return-dispatch.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | 3 | import { ActionOutputs } from "./action.ts"; 4 | import * as api from "./api.ts"; 5 | import * as constants from "./constants.ts"; 6 | import type { Result } from "./types.ts"; 7 | import { sleep, type BranchNameResult } from "./utils.ts"; 8 | 9 | export function shouldRetryOrThrow( 10 | error: Error, 11 | currentAttempts: number, 12 | ): boolean { 13 | switch (error.message) { 14 | case "Server Error": { 15 | if ( 16 | currentAttempts < constants.WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MAX 17 | ) { 18 | core.debug( 19 | "Encountered a Server Error while attempting to fetch steps, " + 20 | `retrying in ${constants.WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MS}ms`, 21 | ); 22 | 23 | return true; 24 | } 25 | return false; 26 | } 27 | case "Not Found": { 28 | core.debug("Could not identify ID in run, continuing..."); 29 | return false; 30 | } 31 | default: { 32 | core.debug(`Unhandled error has occurred: ${error.message}`); 33 | throw error; 34 | } 35 | } 36 | } 37 | 38 | /** 39 | * Attempt to read the distinct ID in the steps for each existing run ID. 40 | */ 41 | export async function attemptToFindRunId( 42 | idRegex: RegExp, 43 | workflowRunIds: number[], 44 | ): Promise> { 45 | if (workflowRunIds.length === 0) { 46 | return { 47 | success: false, 48 | reason: "invalid input", 49 | }; 50 | } 51 | 52 | let currentWorkflowRunIndex = 0; 53 | let currentFetchWorkflowRunJobStepsAttempt = 0; 54 | while (currentWorkflowRunIndex < workflowRunIds.length) { 55 | const id = workflowRunIds[currentWorkflowRunIndex]; 56 | if (id === undefined) { 57 | break; 58 | } 59 | 60 | try { 61 | const steps = await api.fetchWorkflowRunJobSteps(id); 62 | 63 | for (const step of steps) { 64 | if (idRegex.test(step)) { 65 | const url = await api.fetchWorkflowRunUrl(id); 66 | return { success: true, value: { id, url } }; 67 | } 68 | } 69 | } catch (error) { 70 | if (!(error instanceof Error)) { 71 | throw error; 72 | } 73 | 74 | const shouldRetry = shouldRetryOrThrow( 75 | error, 76 | currentFetchWorkflowRunJobStepsAttempt, 77 | ); 78 | if (shouldRetry) { 79 | currentFetchWorkflowRunJobStepsAttempt++; 80 | await sleep(constants.WORKFLOW_JOB_STEPS_SERVER_ERROR_RETRY_MS); 81 | // Continue without increasing the current index to retry the same ID. 82 | continue; 83 | } 84 | } 85 | 86 | currentFetchWorkflowRunJobStepsAttempt = 0; 87 | currentWorkflowRunIndex++; 88 | } 89 | 90 | return { success: false, reason: "timeout" }; 91 | } 92 | 93 | /** 94 | * Attempt to get the upstream workflow ID if given a string, otherwise 95 | * use the workflow config as the ID number. 96 | */ 97 | export async function getWorkflowId( 98 | workflow: string | number, 99 | ): Promise { 100 | if (typeof workflow === "number") { 101 | // Already asserted is a non-NaN number during config construction 102 | return workflow; 103 | } 104 | 105 | core.info(`Fetching Workflow ID for ${workflow}...`); 106 | const workflowId = await api.fetchWorkflowId(workflow); 107 | core.info(`Fetched Workflow ID: ${workflowId}`); 108 | return workflowId; 109 | } 110 | 111 | export function handleActionSuccess(id: number, url: string): void { 112 | core.info( 113 | "Successfully identified remote Run:\n" + 114 | ` Run ID: ${id}\n` + 115 | ` URL: ${url}`, 116 | ); 117 | core.setOutput(ActionOutputs.runId, id); 118 | core.setOutput(ActionOutputs.runUrl, url); 119 | } 120 | 121 | export function handleActionFail(): void { 122 | core.error("Failed: Timeout exceeded while attempting to get Run ID"); 123 | core.setFailed("Timeout exceeded while attempting to get Run ID"); 124 | } 125 | 126 | export interface GetRunIdAndUrlOpts { 127 | startTime: number; 128 | branch: BranchNameResult; 129 | distinctIdRegex: RegExp; 130 | workflowId: number; 131 | workflowTimeoutMs: number; 132 | workflowJobStepsRetryMs: number; 133 | } 134 | export async function getRunIdAndUrl({ 135 | startTime, 136 | branch, 137 | distinctIdRegex, 138 | workflowId, 139 | workflowTimeoutMs, 140 | workflowJobStepsRetryMs, 141 | }: GetRunIdAndUrlOpts): Promise> { 142 | const retryTimeout = Math.max( 143 | constants.WORKFLOW_FETCH_TIMEOUT_MS, 144 | workflowTimeoutMs, 145 | ); 146 | 147 | let attemptNo = 0; 148 | let elapsedTime = Date.now() - startTime; 149 | while (elapsedTime < workflowTimeoutMs) { 150 | attemptNo++; 151 | 152 | // Get all runs for a given workflow ID 153 | const fetchWorkflowRunIds = await api.retryOrTimeout( 154 | () => api.fetchWorkflowRunIds(workflowId, branch), 155 | retryTimeout, 156 | ); 157 | if (!fetchWorkflowRunIds.success) { 158 | core.debug( 159 | `Timed out while attempting to fetch Workflow Run IDs, waited ${Date.now() - startTime}ms`, 160 | ); 161 | break; 162 | } 163 | 164 | const workflowRunIds = fetchWorkflowRunIds.value; 165 | 166 | if (workflowRunIds.length > 0) { 167 | core.debug( 168 | `Attempting to get step names for Run IDs: [${workflowRunIds.join(", ")}]`, 169 | ); 170 | 171 | const result = await attemptToFindRunId(distinctIdRegex, workflowRunIds); 172 | if (result.success) { 173 | return result; 174 | } 175 | 176 | core.info( 177 | `Exhausted searching IDs in known runs, attempt ${attemptNo}...`, 178 | ); 179 | } else { 180 | core.info(`No Run IDs found for workflow, attempt ${attemptNo}...`); 181 | } 182 | 183 | const waitTime = Math.min( 184 | workflowJobStepsRetryMs * attemptNo, // Lineal backoff 185 | workflowTimeoutMs - elapsedTime, // Ensure we don't exceed the timeout 186 | ); 187 | 188 | core.info(`Waiting for ${waitTime}ms before the next attempt...`); 189 | await sleep(waitTime); 190 | 191 | elapsedTime = Date.now() - startTime; 192 | } 193 | 194 | return { success: false, reason: "timeout" }; 195 | } 196 | -------------------------------------------------------------------------------- /src/test-utils/logging.mock.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import { symDiff } from "@opentf/std"; 3 | import { type MockInstance, vi, expect } from "vitest"; 4 | 5 | // Consuming test suites must first call: 6 | // vi.mock("@actions/core"); 7 | 8 | interface MockedLoggingFunctions { 9 | coreDebugLogMock: MockInstance<(message: string) => void>; 10 | coreInfoLogMock: MockInstance<(message: string) => void>; 11 | coreWarningLogMock: MockInstance<(message: string) => void>; 12 | coreErrorLogMock: MockInstance<(message: string) => void>; 13 | assertOnlyCalled: ( 14 | ...coreLogMocks: MockInstance<(message: string) => void>[] 15 | ) => void; 16 | assertNoneCalled: () => void; 17 | } 18 | 19 | export function mockLoggingFunctions(): MockedLoggingFunctions { 20 | const coreDebugLogMock: MockInstance = vi 21 | .spyOn(core, "debug") 22 | .mockImplementation(() => undefined); 23 | const coreInfoLogMock: MockInstance = vi 24 | .spyOn(core, "info") 25 | .mockImplementation(() => undefined); 26 | const coreWarningLogMock: MockInstance = vi.spyOn( 27 | core, 28 | "warning", 29 | ); 30 | const coreErrorLogMock: MockInstance = vi 31 | .spyOn(core, "error") 32 | .mockImplementation(() => undefined); 33 | 34 | const coreLogMockSet = new Set void>>([ 35 | coreDebugLogMock, 36 | coreInfoLogMock, 37 | coreWarningLogMock, 38 | coreErrorLogMock, 39 | ]); 40 | const assertOnlyCalled = ( 41 | ...coreLogMocks: MockInstance<(message: string) => void>[] 42 | ): void => { 43 | assertOnlyCalledInner(coreLogMockSet, ...coreLogMocks); 44 | }; 45 | 46 | const assertNoneCalled = (): void => { 47 | assertNoneCalledInner(coreLogMockSet); 48 | }; 49 | 50 | return { 51 | coreDebugLogMock, 52 | coreInfoLogMock, 53 | coreWarningLogMock, 54 | coreErrorLogMock, 55 | assertOnlyCalled, 56 | assertNoneCalled, 57 | }; 58 | } 59 | 60 | /** 61 | * Explicitly assert no rogue log calls are made 62 | * that are not correctly asserted in these tests 63 | */ 64 | function assertOnlyCalledInner( 65 | coreLogMockSet: Set void>>, 66 | ...coreLogMocks: MockInstance<(message: string) => void>[] 67 | ): void { 68 | if (coreLogMocks.length <= 0) { 69 | throw new Error( 70 | "assertOnlyCalled must be called with at least one mock to assert", 71 | ); 72 | } 73 | 74 | // Once Node 22 is LTS, this can be: 75 | // const diff = coreLogMockSet.symmetricDifference(new Set(coreLogMocks)); 76 | 77 | const notCalled = symDiff([[...coreLogMockSet], coreLogMocks]); 78 | for (const logMock of notCalled) { 79 | expect(logMock).not.toHaveBeenCalled(); 80 | } 81 | for (const logMock of coreLogMocks) { 82 | expect(logMock).toHaveBeenCalled(); 83 | } 84 | } 85 | 86 | function assertNoneCalledInner( 87 | coreLogMockSet: Set void>>, 88 | ): void { 89 | for (const logMock of coreLogMockSet) { 90 | expect(logMock).not.toHaveBeenCalled(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Result = ResultSuccess | ResultTimeout | ResultInvalidInput; 2 | 3 | interface ResultSuccess { 4 | success: true; 5 | value: T; 6 | } 7 | 8 | interface ResultTimeout { 9 | success: false; 10 | reason: "timeout"; 11 | } 12 | 13 | interface ResultInvalidInput { 14 | success: false; 15 | reason: "invalid input"; 16 | } 17 | -------------------------------------------------------------------------------- /src/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | afterAll, 3 | afterEach, 4 | beforeAll, 5 | describe, 6 | expect, 7 | it, 8 | vi, 9 | } from "vitest"; 10 | 11 | import { mockLoggingFunctions } from "./test-utils/logging.mock.ts"; 12 | import { 13 | createDistinctIdRegex, 14 | escapeRegExp, 15 | getBranchName, 16 | logInfoForBranchNameResult, 17 | sleep, 18 | } from "./utils.ts"; 19 | 20 | vi.mock("@actions/core"); 21 | 22 | describe("utils", () => { 23 | const { 24 | coreDebugLogMock, 25 | coreInfoLogMock, 26 | coreWarningLogMock, 27 | assertOnlyCalled, 28 | assertNoneCalled, 29 | } = mockLoggingFunctions(); 30 | 31 | afterEach(() => { 32 | vi.resetAllMocks(); 33 | }); 34 | 35 | afterAll(() => { 36 | vi.restoreAllMocks(); 37 | }); 38 | 39 | describe("getBranchNameFromRef", () => { 40 | // We want to assert that the props are properly set in 41 | // the union of the return type 42 | interface BranchNameResultUnion { 43 | branchName?: string; 44 | isTag: boolean; 45 | ref: string; 46 | } 47 | 48 | it("should return the branch name for a valid branch ref", () => { 49 | const branchName = "cool_feature"; 50 | const ref = `/refs/heads/${branchName}`; 51 | const branch = getBranchName(ref) as BranchNameResultUnion; 52 | 53 | // Behaviour 54 | expect(branch.isTag).toStrictEqual(false); 55 | expect(branch.branchName).toStrictEqual(branchName); 56 | expect(branch.ref).toStrictEqual(ref); 57 | 58 | // Logging 59 | assertOnlyCalled(coreDebugLogMock); 60 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 61 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 62 | `"getBranchNameFromRef: Filtered branch name: /refs/heads/cool_feature"`, 63 | ); 64 | }); 65 | 66 | it("should return the branch name for a valid branch ref without a leading slash", () => { 67 | const branchName = "cool_feature"; 68 | const ref = `refs/heads/${branchName}`; 69 | const branch = getBranchName(ref) as BranchNameResultUnion; 70 | 71 | // Behaviour 72 | expect(branch.isTag).toStrictEqual(false); 73 | expect(branch.branchName).toStrictEqual(branchName); 74 | expect(branch.ref).toStrictEqual(ref); 75 | 76 | // Logging 77 | assertOnlyCalled(coreDebugLogMock); 78 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 79 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 80 | `"getBranchNameFromRef: Filtered branch name: refs/heads/cool_feature"`, 81 | ); 82 | }); 83 | 84 | it("should return undefined for an invalid branch ref", () => { 85 | const ref = "refs/heads/"; 86 | const branch = getBranchName(ref) as BranchNameResultUnion; 87 | 88 | // Behaviour 89 | expect(branch.isTag).toStrictEqual(false); 90 | expect(branch.branchName).toBeUndefined(); 91 | expect(branch.ref).toStrictEqual(ref); 92 | 93 | // Logging 94 | assertOnlyCalled(coreDebugLogMock); 95 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 96 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 97 | `"getBranchName: failed to get branch for ref: refs/heads/, please raise an issue with this git ref."`, 98 | ); 99 | }); 100 | 101 | it("should return isTag true if the ref is for a tag", () => { 102 | const ref = "refs/tags/v1.0.1"; 103 | const branch = getBranchName(ref) as BranchNameResultUnion; 104 | 105 | // Behaviour 106 | expect(branch.isTag).toStrictEqual(true); 107 | expect(branch.branchName).toBeUndefined(); 108 | expect(branch.ref).toStrictEqual(ref); 109 | 110 | // Logging 111 | assertOnlyCalled(coreDebugLogMock); 112 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 113 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 114 | `"Unable to filter branch, unsupported ref: refs/tags/v1.0.1"`, 115 | ); 116 | }); 117 | 118 | it("should return isTag true if the ref is for an invalid tag", () => { 119 | const ref = "refs/tags/"; 120 | const branch = getBranchName(ref) as BranchNameResultUnion; 121 | 122 | // Behaviour 123 | expect(branch.isTag).toStrictEqual(true); 124 | expect(branch.branchName).toBeUndefined(); 125 | expect(branch.ref).toStrictEqual(ref); 126 | 127 | // Logging 128 | assertOnlyCalled(coreDebugLogMock); 129 | expect(coreDebugLogMock).toHaveBeenCalledOnce(); 130 | expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 131 | `"Unable to filter branch, unsupported ref: refs/tags/"`, 132 | ); 133 | }); 134 | }); 135 | 136 | describe("logInfoForBranchNameResult", () => { 137 | it("should log when finding a tag", () => { 138 | const ref = "refs/tags/v1.0.1"; 139 | const branch = getBranchName(ref); 140 | coreDebugLogMock.mockReset(); 141 | 142 | logInfoForBranchNameResult(branch, ref); 143 | 144 | // Logging 145 | assertOnlyCalled(coreInfoLogMock); 146 | expect(coreInfoLogMock).toHaveBeenCalledOnce(); 147 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 148 | `"Tag found for 'refs/tags/v1.0.1', branch filtering will not be used"`, 149 | ); 150 | }); 151 | 152 | it("should log when finding a branch", () => { 153 | const branchName = "cool_feature"; 154 | const ref = `/refs/heads/${branchName}`; 155 | const branch = getBranchName(ref); 156 | coreDebugLogMock.mockReset(); 157 | 158 | logInfoForBranchNameResult(branch, ref); 159 | 160 | // Logging 161 | assertOnlyCalled(coreInfoLogMock); 162 | expect(coreInfoLogMock).toHaveBeenCalledOnce(); 163 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 164 | `"Branch found for '/refs/heads/cool_feature': cool_feature"`, 165 | ); 166 | }); 167 | 168 | it("should log when nothing is found", () => { 169 | const ref = "refs/heads/"; 170 | const branch = getBranchName(ref); 171 | coreDebugLogMock.mockReset(); 172 | 173 | logInfoForBranchNameResult(branch, ref); 174 | 175 | // Logging 176 | assertOnlyCalled(coreInfoLogMock); 177 | expect(coreInfoLogMock).toHaveBeenCalledOnce(); 178 | expect(coreInfoLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 179 | `"Branch not found for 'refs/heads/', branch filtering will not be used"`, 180 | ); 181 | }); 182 | }); 183 | 184 | describe("sleep", () => { 185 | beforeAll(() => { 186 | vi.useFakeTimers(); 187 | }); 188 | 189 | afterAll(() => { 190 | vi.useRealTimers(); 191 | }); 192 | 193 | it("should sleep for n ms", async () => { 194 | const sleepTime = 1000; 195 | 196 | // This is more of a smoke test than anything else 197 | const sleepPromise = sleep(sleepTime); 198 | await vi.advanceTimersByTimeAsync(1000); 199 | 200 | await expect(sleepPromise).resolves.toBeUndefined(); 201 | 202 | assertNoneCalled(); 203 | }); 204 | }); 205 | 206 | describe("escapeRegExp", () => { 207 | const escaped = "\\^\\$\\.\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|\\\\"; 208 | const unescaped = "^$.*+?()[]{}|\\"; 209 | 210 | it("should escape values", () => { 211 | expect(escapeRegExp(unescaped + unescaped)).toBe(escaped + escaped); 212 | assertNoneCalled(); 213 | }); 214 | 215 | it("should handle strings with nothing to escape", () => { 216 | expect(escapeRegExp("abc")).toBe("abc"); 217 | assertNoneCalled(); 218 | }); 219 | 220 | it("should return an empty string for empty values", () => { 221 | expect(escapeRegExp("")).toEqual(""); 222 | assertNoneCalled(); 223 | }); 224 | }); 225 | 226 | describe("createDistinctIdRegex", () => { 227 | it("should return a regex without warning if the input is safe", () => { 228 | expect(createDistinctIdRegex("test-cfg")).toStrictEqual( 229 | new RegExp("test-cfg"), 230 | ); 231 | assertNoneCalled(); 232 | }); 233 | 234 | it("should return a regex with warning if the input is required escaping", () => { 235 | const input = "test$.*+?()[]{}|\\cfg"; 236 | const escapedInput = escapeRegExp(input); 237 | 238 | const distinctId = createDistinctIdRegex(input); 239 | 240 | // Behaviour 241 | expect(distinctId).toStrictEqual(new RegExp(escapedInput)); 242 | 243 | // Logging 244 | assertOnlyCalled(coreWarningLogMock); 245 | expect(coreWarningLogMock).toHaveBeenCalledOnce(); 246 | expect(coreWarningLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( 247 | `"Unescaped characters found in distinctId input, using: test\\$\\.\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|\\\\cfg"`, 248 | ); 249 | }); 250 | }); 251 | }); 252 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | 3 | function getBranchNameFromRef(ref: string): string | undefined { 4 | const refItems = ref.split(/\/?refs\/heads\//); 5 | if (refItems.length > 1 && (refItems[1]?.length ?? 0) > 0) { 6 | return refItems[1]; 7 | } 8 | } 9 | 10 | function isTagRef(ref: string): boolean { 11 | return new RegExp(/\/?refs\/tags\//).test(ref); 12 | } 13 | 14 | interface RefBranch { 15 | branchName?: string; 16 | isTag: false; 17 | ref: string; 18 | } 19 | 20 | interface RefTag { 21 | isTag: true; 22 | ref: string; 23 | } 24 | 25 | export type BranchNameResult = RefBranch | RefTag; 26 | 27 | export function getBranchName(ref: string): BranchNameResult { 28 | if (isTagRef(ref)) { 29 | core.debug(`Unable to filter branch, unsupported ref: ${ref}`); 30 | return { isTag: true, ref }; 31 | } 32 | 33 | /** 34 | * The listRepoWorkflows request only accepts a branch name and not a ref (for some reason). 35 | * 36 | * Attempt to filter the branch name specifically and use that. 37 | */ 38 | const branch = getBranchNameFromRef(ref); 39 | if (branch) { 40 | core.debug(`getBranchNameFromRef: Filtered branch name: ${ref}`); 41 | } else { 42 | core.debug( 43 | `getBranchName: failed to get branch for ref: ${ref}, please raise an issue with this git ref.`, 44 | ); 45 | } 46 | return { branchName: branch, isTag: false, ref }; 47 | } 48 | 49 | export function logInfoForBranchNameResult( 50 | branch: BranchNameResult, 51 | ref: string, 52 | ): void { 53 | if (branch.isTag) { 54 | core.info(`Tag found for '${ref}', branch filtering will not be used`); 55 | } else if (branch.branchName) { 56 | core.info(`Branch found for '${ref}': ${branch.branchName}`); 57 | } else { 58 | core.info( 59 | `Branch not found for '${ref}', branch filtering will not be used`, 60 | ); 61 | } 62 | } 63 | 64 | export function sleep(ms: number): Promise { 65 | return new Promise((resolve) => setTimeout(resolve, ms)); 66 | } 67 | 68 | /** 69 | * Used to match `RegExp` 70 | * [syntax characters](http://ecma-international.org/ecma-262/7.0/#sec-patterns). 71 | * 72 | * https://github.com/lodash/lodash/blob/main/src/escapeRegExp.ts 73 | */ 74 | const reRegExpChar = /[\\^$.*+?()[\]{}|]/g; 75 | const reHasRegExpChar = RegExp(reRegExpChar.source); 76 | 77 | /** 78 | * Escapes the `RegExp` special characters "^", "$", "\", ".", "*", "+", 79 | * "?", "(", ")", "[", "]", "{", "}", and "|" in `string`. 80 | * 81 | * https://github.com/lodash/lodash/blob/main/src/escapeRegExp.ts 82 | */ 83 | export function escapeRegExp(str: string): string { 84 | return reHasRegExpChar.test(str) 85 | ? str.replace(reRegExpChar, "\\$&") 86 | : str || ""; 87 | } 88 | 89 | /** 90 | * If the input distinct ID contains unescaped characters, log the 91 | * escaped distinct ID as a warning. 92 | */ 93 | export function createDistinctIdRegex(distinctId: string): RegExp { 94 | const escapedDistinctId = escapeRegExp(distinctId); 95 | if (distinctId !== escapedDistinctId) { 96 | core.warning( 97 | `Unescaped characters found in distinctId input, using: ${escapedDistinctId}`, 98 | ); 99 | } 100 | 101 | return new RegExp(escapedDistinctId); 102 | } 103 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2023", 4 | "module": "nodenext", 5 | "noEmit": true, 6 | 7 | "outDir": "lib", 8 | "rootDir": "src", 9 | 10 | "strict": true, 11 | 12 | "allowImportingTsExtensions": true, 13 | "esModuleInterop": true, 14 | "isolatedModules": true, 15 | "moduleDetection": "force", 16 | "moduleResolution": "nodenext", 17 | "noFallthroughCasesInSwitch": true, 18 | "noImplicitAny": true, 19 | "noUncheckedIndexedAccess": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "resolveJsonModule": true, 23 | "skipLibCheck": true, 24 | "useDefineForClassFields": true, 25 | "verbatimModuleSyntax": true 26 | }, 27 | "include": ["src"] 28 | } 29 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | coverage: { 7 | provider: "v8", 8 | reporter: ["text", "lcov"], 9 | include: ["src/**/*.ts"], 10 | exclude: ["src/**/*.spec.*", "src/test-utils/**/*.ts", "src/reset.d.ts"], 11 | }, 12 | isolate: true, 13 | }, 14 | }); 15 | --------------------------------------------------------------------------------