├── .config ├── .commitlintrc.js ├── .eslintignore ├── .eslintrc.js ├── .prettierignore ├── .prettierrc.yml └── jest.config.js ├── .github ├── ISSUE_TEMPLATE │ └── bug-report.md ├── codecov.yml ├── pull_request_template.md ├── scripts │ └── log-examples.js └── workflows │ └── ci_cd.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .releaserc.js ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── action.yml ├── dist └── index.js ├── package-lock.json ├── package.json ├── sample.env ├── src ├── index.ts ├── inputs.ts ├── util.test.ts └── util.ts ├── test-data └── large-output │ ├── Makefile │ └── kibibyte.txt └── tsconfig.json /.config/.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [ 5 | 2, 6 | 'always', 7 | ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'revert', 'patch', 'minor', 'major'], 8 | ], 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.config/.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js -------------------------------------------------------------------------------- /.config/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 6 | ignorePatterns: ['**/*.js', 'dist/'], 7 | }; 8 | -------------------------------------------------------------------------------- /.config/.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ -------------------------------------------------------------------------------- /.config/.prettierrc.yml: -------------------------------------------------------------------------------- 1 | trailingComma: 'es5' 2 | tabWidth: 2 3 | semi: true 4 | singleQuote: true 5 | printWidth: 100 6 | -------------------------------------------------------------------------------- /.config/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | rootDir: '..', 5 | testEnvironment: 'node', 6 | testMatch: ['/src/**/*.test.ts'], 7 | transform: { 8 | '^.+\\.ts$': 'ts-jest', 9 | }, 10 | verbose: true, 11 | collectCoverage: true, 12 | collectCoverageFrom: ['src/**/*.{js,ts,jsx,tsx}'], 13 | }; 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: nick-fields 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is, **including the snippet from your workflow `yaml` showing your configuration and command being executed.** 11 | 12 | **Expected behavior** 13 | A clear and concise description of what you expected to happen. 14 | 15 | **Screenshots** 16 | If applicable, add screenshots to help explain your problem. 17 | 18 | **Logs** 19 | Enable [debug logging](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging#enabling-step-debug-logging) then attach the [raw logs](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/using-workflow-run-logs#downloading-logs) (specifically the raw output of this action). 20 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | # see https://docs.codecov.com/docs/codecovyml-reference 2 | codecov: 3 | require_ci_to_pass: false 4 | comment: 5 | layout: 'diff, flags' 6 | behavior: default 7 | require_changes: true 8 | coverage: 9 | # don't pass/fail PRs for coverage yet 10 | status: 11 | project: off 12 | patch: off 13 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | _Replace the bullet points below with your answers_ 2 | 3 | ### Description 4 | 5 | - What change is being made and why? 6 | 7 | ### Testing 8 | 9 | - What tests were added? 10 | - These can be either ["integration tests"](./workflows/ci_cd.yml) or unit tests 11 | -------------------------------------------------------------------------------- /.github/scripts/log-examples.js: -------------------------------------------------------------------------------- 1 | console.log('console.log test'); 2 | console.warn('console.warn test'); 3 | console.error('console.error test'); 4 | process.stdout.write('stdout test'); 5 | process.stderr.write('stderr test'); 6 | -------------------------------------------------------------------------------- /.github/workflows/ci_cd.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | on: 3 | # only on PRs into and merge to default branch 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | ci_unit: 13 | name: Run Unit Tests 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | - name: Install dependencies 23 | run: npm ci 24 | - name: Run Unit Tests 25 | run: npm test 26 | - uses: codecov/codecov-action@v5 27 | with: 28 | directory: ./coverage/ 29 | verbose: true 30 | 31 | ci_integration: 32 | name: Run Integration Tests 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Checkout 36 | uses: actions/checkout@v4 37 | - name: Setup Node.js 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: 20 41 | - name: Install dependencies 42 | run: npm ci 43 | 44 | - name: happy-path 45 | id: happy_path 46 | uses: ./ 47 | with: 48 | timeout_minutes: 1 49 | max_attempts: 2 50 | command: npm -v 51 | - uses: nick-fields/assert-action@v2 52 | with: 53 | expected: true 54 | actual: ${{ steps.happy_path.outputs.total_attempts == '1' && steps.happy_path.outputs.exit_code == '0' }} 55 | 56 | - name: log examples 57 | uses: ./ 58 | with: 59 | command: node ./.github/scripts/log-examples.js 60 | timeout_minutes: 1 61 | 62 | - name: sad-path (error) 63 | id: sad_path_error 64 | uses: ./ 65 | continue-on-error: true 66 | with: 67 | timeout_minutes: 1 68 | max_attempts: 2 69 | command: node -e "process.exit(1)" 70 | - uses: nick-fields/assert-action@v2 71 | with: 72 | expected: 2 73 | actual: ${{ steps.sad_path_error.outputs.total_attempts }} 74 | - uses: nick-fields/assert-action@v2 75 | with: 76 | expected: failure 77 | actual: ${{ steps.sad_path_error.outcome }} 78 | 79 | - name: retry_on (timeout) fails early if error encountered 80 | id: retry_on_timeout_fail 81 | uses: ./ 82 | continue-on-error: true 83 | with: 84 | timeout_minutes: 1 85 | max_attempts: 3 86 | retry_on: timeout 87 | command: node -e "process.exit(2)" 88 | - uses: nick-fields/assert-action@v2 89 | with: 90 | expected: 1 91 | actual: ${{ steps.retry_on_timeout_fail.outputs.total_attempts }} 92 | - uses: nick-fields/assert-action@v2 93 | with: 94 | expected: failure 95 | actual: ${{ steps.retry_on_timeout_fail.outcome }} 96 | - uses: nick-fields/assert-action@v2 97 | with: 98 | expected: 2 99 | actual: ${{ steps.retry_on_timeout_fail.outputs.exit_code }} 100 | 101 | - name: retry_on (error) 102 | id: retry_on_error 103 | uses: ./ 104 | continue-on-error: true 105 | with: 106 | timeout_minutes: 1 107 | max_attempts: 2 108 | retry_on: error 109 | command: node -e "process.exit(2)" 110 | - uses: nick-fields/assert-action@v2 111 | with: 112 | expected: 2 113 | actual: ${{ steps.retry_on_error.outputs.total_attempts }} 114 | - uses: nick-fields/assert-action@v2 115 | with: 116 | expected: failure 117 | actual: ${{ steps.retry_on_error.outcome }} 118 | - uses: nick-fields/assert-action@v2 119 | with: 120 | expected: 2 121 | actual: ${{ steps.retry_on_error.outputs.exit_code }} 122 | 123 | - name: sad-path (wrong shell for OS) 124 | id: wrong_shell 125 | uses: ./ 126 | continue-on-error: true 127 | with: 128 | timeout_minutes: 1 129 | max_attempts: 2 130 | shell: cmd 131 | command: 'dir' 132 | - uses: nick-fields/assert-action@v2 133 | with: 134 | expected: 2 135 | actual: ${{ steps.wrong_shell.outputs.total_attempts }} 136 | - uses: nick-fields/assert-action@v2 137 | with: 138 | expected: failure 139 | actual: ${{ steps.wrong_shell.outcome }} 140 | 141 | ci_integration_envvar: 142 | name: Run Integration Env Var Tests 143 | runs-on: ubuntu-latest 144 | steps: 145 | - name: Checkout 146 | uses: actions/checkout@v4 147 | - name: Setup Node.js 148 | uses: actions/setup-node@v4 149 | with: 150 | node-version: 20 151 | - name: Install dependencies 152 | run: npm ci 153 | - name: env-vars-passed-through 154 | uses: ./ 155 | env: 156 | NODE_OPTIONS: '--max_old_space_size=3072' 157 | with: 158 | timeout_minutes: 1 159 | max_attempts: 2 160 | command: node -e 'console.log(process.env.NODE_OPTIONS)' 161 | 162 | ci_integration_large_output: 163 | name: Run Integration Large Output Tests 164 | runs-on: ubuntu-latest 165 | steps: 166 | - name: Checkout 167 | uses: actions/checkout@v4 168 | - name: Setup Node.js 169 | uses: actions/setup-node@v4 170 | with: 171 | node-version: 20 172 | - name: Install dependencies 173 | run: npm ci 174 | - name: Test 100MiB of output can be processed 175 | id: large-output 176 | continue-on-error: true 177 | uses: ./ 178 | with: 179 | max_attempts: 1 180 | timeout_minutes: 5 181 | command: 'make -C ./test-data/large-output bytes-102400' 182 | - name: Assert test had expected result 183 | uses: nick-fields/assert-action@v2 184 | with: 185 | expected: failure 186 | actual: ${{ steps.large-output.outcome }} 187 | - name: Assert exit code is expected 188 | uses: nick-fields/assert-action@v2 189 | with: 190 | expected: 2 191 | actual: ${{ steps.large-output.outputs.exit_code }} 192 | 193 | ci_integration_retry_on_exit_code: 194 | name: Run Integration retry_on_exit_code Tests 195 | runs-on: ubuntu-latest 196 | steps: 197 | - name: Checkout 198 | uses: actions/checkout@v4 199 | - name: Setup Node.js 200 | uses: actions/setup-node@v4 201 | with: 202 | node-version: 20 203 | - name: Install dependencies 204 | run: npm ci 205 | - name: retry_on_exit_code (with expected error code) 206 | id: retry_on_exit_code_expected 207 | uses: ./ 208 | continue-on-error: true 209 | with: 210 | timeout_minutes: 1 211 | retry_on_exit_code: 2 212 | max_attempts: 3 213 | command: node -e "process.exit(2)" 214 | - uses: nick-fields/assert-action@v2 215 | with: 216 | expected: failure 217 | actual: ${{ steps.retry_on_exit_code_expected.outcome }} 218 | - uses: nick-fields/assert-action@v2 219 | with: 220 | expected: 3 221 | actual: ${{ steps.retry_on_exit_code_expected.outputs.total_attempts }} 222 | 223 | - name: retry_on_exit_code (with unexpected error code) 224 | id: retry_on_exit_code_unexpected 225 | uses: ./ 226 | continue-on-error: true 227 | with: 228 | timeout_minutes: 1 229 | retry_on_exit_code: 2 230 | max_attempts: 3 231 | command: node -e "process.exit(1)" 232 | - uses: nick-fields/assert-action@v2 233 | with: 234 | expected: failure 235 | actual: ${{ steps.retry_on_exit_code_unexpected.outcome }} 236 | - uses: nick-fields/assert-action@v2 237 | with: 238 | expected: 1 239 | actual: ${{ steps.retry_on_exit_code_unexpected.outputs.total_attempts }} 240 | 241 | ci_integration_continue_on_error: 242 | name: Run Integration continue_on_error Tests 243 | runs-on: ubuntu-latest 244 | steps: 245 | - name: Checkout 246 | uses: actions/checkout@v4 247 | - name: Setup Node.js 248 | uses: actions/setup-node@v4 249 | with: 250 | node-version: 20 251 | - name: Install dependencies 252 | run: npm ci 253 | - name: happy-path (continue_on_error) 254 | id: happy_path_continue_on_error 255 | uses: ./ 256 | with: 257 | command: node -e "process.exit(0)" 258 | timeout_minutes: 1 259 | continue_on_error: true 260 | - name: sad-path (continue_on_error) 261 | id: sad_path_continue_on_error 262 | uses: ./ 263 | with: 264 | command: node -e "process.exit(33)" 265 | timeout_minutes: 1 266 | continue_on_error: true 267 | - name: Verify continue_on_error returns correct exit code on success 268 | uses: nick-fields/assert-action@v2 269 | with: 270 | expected: 0 271 | actual: ${{ steps.happy_path_continue_on_error.outputs.exit_code }} 272 | - name: Verify continue_on_error exits with correct outcome on success 273 | uses: nick-fields/assert-action@v2 274 | with: 275 | expected: success 276 | actual: ${{ steps.happy_path_continue_on_error.outcome }} 277 | - name: Verify continue_on_error returns correct exit code on error 278 | uses: nick-fields/assert-action@v2 279 | with: 280 | expected: 33 281 | actual: ${{ steps.sad_path_continue_on_error.outputs.exit_code }} 282 | - name: Verify continue_on_error exits with successful outcome when an error occurs 283 | uses: nick-fields/assert-action@v2 284 | with: 285 | expected: success 286 | actual: ${{ steps.sad_path_continue_on_error.outcome }} 287 | 288 | ci_integration_retry_wait_seconds: 289 | name: Run Integration Tests (retry_wait_seconds) 290 | runs-on: ubuntu-latest 291 | steps: 292 | - name: Checkout 293 | uses: actions/checkout@v4 294 | - name: Setup Node.js 295 | uses: actions/setup-node@v4 296 | with: 297 | node-version: 20 298 | - name: Install dependencies 299 | run: npm ci 300 | 301 | - name: sad-path (retry_wait_seconds) 302 | id: sad_path_wait_sec 303 | uses: ./ 304 | continue-on-error: true 305 | with: 306 | timeout_minutes: 1 307 | max_attempts: 3 308 | retry_wait_seconds: 15 309 | command: npm install this-isnt-a-real-package-name-zzz 310 | - uses: nick-fields/assert-action@v2 311 | with: 312 | expected: 3 313 | actual: ${{ steps.sad_path_wait_sec.outputs.total_attempts }} 314 | - uses: nick-fields/assert-action@v2 315 | with: 316 | expected: failure 317 | actual: ${{ steps.sad_path_wait_sec.outcome }} 318 | - uses: nick-fields/assert-action@v2 319 | with: 320 | expected: 'Final attempt failed' 321 | actual: ${{ steps.sad_path_wait_sec.outputs.exit_error }} 322 | comparison: contains 323 | 324 | ci_integration_on_retry_cmd: 325 | name: Run Integration Tests (on_retry_command) 326 | runs-on: ubuntu-latest 327 | steps: 328 | - name: Checkout 329 | uses: actions/checkout@v4 330 | - name: Setup Node.js 331 | uses: actions/setup-node@v4 332 | with: 333 | node-version: 20 334 | - name: Install dependencies 335 | run: npm ci 336 | 337 | - name: new-command-on-retry 338 | id: new-command-on-retry 339 | uses: ./ 340 | with: 341 | timeout_minutes: 1 342 | max_attempts: 3 343 | command: node -e "process.exit(1)" 344 | new_command_on_retry: node -e "console.log('this is the new command on retry')" 345 | 346 | - name: on-retry-cmd 347 | id: on-retry-cmd 348 | uses: ./ 349 | continue-on-error: true 350 | with: 351 | timeout_minutes: 1 352 | max_attempts: 3 353 | command: node -e "process.exit(1)" 354 | on_retry_command: node -e "console.log('this is a retry command')" 355 | 356 | - name: on-retry-cmd (on-retry fails) 357 | id: on-retry-cmd-fails 358 | uses: ./ 359 | continue-on-error: true 360 | with: 361 | timeout_minutes: 1 362 | max_attempts: 3 363 | command: node -e "process.exit(1)" 364 | on_retry_command: node -e "throw new Error('This is an on-retry command error')" 365 | 366 | # timeout tests take longer to run so run in parallel 367 | ci_integration_timeout_seconds: 368 | name: Run Integration Timeout Tests (seconds) 369 | runs-on: ubuntu-latest 370 | steps: 371 | - name: Checkout 372 | uses: actions/checkout@v4 373 | - name: Setup Node.js 374 | uses: actions/setup-node@v4 375 | with: 376 | node-version: 20 377 | - name: Install dependencies 378 | run: npm ci 379 | 380 | - name: sad-path (timeout) 381 | id: sad_path_timeout 382 | uses: ./ 383 | continue-on-error: true 384 | with: 385 | timeout_seconds: 15 386 | max_attempts: 2 387 | command: node -e "(async()=>await new Promise(r => setTimeout(r, 120000)))()" 388 | - uses: nick-fields/assert-action@v2 389 | with: 390 | expected: 2 391 | actual: ${{ steps.sad_path_timeout.outputs.total_attempts }} 392 | - uses: nick-fields/assert-action@v2 393 | with: 394 | expected: failure 395 | actual: ${{ steps.sad_path_timeout.outcome }} 396 | 397 | ci_integration_timeout_retry_on_timeout: 398 | name: Run Integration Timeout Tests (retry_on timeout) 399 | runs-on: ubuntu-latest 400 | steps: 401 | - name: Checkout 402 | uses: actions/checkout@v4 403 | - name: Setup Node.js 404 | uses: actions/setup-node@v4 405 | with: 406 | node-version: 20 407 | - name: Install dependencies 408 | run: npm ci 409 | 410 | - name: retry_on (timeout) 411 | id: retry_on_timeout 412 | uses: ./ 413 | continue-on-error: true 414 | with: 415 | timeout_seconds: 15 416 | max_attempts: 2 417 | retry_on: timeout 418 | command: node -e "(async()=>await new Promise(r => setTimeout(r, 120000)))()" 419 | - uses: nick-fields/assert-action@v2 420 | with: 421 | expected: 2 422 | actual: ${{ steps.retry_on_timeout.outputs.total_attempts }} 423 | - uses: nick-fields/assert-action@v2 424 | with: 425 | expected: failure 426 | actual: ${{ steps.retry_on_timeout.outcome }} 427 | 428 | ci_integration_timeout_retry_on_error: 429 | name: Run Integration Timeout Tests (retry_on error) 430 | runs-on: ubuntu-latest 431 | steps: 432 | - name: Checkout 433 | uses: actions/checkout@v4 434 | - name: Setup Node.js 435 | uses: actions/setup-node@v4 436 | with: 437 | node-version: 20 438 | - name: Install dependencies 439 | run: npm ci 440 | 441 | - name: retry_on (error) fails early if timeout encountered 442 | id: retry_on_error_fail 443 | uses: ./ 444 | continue-on-error: true 445 | with: 446 | timeout_seconds: 15 447 | max_attempts: 2 448 | retry_on: error 449 | command: node -e "(async()=>await new Promise(r => setTimeout(r, 120000)))()" 450 | - uses: nick-fields/assert-action@v2 451 | with: 452 | expected: 1 453 | actual: ${{ steps.retry_on_error_fail.outputs.total_attempts }} 454 | - uses: nick-fields/assert-action@v2 455 | with: 456 | expected: failure 457 | actual: ${{ steps.retry_on_error_fail.outcome }} 458 | - uses: nick-fields/assert-action@v2 459 | with: 460 | expected: 1 461 | actual: ${{ steps.retry_on_error_fail.outputs.exit_code }} 462 | 463 | ci_integration_timeout_minutes: 464 | name: Run Integration Timeout Tests (minutes) 465 | runs-on: ubuntu-latest 466 | steps: 467 | - name: Checkout 468 | uses: actions/checkout@v4 469 | - name: Setup Node.js 470 | uses: actions/setup-node@v4 471 | with: 472 | node-version: 20 473 | - name: Install dependencies 474 | run: npm ci 475 | 476 | - name: sad-path (timeout minutes) 477 | id: sad_path_timeout_minutes 478 | uses: ./ 479 | continue-on-error: true 480 | with: 481 | timeout_minutes: 1 482 | max_attempts: 2 483 | command: node -e "(async()=>await new Promise(r => setTimeout(r, 120000)))()" 484 | - uses: nick-fields/assert-action@v2 485 | with: 486 | expected: 2 487 | actual: ${{ steps.sad_path_timeout_minutes.outputs.total_attempts }} 488 | - uses: nick-fields/assert-action@v2 489 | with: 490 | expected: failure 491 | actual: ${{ steps.sad_path_timeout_minutes.outcome }} 492 | 493 | ci_windows: 494 | name: Run Windows Tests 495 | runs-on: windows-latest 496 | steps: 497 | - name: Checkout 498 | uses: actions/checkout@v4 499 | - name: Setup Node.js 500 | uses: actions/setup-node@v4 501 | with: 502 | node-version: 20 503 | - name: Install dependencies 504 | run: npm ci 505 | - name: Powershell test 506 | uses: ./ 507 | with: 508 | timeout_minutes: 1 509 | max_attempts: 2 510 | shell: powershell 511 | command: Get-ComputerInfo 512 | - name: CMD.exe test 513 | uses: ./ 514 | with: 515 | timeout_minutes: 1 516 | max_attempts: 2 517 | shell: cmd 518 | command: echo %PATH% 519 | - name: Python test 520 | uses: ./ 521 | with: 522 | timeout_minutes: 1 523 | max_attempts: 2 524 | shell: python 525 | command: print('1', '2', '3') 526 | - name: Multi-line multi-command Test 527 | uses: ./ 528 | with: 529 | timeout_minutes: 1 530 | max_attempts: 2 531 | command: | 532 | Get-ComputerInfo 533 | Get-Date 534 | - name: Multi-line single-command Test 535 | uses: ./ 536 | with: 537 | timeout_minutes: 1 538 | max_attempts: 2 539 | shell: cmd 540 | command: >- 541 | echo "this is 542 | a test" 543 | 544 | ci_all_tests_passed: 545 | name: All tests passed 546 | needs: 547 | [ 548 | ci_unit, 549 | ci_integration, 550 | ci_integration_envvar, 551 | ci_integration_large_output, 552 | ci_integration_on_retry_cmd, 553 | ci_integration_retry_wait_seconds, 554 | ci_integration_continue_on_error, 555 | ci_integration_retry_on_exit_code, 556 | ci_integration_timeout_seconds, 557 | ci_integration_timeout_minutes, 558 | ci_integration_timeout_retry_on_timeout, 559 | ci_integration_timeout_retry_on_error, 560 | ci_windows, 561 | ] 562 | runs-on: ubuntu-latest 563 | steps: 564 | - run: echo "If this is hit, all tests successfully passed" 565 | 566 | # runs on merge to default only 567 | cd: 568 | name: Publish Action 569 | needs: [ci_all_tests_passed] 570 | if: github.ref == 'refs/heads/master' 571 | runs-on: ubuntu-latest 572 | steps: 573 | - name: Checkout 574 | uses: actions/checkout@v4 575 | - name: Setup Node.js 576 | uses: actions/setup-node@v4 577 | with: 578 | node-version: 20 579 | - name: Install dependencies 580 | run: npm ci 581 | - name: Release 582 | id: semantic 583 | uses: cycjimmy/semantic-release-action@v4 584 | env: 585 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 586 | - name: Tag 587 | # only bump v# (e.g., v3) tag if semantic release action publishes any new version 588 | if: ${{ steps.semantic.outputs.new_release_major_version != '' }} 589 | run: git tag -f v${MAJOR_VERSION} && git push -f origin v${MAJOR_VERSION} 590 | env: 591 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 592 | MAJOR_VERSION: ${{ steps.semantic.outputs.new_release_major_version }} 593 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | 84 | # Gatsby files 85 | .cache/ 86 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 87 | # https://nextjs.org/blog/next-9-1#public-directory-support 88 | # public 89 | 90 | # vuepress build output 91 | .vuepress/dist 92 | 93 | # Serverless directories 94 | .serverless/ 95 | 96 | # FuseBox cache 97 | .fusebox/ 98 | 99 | # DynamoDB Local files 100 | .dynamodb/ 101 | 102 | # TernJS port file 103 | .tern-port 104 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | # lint commit message 5 | npx --no -- commitlint --config ./.config/.commitlintrc.js --edit $1 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | # run lint/styling on staged changes 5 | npx lint-staged 6 | 7 | # regenerate dist 8 | npm run prepare && git add . 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.11.0 -------------------------------------------------------------------------------- /.releaserc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | [ 4 | '@semantic-release/commit-analyzer', 5 | { 6 | releaseRules: [ 7 | { type: 'docs', scope: 'README', release: 'patch' }, 8 | { type: 'minor', release: 'minor' }, 9 | { type: 'major', release: 'major' }, 10 | { type: 'patch', release: 'patch' }, 11 | { type: 'test', release: false }, 12 | { scope: 'no-release', release: false }, 13 | ], 14 | }, 15 | ], 16 | '@semantic-release/release-notes-generator', 17 | '@semantic-release/github', 18 | ], 19 | branches: [ 20 | { name: 'master' }, 21 | { name: 'develop', channel: 'develop', prerelease: 'develop' }, // `prerelease` is set to `beta` as it is the value of `name` 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "prettier.configPath": "./.config/.prettierrc.yml", 5 | "prettier.ignorePath": "./.config/.prettierignore", 6 | "typescript.tsdk": "node_modules/typescript/lib" 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Nick Fields 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # retry 2 | 3 | Retries an Action step on failure or timeout. This is currently intended to replace the `run` step for moody commands. 4 | 5 | **NOTE:** Ownership of this project was transferred to my personal account `nick-fields` from my work account `nick-invision`. Details [here](#Ownership) 6 | 7 | --- 8 | 9 | ## Inputs 10 | 11 | ### `timeout_minutes` 12 | 13 | **Required** Minutes to wait before attempt times out. Must only specify either minutes or seconds 14 | 15 | ### `timeout_seconds` 16 | 17 | **Required** Seconds to wait before attempt times out. Must only specify either minutes or seconds 18 | 19 | ### `max_attempts` 20 | 21 | **Required** Number of attempts to make before failing the step 22 | 23 | ### `command` 24 | 25 | **Required** The command to run 26 | 27 | ### `retry_wait_seconds` 28 | 29 | **Optional** Number of seconds to wait before attempting the next retry. Defaults to `10` 30 | 31 | ### `shell` 32 | 33 | **Optional** Shell to use to execute `command`. Defaults to `powershell` on Windows, `bash` otherwise. Supports bash, python, pwsh, sh, cmd, and powershell per [docs](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell) 34 | 35 | ### `polling_interval_seconds` 36 | 37 | **Optional** Number of seconds to wait while polling for command result. Defaults to `1` 38 | 39 | ### `retry_on` 40 | 41 | **Optional** Event to retry on. Currently supports [any (default), timeout, error]. 42 | 43 | ### `warning_on_retry` 44 | 45 | **Optional** Whether to output a warning on retry, or just output to info. Defaults to `true`. 46 | 47 | ### `on_retry_command` 48 | 49 | **Optional** Command to run before a retry (such as a cleanup script). Any error thrown from retry command is caught and surfaced as a warning. 50 | 51 | ### `new_command_on_retry` 52 | 53 | **Optional** Command to run if the first attempt fails. This command will be called on all subsequent attempts. 54 | 55 | ### `continue_on_error` 56 | 57 | **Optional** Exit successfully even if an error occurs. Same as native continue-on-error behavior, but for use in composite actions. Defaults to `false` 58 | 59 | ### `retry_on_exit_code` 60 | 61 | **Optional** Specific exit code to retry on. This will only retry for the given error code and fail immediately other error codes. 62 | 63 | ## Outputs 64 | 65 | ### `total_attempts` 66 | 67 | The final number of attempts made 68 | 69 | ### `exit_code` 70 | 71 | The final exit code returned by the command 72 | 73 | ### `exit_error` 74 | 75 | The final error returned by the command 76 | 77 | ## Examples 78 | 79 | ### Shell 80 | 81 | ```yaml 82 | uses: nick-fields/retry@v3 83 | with: 84 | timeout_minutes: 10 85 | max_attempts: 3 86 | shell: pwsh 87 | command: dir 88 | ``` 89 | 90 | ### Timeout in minutes 91 | 92 | ```yaml 93 | uses: nick-fields/retry@v3 94 | with: 95 | timeout_minutes: 10 96 | max_attempts: 3 97 | command: npm run some-typically-slow-script 98 | ``` 99 | 100 | ### Timeout in seconds 101 | 102 | ```yaml 103 | uses: nick-fields/retry@v3 104 | with: 105 | timeout_seconds: 15 106 | max_attempts: 3 107 | command: npm run some-typically-fast-script 108 | ``` 109 | 110 | ### Only retry after timeout 111 | 112 | ```yaml 113 | uses: nick-fields/retry@v3 114 | with: 115 | timeout_seconds: 15 116 | max_attempts: 3 117 | retry_on: timeout 118 | command: npm run some-typically-fast-script 119 | ``` 120 | 121 | ### Only retry after error 122 | 123 | ```yaml 124 | uses: nick-fields/retry@v3 125 | with: 126 | timeout_seconds: 15 127 | max_attempts: 3 128 | retry_on: error 129 | command: npm run some-typically-fast-script 130 | ``` 131 | 132 | ### Retry using continue_on_error input (in composite action) but allow failure and do something with output 133 | 134 | ```yaml 135 | - uses: nick-fields/retry@v3 136 | id: retry 137 | with: 138 | timeout_seconds: 15 139 | max_attempts: 3 140 | continue_on_error: true 141 | command: node -e 'process.exit(99);' 142 | - name: Assert that step succeeded (despite failing command) 143 | uses: nick-fields/assert-action@v1 144 | with: 145 | expected: success 146 | actual: ${{ steps.retry.outcome }} 147 | - name: Assert that action exited with expected exit code 148 | uses: nick-fields/assert-action@v1 149 | with: 150 | expected: 99 151 | actual: ${{ steps.retry.outputs.exit_code }} 152 | ``` 153 | 154 | ### Retry using continue-on-error built-in command (in workflow action) but allow failure and do something with output 155 | 156 | ```yaml 157 | - uses: nick-fields/retry@v3 158 | id: retry 159 | # see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idcontinue-on-error 160 | continue-on-error: true 161 | with: 162 | timeout_seconds: 15 163 | max_attempts: 3 164 | retry_on: error 165 | command: node -e 'process.exit(99);' 166 | - name: Assert that action failed 167 | uses: nick-fields/assert-action@v1 168 | with: 169 | expected: failure 170 | actual: ${{ steps.retry.outcome }} 171 | - name: Assert that action exited with expected exit code 172 | uses: nick-fields/assert-action@v1 173 | with: 174 | expected: 99 175 | actual: ${{ steps.retry.outputs.exit_code }} 176 | - name: Assert that action made expected number of attempts 177 | uses: nick-fields/assert-action@v1 178 | with: 179 | expected: 3 180 | actual: ${{ steps.retry.outputs.total_attempts }} 181 | ``` 182 | 183 | ### Run script after failure but before retry 184 | 185 | ```yaml 186 | uses: nick-fields/retry@v3 187 | with: 188 | timeout_seconds: 15 189 | max_attempts: 3 190 | command: npm run some-flaky-script-that-outputs-something 191 | on_retry_command: npm run cleanup-flaky-script-output 192 | ``` 193 | 194 | ### Run different command after first failure 195 | 196 | ```yaml 197 | uses: nick-fields/retry@v3 198 | with: 199 | timeout_seconds: 15 200 | max_attempts: 3 201 | command: npx jest 202 | new_command_on_retry: npx jest --onlyFailures 203 | ``` 204 | 205 | ### Run multi-line, multi-command script 206 | 207 | ```yaml 208 | name: Multi-line multi-command Test 209 | uses: nick-fields/retry@v3 210 | with: 211 | timeout_minutes: 1 212 | max_attempts: 2 213 | command: | 214 | Get-ComputerInfo 215 | Get-Date 216 | ``` 217 | 218 | ### Run multi-line, single-command script 219 | 220 | ```yaml 221 | name: Multi-line single-command Test 222 | uses: nick-fields/retry@v3 223 | with: 224 | timeout_minutes: 1 225 | max_attempts: 2 226 | shell: cmd 227 | command: >- 228 | echo "this is 229 | a test" 230 | ``` 231 | 232 | ## Requirements 233 | 234 | NodeJS is required for this action to run. This runs without issue on all GitHub hosted runners but if you are running into issues with this on self hosted runners ensure NodeJS is installed. 235 | 236 | --- 237 | 238 | ## **Ownership** 239 | 240 | As of 2022/02/15 ownership of this project has been transferred to my personal account `nick-fields` from my work account `nick-invision` due to me leaving InVision. I am the author and have been the primary maintainer since day one and will continue to maintain this as needed. 241 | 242 | Existing workflow references to `nick-invision/retry@` no longer work and must be updated to `nick-fields/retry@`. 243 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Retry Step 2 | description: 'Retry a step on failure or timeout' 3 | inputs: 4 | timeout_minutes: 5 | description: Minutes to wait before attempt times out. Must only specify either minutes or seconds 6 | required: false 7 | timeout_seconds: 8 | description: Seconds to wait before attempt times out. Must only specify either minutes or seconds 9 | required: false 10 | max_attempts: 11 | description: Number of attempts to make before failing the step 12 | required: true 13 | default: 3 14 | command: 15 | description: The command to run 16 | required: true 17 | retry_wait_seconds: 18 | description: Number of seconds to wait before attempting the next retry 19 | required: false 20 | default: 10 21 | shell: 22 | description: Alternate shell to use (defaults to powershell on windows, bash otherwise). Supports bash, python, pwsh, sh, cmd, and powershell 23 | required: false 24 | polling_interval_seconds: 25 | description: Number of seconds to wait for each check that command has completed running 26 | required: false 27 | default: 1 28 | retry_on: 29 | description: Event to retry on. Currently supported [any, timeout, error] 30 | warning_on_retry: 31 | description: Whether to output a warning on retry, or just output to info. Defaults to true 32 | default: true 33 | on_retry_command: 34 | description: Command to run before a retry (such as a cleanup script). Any error thrown from retry command is caught and surfaced as a warning. 35 | required: false 36 | continue_on_error: 37 | description: Exits successfully even if an error occurs. Same as native continue-on-error behavior, but for use in composite actions. Default is false 38 | default: false 39 | new_command_on_retry: 40 | description: Command to run if the first attempt fails. This command will be called on all subsequent attempts. 41 | required: false 42 | retry_on_exit_code: 43 | description: Specific exit code to retry on. This will only retry for the given error code and fail immediately other error codes. 44 | required: false 45 | outputs: 46 | total_attempts: 47 | description: The final number of attempts made 48 | exit_code: 49 | description: The final exit code returned by the command 50 | exit_error: 51 | description: The final error returned by the command 52 | runs: 53 | using: 'node20' 54 | main: 'dist/index.js' 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "retry", 3 | "version": "0.0.0-managed-by-semantic-release", 4 | "description": "Retries a GitHub Action step on failure or timeout.", 5 | "scripts": { 6 | "lint:base": "eslint --config ./.config/.eslintrc.js ", 7 | "lint": "npm run lint:base -- .", 8 | "local": "npm run prepare && node -r dotenv/config ./dist/index.js", 9 | "prepare": "ncc build src/index.ts && husky install", 10 | "style:base": "prettier --config ./.config/.prettierrc.yml --ignore-path ./.config/.prettierignore --write ", 11 | "style": "npm run style:base -- .", 12 | "test": "jest -c ./.config/jest.config.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/nick-fields/retry.git" 17 | }, 18 | "keywords": [], 19 | "author": "Nick Fields", 20 | "license": "ISC", 21 | "bugs": { 22 | "url": "https://github.com/nick-fields/retry/issues" 23 | }, 24 | "homepage": "https://github.com/nick-fields/retry#readme", 25 | "dependencies": { 26 | "@actions/core": "^1.10.0", 27 | "milliseconds": "^1.0.3", 28 | "tree-kill": "^1.2.2" 29 | }, 30 | "devDependencies": { 31 | "@commitlint/cli": "^16.2.3", 32 | "@commitlint/config-conventional": "^16.2.1", 33 | "@semantic-release/changelog": "^6.0.3", 34 | "@semantic-release/git": "^10.0.1", 35 | "@types/jest": "^28.1.6", 36 | "@types/milliseconds": "0.0.30", 37 | "@types/node": "^16.11.7", 38 | "@typescript-eslint/eslint-plugin": "^5.32.0", 39 | "@typescript-eslint/parser": "^5.32.0", 40 | "@vercel/ncc": "^0.38.1", 41 | "dotenv": "8.2.0", 42 | "eslint": "^8.21.0", 43 | "eslint-config-prettier": "^8.5.0", 44 | "husky": "^8.0.1", 45 | "jest": "^28.1.3", 46 | "lint-staged": "^13.0.3", 47 | "prettier": "^2.7.1", 48 | "semantic-release": "^24.2.3", 49 | "ts-jest": "^28.0.7", 50 | "ts-node": "9.0.0", 51 | "typescript": "^4.7.4", 52 | "yaml-lint": "^1.7.0" 53 | }, 54 | "lint-staged": { 55 | "**/*.ts": [ 56 | "npm run style:base --", 57 | "npm run lint:base --" 58 | ], 59 | "**/*.{md,yaml,yml}": [ 60 | "npm run style:base --" 61 | ], 62 | "**/*.{yaml,yml}": [ 63 | "npx yamllint " 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | # these are the bare minimum envvars required 2 | INPUT_TIMEOUT_MINUTES=1 3 | INPUT_MAX_ATTEMPTS=3 4 | INPUT_COMMAND="node -e 'process.exit(99)'" 5 | INPUT_CONTINUE_ON_ERROR=false 6 | 7 | # these are optional 8 | #INPUT_RETRY_WAIT_SECONDS=10 9 | #SHELL=pwsh 10 | #INPUT_POLLING_INTERVAL_SECONDS=1 11 | #INPUT_RETRY_ON=any 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { error, warning, info, debug, setOutput } from '@actions/core'; 2 | import { execSync, spawn } from 'child_process'; 3 | import ms from 'milliseconds'; 4 | import kill from 'tree-kill'; 5 | 6 | import { getInputs, getTimeout, Inputs, validateInputs } from './inputs'; 7 | import { retryWait, wait } from './util'; 8 | 9 | const OS = process.platform; 10 | const OUTPUT_TOTAL_ATTEMPTS_KEY = 'total_attempts'; 11 | const OUTPUT_EXIT_CODE_KEY = 'exit_code'; 12 | const OUTPUT_EXIT_ERROR_KEY = 'exit_error'; 13 | 14 | let exit: number; 15 | let done: boolean; 16 | 17 | function getExecutable(inputs: Inputs): string { 18 | if (!inputs.shell) { 19 | return OS === 'win32' ? 'powershell' : 'bash'; 20 | } 21 | 22 | let executable: string; 23 | const shellName = inputs.shell.split(' ')[0]; 24 | 25 | switch (shellName) { 26 | case 'bash': 27 | case 'python': 28 | case 'pwsh': { 29 | executable = inputs.shell; 30 | break; 31 | } 32 | case 'sh': { 33 | if (OS === 'win32') { 34 | throw new Error(`Shell ${shellName} not allowed on OS ${OS}`); 35 | } 36 | executable = inputs.shell; 37 | break; 38 | } 39 | case 'cmd': 40 | case 'powershell': { 41 | if (OS !== 'win32') { 42 | throw new Error(`Shell ${shellName} not allowed on OS ${OS}`); 43 | } 44 | executable = shellName + '.exe' + inputs.shell.replace(shellName, ''); 45 | break; 46 | } 47 | default: { 48 | throw new Error( 49 | `Shell ${shellName} not supported. See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell for supported shells` 50 | ); 51 | } 52 | } 53 | return executable; 54 | } 55 | 56 | async function runRetryCmd(inputs: Inputs): Promise { 57 | // if no retry script, just continue 58 | if (!inputs.on_retry_command) { 59 | return; 60 | } 61 | 62 | try { 63 | await execSync(inputs.on_retry_command, { stdio: 'inherit' }); 64 | // eslint-disable-next-line 65 | } catch (error: any) { 66 | info(`WARNING: Retry command threw the error ${error.message}`); 67 | } 68 | } 69 | 70 | async function runCmd(attempt: number, inputs: Inputs) { 71 | const end_time = Date.now() + getTimeout(inputs); 72 | const executable = getExecutable(inputs); 73 | 74 | exit = 0; 75 | done = false; 76 | let timeout = false; 77 | 78 | debug(`Running command ${inputs.command} on ${OS} using shell ${executable}`); 79 | const child = 80 | attempt > 1 && inputs.new_command_on_retry 81 | ? spawn(inputs.new_command_on_retry, { shell: executable }) 82 | : spawn(inputs.command, { shell: executable }); 83 | 84 | child.stdout?.on('data', (data) => { 85 | process.stdout.write(data); 86 | }); 87 | child.stderr?.on('data', (data) => { 88 | process.stdout.write(data); 89 | }); 90 | 91 | child.on('exit', (code, signal) => { 92 | debug(`Code: ${code}`); 93 | debug(`Signal: ${signal}`); 94 | 95 | // timeouts are killed manually 96 | if (signal === 'SIGTERM') { 97 | return; 98 | } 99 | 100 | // On Windows signal is null. 101 | if (timeout) { 102 | return; 103 | } 104 | 105 | if (code && code > 0) { 106 | exit = code; 107 | } 108 | 109 | done = true; 110 | }); 111 | 112 | do { 113 | await wait(ms.seconds(inputs.polling_interval_seconds)); 114 | } while (Date.now() < end_time && !done); 115 | 116 | if (!done && child.pid) { 117 | timeout = true; 118 | kill(child.pid); 119 | await retryWait(ms.seconds(inputs.retry_wait_seconds)); 120 | throw new Error(`Timeout of ${getTimeout(inputs)}ms hit`); 121 | } else if (exit > 0) { 122 | await retryWait(ms.seconds(inputs.retry_wait_seconds)); 123 | throw new Error(`Child_process exited with error code ${exit}`); 124 | } else { 125 | return; 126 | } 127 | } 128 | 129 | async function runAction(inputs: Inputs) { 130 | await validateInputs(inputs); 131 | 132 | for (let attempt = 1; attempt <= inputs.max_attempts; attempt++) { 133 | info(`::group::Attempt ${attempt}`); 134 | try { 135 | // just keep overwriting attempts output 136 | setOutput(OUTPUT_TOTAL_ATTEMPTS_KEY, attempt); 137 | await runCmd(attempt, inputs); 138 | info(`Command completed after ${attempt} attempt(s).`); 139 | break; 140 | // eslint-disable-next-line 141 | } catch (error: any) { 142 | if (attempt === inputs.max_attempts) { 143 | throw new Error(`Final attempt failed. ${error.message}`); 144 | } else if (!done && inputs.retry_on === 'error') { 145 | // error: timeout 146 | throw error; 147 | } else if (inputs.retry_on_exit_code && inputs.retry_on_exit_code !== exit) { 148 | throw error; 149 | } else if (exit > 0 && inputs.retry_on === 'timeout') { 150 | // error: error 151 | throw error; 152 | } else { 153 | await runRetryCmd(inputs); 154 | if (inputs.warning_on_retry) { 155 | warning(`Attempt ${attempt} failed. Reason: ${error.message}`); 156 | } else { 157 | info(`Attempt ${attempt} failed. Reason: ${error.message}`); 158 | } 159 | } 160 | } finally { 161 | info(`::endgroup::`); 162 | } 163 | } 164 | } 165 | 166 | const inputs = getInputs(); 167 | 168 | runAction(inputs) 169 | .then(() => { 170 | setOutput(OUTPUT_EXIT_CODE_KEY, 0); 171 | process.exit(0); // success 172 | }) 173 | .catch((err) => { 174 | // exact error code if available, otherwise just 1 175 | const exitCode = exit > 0 ? exit : 1; 176 | 177 | if (inputs.continue_on_error) { 178 | warning(err.message); 179 | } else { 180 | error(err.message); 181 | } 182 | 183 | // these can be helpful to know if continue-on-error is true 184 | setOutput(OUTPUT_EXIT_ERROR_KEY, err.message); 185 | setOutput(OUTPUT_EXIT_CODE_KEY, exitCode); 186 | 187 | // if continue_on_error, exit with exact error code else exit gracefully 188 | // mimics native continue-on-error that is not supported in composite actions 189 | process.exit(inputs.continue_on_error ? 0 : exitCode); 190 | }); 191 | -------------------------------------------------------------------------------- /src/inputs.ts: -------------------------------------------------------------------------------- 1 | import { getInput } from '@actions/core'; 2 | import ms from 'milliseconds'; 3 | 4 | export interface Inputs { 5 | timeout_minutes: number | undefined; 6 | timeout_seconds: number | undefined; 7 | max_attempts: number; 8 | command: string; 9 | retry_wait_seconds: number; 10 | shell: string | undefined; 11 | polling_interval_seconds: number; 12 | retry_on: string | undefined; 13 | warning_on_retry: boolean; 14 | on_retry_command: string | undefined; 15 | continue_on_error: boolean; 16 | new_command_on_retry: string | undefined; 17 | retry_on_exit_code: number | undefined; 18 | } 19 | 20 | export function getInputNumber(id: string, required: boolean): number | undefined { 21 | const input = getInput(id, { required }); 22 | const num = Number.parseInt(input); 23 | 24 | // empty is ok 25 | if (!input && !required) { 26 | return; 27 | } 28 | 29 | if (!Number.isInteger(num)) { 30 | throw `Input ${id} only accepts numbers. Received ${input}`; 31 | } 32 | 33 | return num; 34 | } 35 | 36 | export function getInputBoolean(id: string): boolean { 37 | const input = getInput(id); 38 | 39 | if (!['true', 'false'].includes(input.toLowerCase())) { 40 | throw `Input ${id} only accepts boolean values. Received ${input}`; 41 | } 42 | return input.toLowerCase() === 'true'; 43 | } 44 | 45 | export async function validateInputs(inputs: Inputs) { 46 | if ( 47 | (!inputs.timeout_minutes && !inputs.timeout_seconds) || 48 | (inputs.timeout_minutes && inputs.timeout_seconds) 49 | ) { 50 | throw new Error('Must specify either timeout_minutes or timeout_seconds inputs'); 51 | } 52 | } 53 | 54 | export function getTimeout(inputs: Inputs): number { 55 | if (inputs.timeout_minutes) { 56 | return ms.minutes(inputs.timeout_minutes); 57 | } else if (inputs.timeout_seconds) { 58 | return ms.seconds(inputs.timeout_seconds); 59 | } 60 | 61 | throw new Error('Must specify either timeout_minutes or timeout_seconds inputs'); 62 | } 63 | 64 | export function getInputs(): Inputs { 65 | const timeout_minutes = getInputNumber('timeout_minutes', false); 66 | const timeout_seconds = getInputNumber('timeout_seconds', false); 67 | const max_attempts = getInputNumber('max_attempts', true) || 3; 68 | const command = getInput('command', { required: true }); 69 | const retry_wait_seconds = getInputNumber('retry_wait_seconds', false) || 10; 70 | const shell = getInput('shell'); 71 | const polling_interval_seconds = getInputNumber('polling_interval_seconds', false) || 1; 72 | const retry_on = getInput('retry_on') || 'any'; 73 | const warning_on_retry = getInput('warning_on_retry').toLowerCase() === 'true'; 74 | const on_retry_command = getInput('on_retry_command'); 75 | const continue_on_error = getInputBoolean('continue_on_error'); 76 | const new_command_on_retry = getInput('new_command_on_retry'); 77 | const retry_on_exit_code = getInputNumber('retry_on_exit_code', false); 78 | 79 | return { 80 | timeout_minutes, 81 | timeout_seconds, 82 | max_attempts, 83 | command, 84 | retry_wait_seconds, 85 | shell, 86 | polling_interval_seconds, 87 | retry_on, 88 | warning_on_retry, 89 | on_retry_command, 90 | continue_on_error, 91 | new_command_on_retry, 92 | retry_on_exit_code, 93 | }; 94 | } 95 | -------------------------------------------------------------------------------- /src/util.test.ts: -------------------------------------------------------------------------------- 1 | import 'jest'; 2 | import { getHeapStatistics } from 'v8'; 3 | 4 | import { wait } from './util'; 5 | 6 | // otherwise, TypeError: Cannot assign to read only property 'performance' of object '[object global]' 7 | Object.defineProperty(global, 'performance', { 8 | writable: true, 9 | }); 10 | 11 | // mocks the setTimeout function, see https://jestjs.io/docs/timer-mocks 12 | jest.useFakeTimers(); 13 | jest.spyOn(global, 'setTimeout'); 14 | 15 | describe('util', () => { 16 | test('wait', async () => { 17 | const waitTime = 1000; 18 | wait(waitTime); 19 | expect(setTimeout).toHaveBeenCalledTimes(1); 20 | expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), waitTime); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { debug } from '@actions/core'; 2 | 3 | export async function wait(ms: number) { 4 | return new Promise((r) => setTimeout(r, ms)); 5 | } 6 | 7 | export async function retryWait(retryWaitSeconds: number) { 8 | const waitStart = Date.now(); 9 | await wait(retryWaitSeconds); 10 | debug(`Waited ${Date.now() - waitStart}ms`); 11 | debug(`Configured wait: ${retryWaitSeconds}ms`); 12 | } 13 | -------------------------------------------------------------------------------- /test-data/large-output/Makefile: -------------------------------------------------------------------------------- 1 | SHELL = bash 2 | 3 | # this tests fix for the following issues 4 | # https://github.com/nick-fields/retry/issues/76 5 | # https://github.com/nick-fields/retry/issues/84 6 | 7 | bytes-%: 8 | for i in {1..$*}; do cat kibibyte.txt; done; exit 2 9 | .PHONY: bytes-% 10 | 11 | lines-%: 12 | for i in {1..$*}; do echo a; done; exit 2 13 | .PHONY: lines-% 14 | -------------------------------------------------------------------------------- /test-data/large-output/kibibyte.txt: -------------------------------------------------------------------------------- 1 | 1: 0000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 2 | 2: 0081 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 3 | 3: 0162 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 4 | 4: 243 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 5 | 5: 324 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 6 | 6: 405 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 7 | 7: 486 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 8 | 8: 567 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 9 | 9: 648 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 10 | a: 729 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 11 | b: 810 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 12 | c: 891 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 13 | d: 972 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | "noEmit": true /* Do not emit outputs. */, 7 | 8 | /* Strict Type-Checking Options */ 9 | "strict": true /* Enable all strict type-checking options. */, 10 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 11 | 12 | /* Advanced Options */ 13 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 14 | }, 15 | "exclude": ["**/__tests__", "**/__mocks__"] 16 | } 17 | --------------------------------------------------------------------------------