├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── renovate.json └── workflows │ ├── action.yml │ ├── codeql-analysis.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .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 ├── pnpm-workspace.yaml ├── src ├── action.spec.ts ├── action.ts ├── api.spec.ts ├── api.ts └── main.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 | "matchManagers": ["github-actions"], 15 | "automerge": true 16 | }, 17 | { 18 | "matchDepTypes": ["devDependencies"], 19 | "automerge": true 20 | }, 21 | { 22 | "matchDepTypes": ["dependencies"], 23 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 24 | "automerge": true 25 | } 26 | ], 27 | "vulnerabilityAlerts": { 28 | "labels": ["security"] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.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: Dispatch and return Run ID 14 | id: return_dispatch 15 | uses: Codex-/return-dispatch@main 16 | with: 17 | token: ${{ secrets.TOKEN }} 18 | ref: main 19 | repo: return-dispatch 20 | owner: codex- 21 | workflow: dispatch.yml 22 | workflow_inputs: '{"cake":"delicious"}' 23 | - name: Await Run ID ${{ steps.return_dispatch.outputs.run_id }} 24 | uses: ./ 25 | with: 26 | token: ${{ github.token }} 27 | repo: return-dispatch 28 | owner: codex- 29 | run_id: ${{ steps.return_dispatch.outputs.run_id }} 30 | -------------------------------------------------------------------------------- /.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/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.mjs` 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 | - run: pnpm run build:types 16 | - name: test 17 | id: test 18 | if: ${{ always() }} 19 | run: pnpm run test 20 | - name: lint 21 | if: ${{ always() }} 22 | run: pnpm run lint 23 | - name: style 24 | if: ${{ always() }} 25 | run: pnpm run format:check 26 | codecov: # Send only a single coverage report per run 27 | needs: [build] 28 | timeout-minutes: 15 29 | env: 30 | CI: true 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: jdx/mise-action@v2 35 | - run: pnpm i 36 | - name: test 37 | run: pnpm run test:coverage 38 | - name: codecov 39 | uses: codecov/codecov-action@v5 40 | env: 41 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 42 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | lib 3 | -------------------------------------------------------------------------------- /.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: await-remote-run 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/codex-/await-remote-run/test.yml?style=flat-square)](https://github.com/Codex-/await-remote-run/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-/await-remote-run?style=flat-square)](https://codecov.io/gh/Codex-/await-remote-run) [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-await–remote–run-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/await-remote-run) 4 | 5 | Await the completion of a foreign repository Workflow Run given the Run ID. 6 | 7 | This Action exists as a workaround for the issue where you cannot await the completion of a dispatched action. 8 | 9 | This action requires being able to get the run ID from a dispatched action, this can be achieved through another Action i've created, [return-dispatch](https://github.com/Codex-/return-dispatch). 10 | 11 | Should a remote workflow run fail, this action will attempt to output which step failed, with a link to the workflow run itself. 12 | 13 | An example using both of these actions is documented below. 14 | 15 | ## Usage 16 | 17 | Once you have configured your remote repository to work as expected with the `return-dispatch` action, include `await-remote-run` as described below. 18 | 19 | ```yaml 20 | steps: 21 | - name: Dispatch an action and get the run ID 22 | uses: codex-/return-dispatch@v1 23 | id: return_dispatch 24 | with: 25 | token: ${{ github.token }} 26 | repo: repository-name 27 | owner: repository-owner 28 | workflow: automation-test.yml 29 | - name: Await Run ID ${{ steps.return_dispatch.outputs.run_id }} 30 | uses: Codex-/await-remote-run@v1.0.0 31 | with: 32 | token: ${{ github.token }} 33 | repo: return-dispatch 34 | owner: codex- 35 | run_id: ${{ steps.return_dispatch.outputs.run_id }} 36 | run_timeout_seconds: 300 # Optional 37 | poll_interval_ms: 5000 # Optional 38 | ``` 39 | 40 | ### Permissions Required 41 | 42 | The permissions required for this action to function correctly are: 43 | 44 | - `repo` scope 45 | - You may get away with simply having `repo:public_repo` 46 | - `repo` is definitely needed if the repository is private. 47 | - `actions:read` 48 | 49 | ### APIs Used 50 | 51 | For the sake of transparency please note that this action uses the following API calls: 52 | 53 | - [Get a workflow run](https://docs.github.com/en/rest/reference/actions#get-a-workflow-run) 54 | - GET `/repos/{owner}/{repo}/actions/runs/{run_id}` 55 | - Permissions: 56 | - `repo` 57 | - `actions:read` 58 | - [List jobs for a workflow run](https://docs.github.com/en/rest/reference/actions#list-jobs-for-a-workflow-run) 59 | - GET `/repos/{owner}/{repo}/actions/runs/{run_id}/jobs` 60 | - Permissions: 61 | - `repo` 62 | - `actions:read` 63 | 64 | For more information please see [api.ts](./src/api.ts). 65 | 66 | ## Where does this help? 67 | 68 | If you want to use the result of a Workflow Run from a remote repository to complete a check locally, i.e. you have automated tests on another repository and don't want the local checks to pass if the remote fails. 69 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Await Remote Run 2 | description: Await a remote repository run to complete, returning a result. 3 | author: Alex Miller 4 | branding: 5 | icon: shield 6 | color: purple 7 | inputs: 8 | token: 9 | description: GitHub Personal Access Token for making API requests. 10 | required: true 11 | repo: 12 | description: Repository of the action to dispatch. 13 | required: true 14 | owner: 15 | description: Owner of the given repository. 16 | required: true 17 | run_id: 18 | description: Run ID to await the completion of. 19 | required: true 20 | run_timeout_seconds: 21 | description: Time until giving up on the run. 22 | default: 300 23 | poll_interval_ms: 24 | description: Frequency to poll the run for a status. 25 | default: 5000 26 | runs: 27 | using: node20 28 | main: dist/index.mjs 29 | -------------------------------------------------------------------------------- /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("await-remote-run")} 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 } from 'module';\n" + 24 | "const require = createRequire(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 { fixupPluginRules } from "@eslint/compat"; 4 | import { FlatCompat } from "@eslint/eslintrc"; 5 | import jsEslint from "@eslint/js"; 6 | import eslintConfigPrettier from "eslint-config-prettier"; 7 | import eslintPluginImportX from "eslint-plugin-import-x"; 8 | import * as tsEslint from "typescript-eslint"; 9 | 10 | const compat = new FlatCompat({ 11 | baseDirectory: import.meta.dirname, 12 | recommendedConfig: jsEslint.configs.recommended, 13 | allConfig: jsEslint.configs.all, 14 | }); 15 | 16 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 17 | /** 18 | * @param {string} name the pugin name 19 | * @param {string} alias the plugin alias 20 | * @returns {import("eslint").ESLint.Plugin} 21 | */ 22 | function legacyPlugin(name, alias = name) { 23 | const plugin = compat.plugins(name)[0]?.plugins?.[alias]; 24 | 25 | if (!plugin) { 26 | throw new Error(`Unable to resolve plugin ${name} and/or alias ${alias}`); 27 | } 28 | 29 | return fixupPluginRules(plugin); 30 | } 31 | /* eslint-enable @typescript-eslint/explicit-function-return-type */ 32 | 33 | export default tsEslint.config( 34 | jsEslint.configs.recommended, 35 | eslintPluginImportX.flatConfigs.recommended, 36 | eslintPluginImportX.flatConfigs.typescript, 37 | ...tsEslint.configs.strictTypeChecked, 38 | ...tsEslint.configs.stylisticTypeChecked, 39 | { 40 | languageOptions: { 41 | parserOptions: { 42 | projectService: { 43 | allowDefaultProject: ["*.js", "*.mjs"], 44 | }, 45 | tsconfigRootDir: import.meta.dirname, 46 | }, 47 | }, 48 | }, 49 | { 50 | ignores: [ 51 | "**/coverage", 52 | "**/dist", 53 | "**/esbuild.config.mjs", 54 | "**/vitest.config.ts", 55 | ], 56 | }, 57 | { 58 | plugins: { 59 | github: legacyPlugin("eslint-plugin-github", "github"), // pending https://github.com/github/eslint-plugin-github/issues/513 60 | import: legacyPlugin("eslint-plugin-import", "import"), // Needed for above 61 | }, 62 | rules: { 63 | "@typescript-eslint/await-thenable": "warn", 64 | "@typescript-eslint/explicit-function-return-type": "warn", 65 | "@typescript-eslint/no-explicit-any": "off", 66 | "@typescript-eslint/no-floating-promises": [ 67 | "warn", 68 | { ignoreIIFE: true, ignoreVoid: false }, 69 | ], 70 | "@typescript-eslint/no-shadow": "error", 71 | "@typescript-eslint/no-unused-vars": [ 72 | "warn", 73 | { argsIgnorePattern: "^_" }, 74 | ], 75 | "@typescript-eslint/restrict-template-expressions": [ 76 | "error", 77 | { 78 | allowNever: true, 79 | allowNumber: true, 80 | }, 81 | ], 82 | "github/array-foreach": "error", 83 | "github/no-implicit-buggy-globals": "error", 84 | "github/no-then": "error", 85 | "github/no-dynamic-script-tag": "error", 86 | "import/no-extraneous-dependencies": [ 87 | "error", 88 | { 89 | devDependencies: true, 90 | optionalDependencies: true, 91 | peerDependencies: true, 92 | }, 93 | ], 94 | "import/order": [ 95 | "warn", 96 | { "newlines-between": "always", alphabetize: { order: "asc" } }, 97 | ], 98 | "no-console": ["warn"], 99 | }, 100 | }, 101 | { 102 | files: ["**/*.spec.ts"], 103 | rules: { 104 | "@typescript-eslint/explicit-function-return-type": "off", 105 | "@typescript-eslint/no-non-null-assertion": "off", 106 | "@typescript-eslint/no-unsafe-assignment": "off", 107 | "@typescript-eslint/no-unsafe-member-access": "off", 108 | }, 109 | }, 110 | { 111 | files: ["**/*.js", "**/*.mjs"], 112 | ...tsEslint.configs.disableTypeChecked, 113 | }, 114 | eslintConfigPrettier, 115 | ); 116 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "20.19.2" 3 | pnpm = "10.11.1" 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "await-remote-run", 3 | "version": "1.12.3", 4 | "private": true, 5 | "description": "Await the result of a remote repository run.", 6 | "author": "Alex Miller", 7 | "license": "MIT", 8 | "type": "module", 9 | "scripts": { 10 | "build": "pnpm run build:types && pnpm run build:bundle", 11 | "build:bundle": "node ./esbuild.config.mjs", 12 | "build:types": "tsc", 13 | "format:check": "prettier --check **/*.ts", 14 | "format": "pnpm run format:check --write", 15 | "lint": "eslint .", 16 | "lint:fix": "pnpm run lint --fix", 17 | "release": "release-it", 18 | "test": "vitest", 19 | "test:coverage": "vitest --coverage" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/Codex-/await-remote-action.git" 24 | }, 25 | "keywords": [ 26 | "actions", 27 | "node", 28 | "setup" 29 | ], 30 | "dependencies": { 31 | "@actions/core": "^1.11.1", 32 | "@actions/github": "^6.0.1" 33 | }, 34 | "devDependencies": { 35 | "@eslint/compat": "^1.2.9", 36 | "@eslint/eslintrc": "^3.3.1", 37 | "@eslint/js": "^9.28.0", 38 | "@types/eslint__js": "^8.42.3", 39 | "@types/node": "^20.17.57", 40 | "@typescript-eslint/eslint-plugin": "^8.33.1", 41 | "@typescript-eslint/parser": "^8.33.1", 42 | "@vitest/coverage-v8": "^3.2.1", 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-github": "^5.1.8", 50 | "eslint-plugin-import": "^2.31.0", 51 | "eslint-plugin-import-x": "^4.15.0", 52 | "prettier": "3.5.3", 53 | "typescript": "^5.8.3", 54 | "typescript-eslint": "^8.33.1", 55 | "vitest": "^3.2.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - esbuild 3 | - unrs-resolver 4 | -------------------------------------------------------------------------------- /src/action.spec.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | 4 | import { type ActionConfig, getConfig } from "./action.ts"; 5 | 6 | vi.mock("@actions/core"); 7 | 8 | describe("Action", () => { 9 | describe("getConfig", () => { 10 | // Represent the process.env inputs. 11 | let mockEnvConfig: any; 12 | 13 | beforeEach(() => { 14 | mockEnvConfig = { 15 | token: "secret", 16 | repo: "repository", 17 | owner: "owner", 18 | run_id: "123456", 19 | run_timeout_seconds: "300", 20 | poll_interval_ms: "2500", 21 | }; 22 | 23 | vi.spyOn(core, "getInput").mockImplementation((input: string) => { 24 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 25 | switch (input) { 26 | case "token": 27 | return mockEnvConfig.token; 28 | case "repo": 29 | return mockEnvConfig.repo; 30 | case "owner": 31 | return mockEnvConfig.owner; 32 | case "run_id": 33 | return mockEnvConfig.run_id; 34 | case "run_timeout_seconds": 35 | return mockEnvConfig.run_timeout_seconds; 36 | case "poll_interval_ms": 37 | return mockEnvConfig.poll_interval_ms; 38 | default: 39 | throw new Error("invalid input requested"); 40 | } 41 | /* eslint-enable @typescript-eslint/no-unsafe-return */ 42 | }); 43 | }); 44 | 45 | afterEach(() => { 46 | vi.restoreAllMocks(); 47 | }); 48 | 49 | it("should return a valid config", () => { 50 | const config: ActionConfig = getConfig(); 51 | 52 | // Assert that the numbers / types have been properly loaded. 53 | expect(config.token).toStrictEqual("secret"); 54 | expect(config.repo).toStrictEqual("repository"); 55 | expect(config.owner).toStrictEqual("owner"); 56 | expect(config.runId).toStrictEqual(123456); 57 | expect(config.runTimeoutSeconds).toStrictEqual(300); 58 | expect(config.pollIntervalMs).toStrictEqual(2500); 59 | }); 60 | 61 | it("should provide a default run timeout if none is supplied", () => { 62 | mockEnvConfig.run_timeout_seconds = ""; 63 | const config: ActionConfig = getConfig(); 64 | 65 | expect(config.runTimeoutSeconds).toStrictEqual(300); 66 | }); 67 | 68 | it("should provide a default polling interval if none is supplied", () => { 69 | mockEnvConfig.poll_interval_ms = ""; 70 | const config: ActionConfig = getConfig(); 71 | 72 | expect(config.pollIntervalMs).toStrictEqual(5000); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/action.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | 3 | const RUN_TIMEOUT_SECONDS = 5 * 60; 4 | const POLL_INTERVAL_MS = 5000; 5 | 6 | /** 7 | * action.yaml definition. 8 | */ 9 | export interface ActionConfig { 10 | /** 11 | * GitHub API token for making requests. 12 | */ 13 | token: string; 14 | 15 | /** 16 | * Repository of the action to await. 17 | */ 18 | repo: string; 19 | 20 | /** 21 | * Owner of the given repository. 22 | */ 23 | owner: string; 24 | 25 | /** 26 | * Run ID to await the completion of. 27 | */ 28 | runId: number; 29 | 30 | /** 31 | * Time until giving up on the completion of an action. 32 | * @default 300 33 | */ 34 | runTimeoutSeconds: number; 35 | 36 | /** 37 | * Frequency to poll the action for a status. 38 | * @default 2500 39 | */ 40 | pollIntervalMs: number; 41 | } 42 | 43 | export function getConfig(): ActionConfig { 44 | return { 45 | token: core.getInput("token", { required: true }), 46 | repo: core.getInput("repo", { required: true }), 47 | owner: core.getInput("owner", { required: true }), 48 | runId: getRunIdFromValue(core.getInput("run_id")), 49 | runTimeoutSeconds: 50 | getNumberFromValue(core.getInput("run_timeout_seconds")) ?? 51 | RUN_TIMEOUT_SECONDS, 52 | pollIntervalMs: 53 | getNumberFromValue(core.getInput("poll_interval_ms")) ?? POLL_INTERVAL_MS, 54 | }; 55 | } 56 | 57 | function getRunIdFromValue(value: string): number { 58 | const id = getNumberFromValue(value); 59 | if (id === undefined) { 60 | throw new Error("Run ID must be provided."); 61 | } 62 | return id; 63 | } 64 | 65 | function getNumberFromValue(value: string): number | undefined { 66 | if (value === "") { 67 | return undefined; 68 | } 69 | 70 | try { 71 | const num = parseInt(value); 72 | 73 | if (isNaN(num)) { 74 | throw new Error("Parsed value is NaN"); 75 | } 76 | 77 | return num; 78 | } catch { 79 | throw new Error(`Unable to parse value: ${value}`); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/api.spec.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as github from "@actions/github"; 3 | import { 4 | afterEach, 5 | beforeEach, 6 | describe, 7 | expect, 8 | it, 9 | vi, 10 | type MockInstance, 11 | } from "vitest"; 12 | 13 | import { 14 | getWorkflowRunActiveJobUrl, 15 | getWorkflowRunActiveJobUrlRetry, 16 | getWorkflowRunFailedJobs, 17 | getWorkflowRunState, 18 | init, 19 | retryOnError, 20 | } from "./api.ts"; 21 | 22 | vi.mock("@actions/core"); 23 | vi.mock("@actions/github"); 24 | 25 | interface MockResponse { 26 | data: any; 27 | status: number; 28 | } 29 | 30 | const mockOctokit = { 31 | rest: { 32 | actions: { 33 | getWorkflowRun: (_req?: any): Promise => { 34 | throw new Error("Should be mocked"); 35 | }, 36 | listJobsForWorkflowRun: (_req?: any): Promise => { 37 | throw new Error("Should be mocked"); 38 | }, 39 | }, 40 | }, 41 | }; 42 | 43 | describe("API", () => { 44 | const cfg = { 45 | token: "secret", 46 | ref: "feature_branch", 47 | repo: "repository", 48 | owner: "owner", 49 | runId: 123456, 50 | runTimeoutSeconds: 300, 51 | pollIntervalMs: 2500, 52 | }; 53 | 54 | beforeEach(() => { 55 | vi.spyOn(core, "getInput").mockReturnValue(""); 56 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 57 | vi.spyOn(github, "getOctokit").mockReturnValue(mockOctokit as any); 58 | init(cfg); 59 | }); 60 | 61 | afterEach(() => { 62 | vi.restoreAllMocks(); 63 | }); 64 | 65 | describe("getWorkflowRunState", () => { 66 | it("should return the workflow run state for a given run ID", async () => { 67 | const mockData = { 68 | status: "completed", 69 | conclusion: "cancelled", 70 | }; 71 | vi.spyOn(mockOctokit.rest.actions, "getWorkflowRun").mockReturnValue( 72 | Promise.resolve({ 73 | data: mockData, 74 | status: 200, 75 | }), 76 | ); 77 | 78 | const state = await getWorkflowRunState(123456); 79 | expect(state.conclusion).toStrictEqual(mockData.conclusion); 80 | expect(state.status).toStrictEqual(mockData.status); 81 | }); 82 | 83 | it("should throw if a non-200 status is returned", async () => { 84 | const errorStatus = 401; 85 | vi.spyOn(mockOctokit.rest.actions, "getWorkflowRun").mockReturnValue( 86 | Promise.resolve({ 87 | data: undefined, 88 | status: errorStatus, 89 | }), 90 | ); 91 | 92 | await expect(getWorkflowRunState(0)).rejects.toThrow( 93 | `Failed to get Workflow Run state, expected 200 but received ${errorStatus}`, 94 | ); 95 | }); 96 | }); 97 | 98 | describe("getWorkflowRunJobs", () => { 99 | const mockData = { 100 | total_count: 1, 101 | jobs: [ 102 | { 103 | id: 123456789, 104 | html_url: "https://github.com/codex-/await-remote-run/runs/123456789", 105 | status: "completed", 106 | conclusion: "failure", 107 | name: "test-run", 108 | steps: [ 109 | { 110 | name: "Step 1", 111 | status: "completed", 112 | conclusion: "success", 113 | number: 1, 114 | }, 115 | { 116 | name: "Step 2", 117 | status: "completed", 118 | conclusion: "failure", 119 | number: 6, 120 | }, 121 | ], 122 | }, 123 | ], 124 | }; 125 | 126 | describe("getWorkflowRunFailedJobs", () => { 127 | it("should return the jobs for a failed workflow run given a run ID", async () => { 128 | vi.spyOn( 129 | mockOctokit.rest.actions, 130 | "listJobsForWorkflowRun", 131 | ).mockReturnValue( 132 | Promise.resolve({ 133 | data: mockData, 134 | status: 200, 135 | }), 136 | ); 137 | 138 | const jobs = await getWorkflowRunFailedJobs(123456); 139 | expect(jobs).toHaveLength(1); 140 | expect(jobs[0]?.id).toStrictEqual(mockData.jobs[0]?.id); 141 | expect(jobs[0]?.name).toStrictEqual(mockData.jobs[0]?.name); 142 | expect(jobs[0]?.status).toStrictEqual(mockData.jobs[0]?.status); 143 | expect(jobs[0]?.conclusion).toStrictEqual(mockData.jobs[0]?.conclusion); 144 | expect(jobs[0]?.url).toStrictEqual(mockData.jobs[0]?.html_url); 145 | expect(Array.isArray(jobs[0]?.steps)).toStrictEqual(true); 146 | }); 147 | 148 | it("should throw if a non-200 status is returned", async () => { 149 | const errorStatus = 401; 150 | vi.spyOn( 151 | mockOctokit.rest.actions, 152 | "listJobsForWorkflowRun", 153 | ).mockReturnValue( 154 | Promise.resolve({ 155 | data: undefined, 156 | status: errorStatus, 157 | }), 158 | ); 159 | 160 | await expect(getWorkflowRunFailedJobs(0)).rejects.toThrow( 161 | `Failed to get Jobs for Workflow Run, expected 200 but received ${errorStatus}`, 162 | ); 163 | }); 164 | 165 | it("should return the steps for a failed Job", async () => { 166 | const mockSteps = mockData.jobs[0]!.steps; 167 | vi.spyOn( 168 | mockOctokit.rest.actions, 169 | "listJobsForWorkflowRun", 170 | ).mockReturnValue( 171 | Promise.resolve({ 172 | data: mockData, 173 | status: 200, 174 | }), 175 | ); 176 | 177 | const { steps } = (await getWorkflowRunFailedJobs(123456))[0]!; 178 | expect(steps).toHaveLength(mockData.jobs[0]!.steps.length); 179 | for (let i = 0; i < mockSteps.length; i++) { 180 | expect(steps[i]?.name).toStrictEqual(mockSteps[i]?.name); 181 | expect(steps[i]?.number).toStrictEqual(mockSteps[i]?.number); 182 | expect(steps[i]?.status).toStrictEqual(mockSteps[i]?.status); 183 | expect(steps[i]?.conclusion).toStrictEqual(mockSteps[i]?.conclusion); 184 | } 185 | }); 186 | }); 187 | 188 | describe("getWorkflowRunActiveJobUrl", () => { 189 | let inProgressMockData: any; 190 | 191 | beforeEach(() => { 192 | inProgressMockData = { 193 | ...mockData, 194 | jobs: [ 195 | { 196 | ...mockData.jobs[0], 197 | status: "in_progress", 198 | conclusion: null, 199 | }, 200 | ], 201 | }; 202 | }); 203 | 204 | it("should return the url for an in_progress workflow run given a run ID", async () => { 205 | vi.spyOn( 206 | mockOctokit.rest.actions, 207 | "listJobsForWorkflowRun", 208 | ).mockReturnValue( 209 | Promise.resolve({ 210 | data: inProgressMockData, 211 | status: 200, 212 | }), 213 | ); 214 | 215 | const url = await getWorkflowRunActiveJobUrl(123456); 216 | expect(url).toStrictEqual(mockData.jobs[0]?.html_url); 217 | }); 218 | 219 | it("should return the url for an completed workflow run given a run ID", async () => { 220 | inProgressMockData.jobs[0].status = "completed"; 221 | 222 | vi.spyOn( 223 | mockOctokit.rest.actions, 224 | "listJobsForWorkflowRun", 225 | ).mockReturnValue( 226 | Promise.resolve({ 227 | data: inProgressMockData, 228 | status: 200, 229 | }), 230 | ); 231 | 232 | const url = await getWorkflowRunActiveJobUrl(123456); 233 | expect(url).toStrictEqual(mockData.jobs[0]?.html_url); 234 | }); 235 | 236 | it("should throw if a non-200 status is returned", async () => { 237 | const errorStatus = 401; 238 | vi.spyOn( 239 | mockOctokit.rest.actions, 240 | "listJobsForWorkflowRun", 241 | ).mockReturnValue( 242 | Promise.resolve({ 243 | data: undefined, 244 | status: errorStatus, 245 | }), 246 | ); 247 | 248 | await expect(getWorkflowRunActiveJobUrl(0)).rejects.toThrow( 249 | `Failed to get Jobs for Workflow Run, expected 200 but received ${errorStatus}`, 250 | ); 251 | }); 252 | 253 | it("should return undefined if no in_progress job is found", async () => { 254 | inProgressMockData.jobs[0].status = "unknown"; 255 | 256 | vi.spyOn( 257 | mockOctokit.rest.actions, 258 | "listJobsForWorkflowRun", 259 | ).mockReturnValue( 260 | Promise.resolve({ 261 | data: inProgressMockData, 262 | status: 200, 263 | }), 264 | ); 265 | 266 | const url = await getWorkflowRunActiveJobUrl(123456); 267 | expect(url).toStrictEqual(undefined); 268 | }); 269 | 270 | it("should return even if GitHub fails to return a URL", async () => { 271 | inProgressMockData.jobs[0].html_url = null; 272 | 273 | vi.spyOn( 274 | mockOctokit.rest.actions, 275 | "listJobsForWorkflowRun", 276 | ).mockReturnValue( 277 | Promise.resolve({ 278 | data: inProgressMockData, 279 | status: 200, 280 | }), 281 | ); 282 | 283 | const url = await getWorkflowRunActiveJobUrl(123456); 284 | expect(url).toStrictEqual("GitHub failed to return the URL"); 285 | }); 286 | 287 | describe("getWorkflowRunActiveJobUrlRetry", () => { 288 | beforeEach(() => { 289 | vi.useFakeTimers(); 290 | }); 291 | 292 | afterEach(() => { 293 | vi.useRealTimers(); 294 | }); 295 | 296 | it("should return a message if no job is found", async () => { 297 | inProgressMockData.jobs[0].status = "unknown"; 298 | 299 | vi.spyOn( 300 | mockOctokit.rest.actions, 301 | "listJobsForWorkflowRun", 302 | ).mockReturnValue( 303 | Promise.resolve({ 304 | data: inProgressMockData, 305 | status: 200, 306 | }), 307 | ); 308 | 309 | const urlPromise = getWorkflowRunActiveJobUrlRetry(123456, 100); 310 | vi.advanceTimersByTime(400); 311 | await vi.advanceTimersByTimeAsync(400); 312 | 313 | const url = await urlPromise; 314 | expect(url).toStrictEqual("Unable to fetch URL"); 315 | }); 316 | 317 | it("should return a message if no job is found within the timeout period", async () => { 318 | vi.spyOn(mockOctokit.rest.actions, "listJobsForWorkflowRun") 319 | // Final 320 | .mockImplementation(() => { 321 | inProgressMockData.jobs[0].status = "in_progress"; 322 | 323 | return Promise.resolve({ 324 | data: inProgressMockData, 325 | status: 200, 326 | }); 327 | }) 328 | // First 329 | .mockImplementationOnce(() => { 330 | inProgressMockData.jobs[0].status = "unknown"; 331 | 332 | return Promise.resolve({ 333 | data: inProgressMockData, 334 | status: 200, 335 | }); 336 | }) 337 | // Second 338 | .mockImplementationOnce(() => 339 | Promise.resolve({ 340 | data: inProgressMockData, 341 | status: 200, 342 | }), 343 | ); 344 | 345 | const urlPromise = getWorkflowRunActiveJobUrlRetry(123456, 200); 346 | vi.advanceTimersByTime(400); 347 | await vi.advanceTimersByTimeAsync(400); 348 | 349 | const url = await urlPromise; 350 | expect(url).toStrictEqual("Unable to fetch URL"); 351 | }); 352 | 353 | it("should return a URL if an in_progress job is found", async () => { 354 | vi.spyOn( 355 | mockOctokit.rest.actions, 356 | "listJobsForWorkflowRun", 357 | ).mockImplementation(() => 358 | Promise.resolve({ 359 | data: inProgressMockData, 360 | status: 200, 361 | }), 362 | ); 363 | 364 | const urlPromise = getWorkflowRunActiveJobUrlRetry(123456, 200); 365 | vi.advanceTimersByTime(400); 366 | await vi.advanceTimersByTimeAsync(400); 367 | 368 | const url = await urlPromise; 369 | expect(url).toStrictEqual(inProgressMockData.jobs[0]?.html_url); 370 | }); 371 | }); 372 | }); 373 | }); 374 | 375 | describe("retryOnError", () => { 376 | let warningLogSpy: MockInstance; 377 | 378 | beforeEach(() => { 379 | vi.useFakeTimers(); 380 | warningLogSpy = vi.spyOn(core, "warning"); 381 | }); 382 | 383 | afterEach(() => { 384 | vi.useRealTimers(); 385 | warningLogSpy.mockRestore(); 386 | }); 387 | 388 | it("should retry a function if it throws an error", async () => { 389 | const funcName = "testFunc"; 390 | const errorMsg = "some error"; 391 | const testFunc = vi 392 | .fn<() => Promise>() 393 | .mockImplementation(() => Promise.resolve("completed")) 394 | .mockImplementationOnce(() => Promise.reject(Error(errorMsg))); 395 | 396 | const retryPromise = retryOnError(() => testFunc(), funcName); 397 | 398 | // Progress timers to first failure 399 | vi.advanceTimersByTime(500); 400 | await vi.advanceTimersByTimeAsync(500); 401 | 402 | expect(warningLogSpy).toHaveBeenCalledOnce(); 403 | expect(warningLogSpy).toHaveBeenCalledWith( 404 | "retryOnError: An unexpected error has occurred:\n" + 405 | ` name: ${funcName}\n` + 406 | ` error: ${errorMsg}`, 407 | ); 408 | 409 | // Progress timers to second success 410 | vi.advanceTimersByTime(500); 411 | await vi.advanceTimersByTimeAsync(500); 412 | const result = await retryPromise; 413 | 414 | expect(warningLogSpy).toHaveBeenCalledOnce(); 415 | expect(result).toStrictEqual("completed"); 416 | }); 417 | 418 | it("should throw the original error if timed out while calling the function", async () => { 419 | const funcName = "testFunc"; 420 | const errorMsg = "some error"; 421 | const testFunc = vi 422 | .fn<() => Promise>() 423 | .mockImplementation(async () => { 424 | await new Promise((resolve) => setTimeout(resolve, 1000)); 425 | throw new Error(errorMsg); 426 | }); 427 | 428 | const retryPromise = retryOnError(() => testFunc(), funcName, 500); 429 | 430 | vi.advanceTimersByTime(500); 431 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 432 | vi.advanceTimersByTimeAsync(500); 433 | 434 | await expect(retryPromise).rejects.toThrowError("some error"); 435 | }); 436 | }); 437 | }); 438 | -------------------------------------------------------------------------------- /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 | 6 | type Octokit = ReturnType<(typeof github)["getOctokit"]>; 7 | 8 | let config: ActionConfig; 9 | let octokit: Octokit; 10 | 11 | export enum WorkflowRunStatus { 12 | Queued = "queued", 13 | InProgress = "in_progress", 14 | Completed = "completed", 15 | } 16 | 17 | export enum WorkflowRunConclusion { 18 | Success = "success", 19 | Failure = "failure", 20 | Neutral = "neutral", 21 | Cancelled = "cancelled", 22 | Skipped = "skipped", 23 | TimedOut = "timed_out", 24 | ActionRequired = "action_required", 25 | } 26 | 27 | export function init(cfg?: ActionConfig): void { 28 | config = cfg ?? getConfig(); 29 | octokit = github.getOctokit(config.token); 30 | } 31 | 32 | export interface WorkflowRunState { 33 | status: WorkflowRunStatus | null; 34 | conclusion: WorkflowRunConclusion | null; 35 | } 36 | 37 | export async function getWorkflowRunState( 38 | runId: number, 39 | ): Promise { 40 | try { 41 | // https://docs.github.com/en/rest/reference/actions#get-a-workflow-run 42 | const response = await octokit.rest.actions.getWorkflowRun({ 43 | owner: config.owner, 44 | repo: config.repo, 45 | run_id: runId, 46 | }); 47 | 48 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 49 | if (response.status !== 200) { 50 | throw new Error( 51 | `Failed to get Workflow Run state, expected 200 but received ${response.status}`, 52 | ); 53 | } 54 | 55 | core.debug( 56 | `Fetched Run:\n` + 57 | ` Repository: ${config.owner}/${config.repo}\n` + 58 | ` Run ID: ${runId}\n` + 59 | ` Status: ${response.data.status}\n` + 60 | ` Conclusion: ${response.data.conclusion}`, 61 | ); 62 | 63 | return { 64 | status: response.data.status as WorkflowRunStatus | null, 65 | conclusion: response.data.conclusion as WorkflowRunConclusion | null, 66 | }; 67 | } catch (error) { 68 | if (error instanceof Error) { 69 | core.error( 70 | `getWorkflowRunState: An unexpected error has occurred: ${error.message}`, 71 | ); 72 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 73 | error.stack && core.debug(error.stack); 74 | } 75 | throw error; 76 | } 77 | } 78 | 79 | export interface WorkflowRunJob { 80 | id: number; 81 | name: string; 82 | status: "queued" | "in_progress" | "completed" | "waiting"; 83 | conclusion: string | null; 84 | steps: WorkflowRunJobStep[]; 85 | url: string | null; 86 | } 87 | 88 | export interface WorkflowRunJobStep { 89 | name: string; 90 | status: string; 91 | conclusion: string | null; 92 | number: number; 93 | } 94 | 95 | type Awaited = T extends PromiseLike ? Awaited : T; 96 | type ListJobsForWorkflowRunResponse = Awaited< 97 | ReturnType 98 | >; 99 | 100 | async function getWorkflowRunJobs( 101 | runId: number, 102 | ): Promise { 103 | // https://docs.github.com/en/rest/reference/actions#list-jobs-for-a-workflow-run 104 | const response = await octokit.rest.actions.listJobsForWorkflowRun({ 105 | owner: config.owner, 106 | repo: config.repo, 107 | run_id: runId, 108 | filter: "latest", 109 | }); 110 | 111 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 112 | if (response.status !== 200) { 113 | throw new Error( 114 | `Failed to get Jobs for Workflow Run, expected 200 but received ${response.status}`, 115 | ); 116 | } 117 | 118 | return response; 119 | } 120 | 121 | export async function getWorkflowRunFailedJobs( 122 | runId: number, 123 | ): Promise { 124 | try { 125 | const response = await getWorkflowRunJobs(runId); 126 | const fetchedFailedJobs = response.data.jobs.filter( 127 | (job) => job.conclusion === "failure", 128 | ); 129 | 130 | if (fetchedFailedJobs.length <= 0) { 131 | core.warning(`Failed to find failed Jobs for Workflow Run ${runId}`); 132 | return []; 133 | } 134 | 135 | const jobs: WorkflowRunJob[] = fetchedFailedJobs.map((job) => { 136 | const steps = job.steps?.map((step) => ({ 137 | name: step.name, 138 | status: step.status, 139 | conclusion: step.conclusion, 140 | number: step.number, 141 | })); 142 | 143 | return { 144 | id: job.id, 145 | name: job.name, 146 | status: job.status, 147 | conclusion: job.conclusion, 148 | steps: steps ?? [], 149 | url: job.html_url, 150 | }; 151 | }); 152 | 153 | const runJobs = jobs.map((job) => job.name); 154 | core.debug( 155 | `Fetched Jobs for Run:\n` + 156 | ` Repository: ${config.owner}/${config.repo}\n` + 157 | ` Run ID: ${config.runId}\n` + 158 | ` Jobs: [${runJobs.join(", ")}]`, 159 | ); 160 | 161 | for (const job of jobs) { 162 | const steps = job.steps.map((step) => `${step.number}: ${step.name}`); 163 | core.debug( 164 | ` Job: ${job.name}\n` + 165 | ` ID: ${job.id}\n` + 166 | ` Status: ${job.status}\n` + 167 | ` Conclusion: ${job.conclusion}\n` + 168 | ` Steps: [${steps.join(", ")}]`, 169 | ); 170 | } 171 | 172 | return jobs; 173 | } catch (error) { 174 | if (error instanceof Error) { 175 | core.error( 176 | `getWorkflowRunJobFailures: An unexpected error has occurred: ${error.message}`, 177 | ); 178 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 179 | error.stack && core.debug(error.stack); 180 | } 181 | throw error; 182 | } 183 | } 184 | 185 | export async function getWorkflowRunActiveJobUrl( 186 | runId: number, 187 | ): Promise { 188 | try { 189 | const response = await getWorkflowRunJobs(runId); 190 | const fetchedInProgressJobs = response.data.jobs.filter( 191 | (job) => job.status === "in_progress" || job.status === "completed", 192 | ); 193 | 194 | const inProgressJobs = fetchedInProgressJobs.map( 195 | (job) => `${job.name} (${job.status})`, 196 | ); 197 | core.debug( 198 | `Fetched Jobs for Run:\n` + 199 | ` Repository: ${config.owner}/${config.repo}\n` + 200 | ` Run ID: ${config.runId}\n` + 201 | ` Jobs: [${inProgressJobs.join(", ")}]`, 202 | ); 203 | 204 | if (fetchedInProgressJobs.length <= 0) { 205 | return undefined; 206 | } 207 | 208 | return ( 209 | fetchedInProgressJobs[0]?.html_url ?? "GitHub failed to return the URL" 210 | ); 211 | } catch (error) { 212 | if (error instanceof Error) { 213 | core.error( 214 | `getWorkflowRunActiveJobUrl: An unexpected error has occurred: ${error.message}`, 215 | ); 216 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 217 | error.stack && core.debug(error.stack); 218 | } 219 | throw error; 220 | } 221 | } 222 | 223 | export async function getWorkflowRunActiveJobUrlRetry( 224 | runId: number, 225 | timeout: number, 226 | ): Promise { 227 | const startTime = Date.now(); 228 | let elapsedTime = Date.now() - startTime; 229 | 230 | while (elapsedTime < timeout) { 231 | elapsedTime = Date.now() - startTime; 232 | core.debug( 233 | `No 'in_progress' or 'completed' Jobs found for Workflow Run ${runId}, retrying...`, 234 | ); 235 | 236 | const url = await getWorkflowRunActiveJobUrl(runId); 237 | if (url) { 238 | return url; 239 | } 240 | 241 | await new Promise((resolve) => setTimeout(resolve, 200)); 242 | } 243 | core.debug(`Timed out while trying to fetch URL for Workflow Run ${runId}`); 244 | 245 | return "Unable to fetch URL"; 246 | } 247 | 248 | export async function retryOnError( 249 | func: () => Promise, 250 | name: string, 251 | timeout = 5000, 252 | ): Promise { 253 | const startTime = Date.now(); 254 | let elapsedTime = Date.now() - startTime; 255 | 256 | while (elapsedTime < timeout) { 257 | elapsedTime = Date.now() - startTime; 258 | try { 259 | return await func(); 260 | } catch (error) { 261 | if (error instanceof Error) { 262 | // We now exceed the time, so throw the error up 263 | if (Date.now() - startTime >= timeout) { 264 | throw error; 265 | } 266 | 267 | core.warning( 268 | "retryOnError: An unexpected error has occurred:\n" + 269 | ` name: ${name}\n` + 270 | ` error: ${error.message}`, 271 | ); 272 | } 273 | await new Promise((resolve) => setTimeout(resolve, 1000)); 274 | } 275 | } 276 | 277 | throw new Error(`Timeout exceeded while attempting to retry ${name}`); 278 | } 279 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | 3 | import { getConfig } from "./action.ts"; 4 | import { 5 | getWorkflowRunActiveJobUrlRetry, 6 | getWorkflowRunFailedJobs, 7 | getWorkflowRunState, 8 | init, 9 | retryOnError, 10 | WorkflowRunConclusion, 11 | WorkflowRunStatus, 12 | } from "./api.ts"; 13 | 14 | async function logFailureDetails(runId: number): Promise { 15 | const failedJobs = await getWorkflowRunFailedJobs(runId); 16 | for (const failedJob of failedJobs) { 17 | const failedSteps = failedJob.steps 18 | .filter((step) => step.conclusion !== "success") 19 | .map((step) => { 20 | return ( 21 | ` ${step.number}: ${step.name}\n` + 22 | ` Status: ${step.status}\n` + 23 | ` Conclusion: ${step.conclusion}` 24 | ); 25 | }) 26 | .join("\n"); 27 | core.error( 28 | `Job ${failedJob.name}:\n` + 29 | ` ID: ${failedJob.id}\n` + 30 | ` Status: ${failedJob.status}\n` + 31 | ` Conclusion: ${failedJob.conclusion}\n` + 32 | ` URL: ${failedJob.url}\n` + 33 | ` Steps (non-success):\n` + 34 | failedSteps, 35 | ); 36 | } 37 | } 38 | 39 | async function run(): Promise { 40 | try { 41 | const config = getConfig(); 42 | const startTime = Date.now(); 43 | init(config); 44 | 45 | const timeoutMs = config.runTimeoutSeconds * 1000; 46 | let attemptNo = 0; 47 | let elapsedTime = Date.now() - startTime; 48 | 49 | core.info( 50 | `Awaiting completion of Workflow Run ${config.runId}...\n` + 51 | ` ID: ${config.runId}\n` + 52 | ` URL: ${await getWorkflowRunActiveJobUrlRetry(config.runId, 1000)}`, 53 | ); 54 | 55 | while (elapsedTime < timeoutMs) { 56 | attemptNo++; 57 | elapsedTime = Date.now() - startTime; 58 | 59 | const { status, conclusion } = await retryOnError( 60 | async () => getWorkflowRunState(config.runId), 61 | "getWorkflowRunState", 62 | 400, 63 | ); 64 | 65 | if (status === WorkflowRunStatus.Completed) { 66 | switch (conclusion) { 67 | case WorkflowRunConclusion.Success: 68 | core.info( 69 | "Run Completed:\n" + 70 | ` Run ID: ${config.runId}\n` + 71 | ` Status: ${status}\n` + 72 | ` Conclusion: ${conclusion}`, 73 | ); 74 | return; 75 | case WorkflowRunConclusion.ActionRequired: 76 | case WorkflowRunConclusion.Cancelled: 77 | case WorkflowRunConclusion.Failure: 78 | case WorkflowRunConclusion.Neutral: 79 | case WorkflowRunConclusion.Skipped: 80 | case WorkflowRunConclusion.TimedOut: 81 | core.error(`Run has failed with conclusion: ${conclusion}`); 82 | await logFailureDetails(config.runId); 83 | core.setFailed(conclusion); 84 | return; 85 | default: 86 | core.setFailed(`Unknown conclusion: ${conclusion}`); 87 | return; 88 | } 89 | } 90 | 91 | core.debug(`Run has not concluded, attempt ${attemptNo}...`); 92 | 93 | await new Promise((resolve) => 94 | setTimeout(resolve, config.pollIntervalMs), 95 | ); 96 | } 97 | 98 | throw new Error( 99 | `Timeout exceeded while awaiting completion of Run ${config.runId}`, 100 | ); 101 | } catch (error) { 102 | if (error instanceof Error) { 103 | core.error(`Failed to complete: ${error.message}`); 104 | if (!error.message.includes("Timeout")) { 105 | core.warning("Does the token have the correct permissions?"); 106 | } 107 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 108 | error.stack && core.debug(error.stack); 109 | core.setFailed(error.message); 110 | } 111 | } 112 | } 113 | 114 | ((): Promise => run())(); 115 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 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 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | provider: "v8", 7 | reporter: ["text", "lcov"], 8 | include: ["src/**/*.ts"], 9 | }, 10 | }, 11 | }); 12 | --------------------------------------------------------------------------------